Initial commit

This commit is contained in:
2026-02-08 10:09:48 +01:00
commit d165506add
58 changed files with 9879 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# `CreateMaterializedViewOp` class
Alembic operation to create a materialized view.
```python
from sqlalchemy_pgview.alembic import CreateMaterializedViewOp
```
::: sqlalchemy_pgview.alembic.CreateMaterializedViewOp
options:
members:
- create_materialized_view
- reverse

View File

@@ -0,0 +1,14 @@
# `CreateViewOp` class
Alembic operation to create a view.
```python
import sqlalchemy_pgview.alembic # Registers operations
from sqlalchemy_pgview.alembic import CreateViewOp
```
::: sqlalchemy_pgview.alembic.CreateViewOp
options:
members:
- create_view
- reverse

View File

@@ -0,0 +1,12 @@
# `DropMaterializedViewOp` class
Alembic operation to drop a materialized view.
```python
from sqlalchemy_pgview.alembic import DropMaterializedViewOp
```
::: sqlalchemy_pgview.alembic.DropMaterializedViewOp
options:
members:
- drop_materialized_view

View File

@@ -0,0 +1,12 @@
# `DropViewOp` class
Alembic operation to drop a view.
```python
from sqlalchemy_pgview.alembic import DropViewOp
```
::: sqlalchemy_pgview.alembic.DropViewOp
options:
members:
- drop_view

View File

@@ -0,0 +1,12 @@
# `RefreshMaterializedViewOp` class
Alembic operation to refresh a materialized view.
```python
from sqlalchemy_pgview.alembic import RefreshMaterializedViewOp
```
::: sqlalchemy_pgview.alembic.RefreshMaterializedViewOp
options:
members:
- refresh_materialized_view

View File

@@ -0,0 +1,9 @@
# `CreateMaterializedView` class
DDL element to create a PostgreSQL materialized view.
```python
from sqlalchemy_pgview import CreateMaterializedView
```
::: sqlalchemy_pgview.CreateMaterializedView

View File

@@ -0,0 +1,9 @@
# `CreateView` class
DDL element to create a PostgreSQL view.
```python
from sqlalchemy_pgview import CreateView
```
::: sqlalchemy_pgview.CreateView

View File

@@ -0,0 +1,12 @@
# `DropMaterializedView` class
DDL element to drop a PostgreSQL materialized view.
```python
from sqlalchemy_pgview import DropMaterializedView
```
::: sqlalchemy_pgview.DropMaterializedView
options:
members:
- fullname

12
docs/api/ddl/drop-view.md Normal file
View File

@@ -0,0 +1,12 @@
# `DropView` class
DDL element to drop a PostgreSQL view.
```python
from sqlalchemy_pgview import DropView
```
::: sqlalchemy_pgview.DropView
options:
members:
- fullname

View File

@@ -0,0 +1,12 @@
# `RefreshMaterializedView` class
DDL element to refresh a PostgreSQL materialized view.
```python
from sqlalchemy_pgview import RefreshMaterializedView
```
::: sqlalchemy_pgview.RefreshMaterializedView
options:
members:
- fullname

View File

@@ -0,0 +1,7 @@
# `get_all_views`
```python
from sqlalchemy_pgview import get_all_views
```
::: sqlalchemy_pgview.get_all_views

View File

@@ -0,0 +1,7 @@
# `get_dependency_order`
```python
from sqlalchemy_pgview import get_dependency_order
```
::: sqlalchemy_pgview.get_dependency_order

View File

@@ -0,0 +1,7 @@
# `get_reverse_dependencies`
```python
from sqlalchemy_pgview import get_reverse_dependencies
```
::: sqlalchemy_pgview.get_reverse_dependencies

View File

@@ -0,0 +1,7 @@
# `get_view_definition`
```python
from sqlalchemy_pgview import get_view_definition
```
::: sqlalchemy_pgview.get_view_definition

View File

@@ -0,0 +1,7 @@
# `get_view_dependencies`
```python
from sqlalchemy_pgview import get_view_dependencies
```
::: sqlalchemy_pgview.get_view_dependencies

View File

@@ -0,0 +1,17 @@
# `ViewDependency` class
Represents a dependency between views.
```python
from sqlalchemy_pgview import ViewDependency
```
::: sqlalchemy_pgview.ViewDependency
options:
members:
- dependent_view
- dependent_schema
- referenced_view
- referenced_schema
- dependent_fullname
- referenced_fullname

View File

@@ -0,0 +1,16 @@
# `ViewInfo` class
Information about a view from the database.
```python
from sqlalchemy_pgview import ViewInfo
```
::: sqlalchemy_pgview.ViewInfo
options:
members:
- name
- schema
- definition
- is_materialized
- fullname

View File

@@ -0,0 +1,9 @@
# `AutoRefreshContext` class
Context manager for auto-refreshing materialized views when using SQLAlchemy Core.
```python
from sqlalchemy_pgview import AutoRefreshContext
```
::: sqlalchemy_pgview.AutoRefreshContext

View File

@@ -0,0 +1,7 @@
# `get_materialized_views`
```python
from sqlalchemy_pgview import get_materialized_views
```
::: sqlalchemy_pgview.get_materialized_views

View File

@@ -0,0 +1,7 @@
# `get_views`
```python
from sqlalchemy_pgview import get_views
```
::: sqlalchemy_pgview.get_views

View File

@@ -0,0 +1,13 @@
# `MaterializedView` class
Here's the reference for the `MaterializedView` class, which extends `View` with materialized view features.
```python
from sqlalchemy_pgview import MaterializedView
```
::: sqlalchemy_pgview.MaterializedView
options:
members:
- refresh
- auto_refresh_on

17
docs/api/views/view.md Normal file
View File

@@ -0,0 +1,17 @@
# `View` class
Here's the reference for the `View` class, with all its parameters, attributes and methods.
You can import it directly from `sqlalchemy_pgview`:
```python
from sqlalchemy_pgview import View
```
::: sqlalchemy_pgview.View
options:
members:
- fullname
- columns
- as_table
- as_from_clause

352
docs/guide/alembic.md Normal file
View File

@@ -0,0 +1,352 @@
# Alembic Migrations
SQLAlchemy-PGView provides Alembic operations for managing views in database migrations, including autogenerate support for automatic view detection.
## Setup
Install with Alembic support:
```bash
pip install sqlalchemy-pgview[alembic]
```
In your `env.py`, import the alembic module to register comparators and renderers:
```python
# env.py
from alembic import context
from sqlalchemy import engine_from_config
import sqlalchemy_pgview.alembic # Registers autogenerate support
# Import your models/views
from myapp.models import metadata
```
## Autogenerate Support
When you run `alembic revision --autogenerate`, Alembic will:
1. **Detect new views**: Views in metadata but not in database → generates `create_view`/`create_materialized_view`
2. **Detect removed views**: Views in database but not in metadata → generates `drop_view`/`drop_materialized_view`
3. **Detect changed views**: Views with different definitions → generates drop + create
```bash
# Generate migration with view changes
alembic revision --autogenerate -m "add user stats view"
```
Register views with metadata to enable detection:
```python
from sqlalchemy import MetaData, Table, Column, Integer, String, select
from sqlalchemy_pgview import View, MaterializedView
metadata = MetaData()
users = Table("users", metadata, ...)
# These views will be detected by autogenerate
active_users = View(
"active_users",
select(users.c.id, users.c.name).where(users.c.is_active == True),
metadata=metadata,
)
user_stats = MaterializedView(
"user_stats",
select(users.c.id, users.c.name),
metadata=metadata,
)
```
Generated migration example:
```python
"""add user stats view
Revision ID: abc123
"""
from alembic import op
import sqlalchemy_pgview.alembic
def upgrade():
op.create_view(
"active_users",
"SELECT users.id, users.name FROM users WHERE users.is_active = true"
)
op.create_materialized_view(
"user_stats",
"SELECT users.id, users.name FROM users",
with_data=True
)
def downgrade():
op.drop_materialized_view("user_stats")
op.drop_view("active_users")
```
## Creating Views
```python
def upgrade():
# Regular view
op.create_view(
"active_users",
"""
SELECT id, name, email
FROM users
WHERE is_active = true
""",
)
# With schema
op.create_view(
"user_metrics",
"""
SELECT
user_id,
COUNT(*) as order_count,
SUM(total) as total_spent
FROM orders
GROUP BY user_id
""",
schema="analytics",
)
# CREATE OR REPLACE (won't fail if exists)
op.create_view(
"user_stats",
"SELECT id, name, created_at FROM users",
or_replace=True,
)
def downgrade():
op.drop_view("user_stats")
op.drop_view("user_metrics", schema="analytics")
op.drop_view("active_users")
```
## Creating Materialized Views
```python
def upgrade():
# With data populated immediately
op.create_materialized_view(
"monthly_revenue",
"""
SELECT
date_trunc('month', created_at) as month,
SUM(total) as revenue
FROM orders
GROUP BY date_trunc('month', created_at)
""",
with_data=True,
)
# Without initial data (populate later)
op.create_materialized_view(
"large_summary",
"SELECT ...",
with_data=False,
)
def downgrade():
op.drop_materialized_view("large_summary")
op.drop_materialized_view("monthly_revenue")
```
## Dropping Views
```python
def upgrade():
# Regular view
op.drop_view("old_view")
# With options
op.drop_view("another_view", if_exists=True, cascade=True)
# Materialized view
op.drop_materialized_view("old_mv", if_exists=True, cascade=True)
```
## Refreshing Materialized Views
Use in data migrations:
```python
def upgrade():
# After data changes, refresh the view
op.refresh_materialized_view("monthly_revenue")
# Concurrent refresh (requires unique index)
op.refresh_materialized_view("monthly_revenue", concurrently=True)
```
## Modifying a View
Views can't be altered - drop and recreate:
```python
def upgrade():
op.drop_view("user_stats")
op.create_view(
"user_stats",
"""
SELECT
u.id,
u.name,
u.email, -- Added column
COUNT(o.id) as order_count,
COALESCE(SUM(o.total), 0) as total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name, u.email
""",
)
def downgrade():
op.drop_view("user_stats")
op.create_view(
"user_stats",
"""
SELECT
u.id,
u.name,
COUNT(o.id) as order_count,
COALESCE(SUM(o.total), 0) as total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
""",
)
```
!!! tip "Use OR REPLACE When Possible"
If you're only changing the query (not column names/types), use `or_replace=True`:
```python
def upgrade():
op.create_view(
"user_stats",
"SELECT ... (new query)",
or_replace=True,
)
```
## Dependent Views
Drop dependents first, recreate in order:
```python
def upgrade():
# Drop in reverse dependency order
op.drop_view("derived_view", if_exists=True)
op.drop_view("base_view", if_exists=True)
# Recreate base view with changes
op.create_view("base_view", "SELECT ...")
# Recreate derived view
op.create_view("derived_view", "SELECT * FROM base_view WHERE ...")
def downgrade():
op.drop_view("derived_view")
op.drop_view("base_view")
op.create_view("base_view", "SELECT ... (old query)")
op.create_view("derived_view", "SELECT ... (old query)")
```
## Converting Table to Materialized View
```python
def upgrade():
# Create materialized view from table data
op.create_materialized_view(
"summary_mv",
"""
SELECT
category,
COUNT(*) as count,
SUM(amount) as total
FROM transactions
GROUP BY category
""",
with_data=True,
)
# Optionally drop the old summary table
op.drop_table("summary_table")
def downgrade():
# Recreate table
op.create_table(
"summary_table",
sa.Column("category", sa.String(50)),
sa.Column("count", sa.Integer),
sa.Column("total", sa.Numeric(10, 2)),
)
# Populate from materialized view
op.execute("""
INSERT INTO summary_table
SELECT * FROM summary_mv
""")
op.drop_materialized_view("summary_mv")
```
## Complete Example
```python
"""Add analytics views
Revision ID: abc123
"""
from alembic import op
import sqlalchemy_pgview.alembic
revision = 'abc123'
down_revision = 'xyz789'
def upgrade():
# Create schema
op.execute("CREATE SCHEMA IF NOT EXISTS analytics")
# Regular view for real-time queries
op.create_view(
"active_orders",
"""
SELECT *
FROM orders
WHERE status = 'pending'
""",
schema="analytics",
)
# Materialized view for reports
op.create_materialized_view(
"daily_revenue",
"""
SELECT
date_trunc('day', created_at) as day,
COUNT(*) as orders,
SUM(total) as revenue
FROM orders
WHERE status = 'completed'
GROUP BY date_trunc('day', created_at)
""",
schema="analytics",
with_data=True,
)
# Create index for concurrent refresh
op.execute("""
CREATE UNIQUE INDEX idx_daily_revenue_day
ON analytics.daily_revenue (day)
""")
def downgrade():
op.drop_materialized_view("daily_revenue", schema="analytics")
op.drop_view("active_orders", schema="analytics")
op.execute("DROP SCHEMA IF EXISTS analytics")
```

219
docs/guide/async.md Normal file
View File

@@ -0,0 +1,219 @@
# Async Support
SQLAlchemy-PGView works with SQLAlchemy's async engine using `asyncpg`.
## Setup
Install asyncpg:
```bash
pip install asyncpg
```
Create an async engine:
```python
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
```
## Creating Views (Async)
```python
from sqlalchemy import select, func
from sqlalchemy_pgview import View, CreateView, DropView
user_stats = View(
"user_stats",
select(
users.c.id,
users.c.name,
func.count(orders.c.id).label("order_count"),
)
.join(orders)
.group_by(users.c.id, users.c.name),
)
async with engine.begin() as conn:
# Create view
await conn.execute(CreateView(user_stats, or_replace=True))
# Query view
result = await conn.execute(select(user_stats.as_table()))
rows = result.fetchall()
# Drop view
await conn.execute(DropView(user_stats, if_exists=True))
```
## Materialized Views (Async)
```python
from sqlalchemy_pgview import (
MaterializedView,
CreateMaterializedView,
RefreshMaterializedView,
DropMaterializedView,
)
monthly_stats = MaterializedView(
"monthly_stats",
select(
func.date_trunc('month', orders.c.created_at).label("month"),
func.sum(orders.c.total).label("revenue"),
).group_by(func.date_trunc('month', orders.c.created_at)),
with_data=True,
)
async with engine.begin() as conn:
# Create
await conn.execute(CreateMaterializedView(monthly_stats))
# Query
result = await conn.execute(select(monthly_stats.as_table()))
rows = result.fetchall()
# Refresh
await conn.execute(RefreshMaterializedView(monthly_stats))
# Drop
await conn.execute(DropMaterializedView(monthly_stats, if_exists=True))
```
## Refreshing with .refresh() Method
The `.refresh()` method is synchronous. Use `run_sync` to call it from async code:
```python
async with engine.begin() as conn:
# Use run_sync for the synchronous refresh method
def refresh_sync(sync_conn):
monthly_stats.refresh(sync_conn)
await conn.run_sync(refresh_sync)
```
Or use the DDL directly:
```python
async with engine.begin() as conn:
await conn.execute(RefreshMaterializedView(monthly_stats, concurrently=True))
```
!!! tip "Prefer DDL Classes for Async"
For async code, prefer using `RefreshMaterializedView` DDL class over the `.refresh()` method to avoid `run_sync` overhead.
## Dependency Functions (Async)
The dependency tracking functions are synchronous. Use `run_sync`:
```python
from sqlalchemy_pgview import get_all_views, get_view_definition
async with engine.connect() as conn:
# Get all views
views = await conn.run_sync(lambda c: get_all_views(c))
for view in views:
print(f"{view.fullname}: {'MV' if view.is_materialized else 'V'}")
# Get view definition
definition = await conn.run_sync(
lambda c: get_view_definition(c, "user_stats")
)
```
## Sync vs Async Operations
| Operation | Sync | Async |
|-----------|------|-------|
| DDL (CREATE/DROP) | Direct | Direct |
| Queries | Direct | Direct |
| `.refresh()` method | Direct | Use `run_sync` |
| Dependency functions | Direct | Use `run_sync` |
## Complete Example
```python
import asyncio
from decimal import Decimal
from sqlalchemy import Column, Integer, String, Numeric, ForeignKey, MetaData, Table, select, func
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy_pgview import (
View,
MaterializedView,
CreateView,
CreateMaterializedView,
RefreshMaterializedView,
DropView,
DropMaterializedView,
)
async def main():
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
metadata = MetaData()
users = Table(
"users", metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100)),
)
orders = Table(
"orders", metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("users.id")),
Column("total", Numeric(10, 2)),
)
# Create tables
async with engine.begin() as conn:
await conn.run_sync(metadata.create_all)
# Define views
user_summary = View(
"user_summary",
select(
users.c.id,
users.c.name,
func.count(orders.c.id).label("order_count"),
)
.select_from(users.outerjoin(orders))
.group_by(users.c.id, users.c.name),
)
revenue_mv = MaterializedView(
"revenue_summary",
select(func.sum(orders.c.total).label("total_revenue")),
with_data=True,
)
async with engine.begin() as conn:
# Create views
await conn.execute(CreateView(user_summary, or_replace=True))
await conn.execute(CreateMaterializedView(revenue_mv))
# Query regular view (always current)
result = await conn.execute(select(user_summary.as_table()))
print("User Summary:", result.fetchall())
# Query materialized view
result = await conn.execute(select(revenue_mv.as_table()))
print("Revenue:", result.fetchone())
# Refresh materialized view
await conn.execute(RefreshMaterializedView(revenue_mv))
# Cleanup
await conn.execute(DropMaterializedView(revenue_mv, if_exists=True))
await conn.execute(DropView(user_summary, if_exists=True))
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())
```

207
docs/guide/dependencies.md Normal file
View File

@@ -0,0 +1,207 @@
# Dependency Tracking
SQLAlchemy-PGView can query PostgreSQL system catalogs to discover view dependencies, helping you manage complex view hierarchies.
## Why Track Dependencies?
- **Safe migrations**: Know which views to drop/recreate when modifying a base view
- **Impact analysis**: Understand what breaks if you change a table or view
- **Correct ordering**: Create views in the right order during deployment
## Getting All Views
```python
from sqlalchemy_pgview import get_all_views
with engine.connect() as conn:
views = get_all_views(conn)
for view in views:
print(f"{view.schema}.{view.name}")
print(f" Materialized: {view.is_materialized}")
print(f" Definition: {view.definition[:50]}...")
# Only views in 'analytics' schema
views = get_all_views(conn, schema="analytics")
# Exclude materialized views
views = get_all_views(conn, include_materialized=False)
```
## Getting View Definition
```python
from sqlalchemy_pgview import get_view_definition
with engine.connect() as conn:
definition = get_view_definition(conn, "user_stats")
print(definition)
# SELECT u.id, u.name, count(o.id) AS order_count
# FROM users u LEFT JOIN orders o ON u.id = o.user_id
# GROUP BY u.id, u.name
```
## Direct Dependencies
Find what a view depends on:
```python
from sqlalchemy_pgview import get_view_dependencies
with engine.connect() as conn:
deps = get_view_dependencies(conn, "user_order_summary")
for dep in deps:
print(f"{dep.dependent_fullname} depends on {dep.referenced_fullname}")
```
## Reverse Dependencies
Find what depends on a view (impact analysis):
```python
from sqlalchemy_pgview import get_reverse_dependencies
with engine.connect() as conn:
# What views depend on 'users' table/view?
dependents = get_reverse_dependencies(conn, "users")
print("Views that depend on 'users':")
for view in dependents:
print(f" - {view.fullname}")
```
## Dependency Order
Get views sorted by dependencies (dependencies first):
```python
from sqlalchemy_pgview import get_dependency_order
with engine.connect() as conn:
ordered_views = get_dependency_order(conn)
print("Views in creation order:")
for i, view in enumerate(ordered_views, 1):
print(f"{i}. {view.fullname}")
```
This is useful for:
- **Creating views**: Process in order to avoid "relation does not exist" errors
- **Dropping views**: Process in reverse order to avoid dependency errors
## Safe View Modification
```python
from sqlalchemy_pgview import (
get_reverse_dependencies,
get_view_definition,
DropView,
CreateView,
)
def modify_view_safely(conn, view_name, new_definition):
"""Modify a view by dropping/recreating it and all dependents."""
# Get all views that depend on this one
dependents = get_reverse_dependencies(conn, view_name)
# Save their definitions
saved_definitions = {}
for dep in dependents:
saved_definitions[dep.fullname] = get_view_definition(
conn, dep.name, dep.schema
)
# Drop dependents in reverse order
for dep in reversed(dependents):
conn.execute(DropView(dep.name, schema=dep.schema, if_exists=True))
# Drop and recreate the target view
conn.execute(DropView(view_name, if_exists=True))
conn.execute(text(f"CREATE VIEW {view_name} AS {new_definition}"))
# Recreate dependents in order
for dep in dependents:
definition = saved_definitions[dep.fullname]
conn.execute(text(
f"CREATE VIEW {dep.fullname} AS {definition}"
))
```
## Migration Helper
```python
def generate_view_migration(conn, schema=None):
"""Generate migration code for all views in dependency order."""
views = get_dependency_order(conn, schema=schema)
print("def upgrade():")
for view in views:
view_type = "materialized_view" if view.is_materialized else "view"
definition = view.definition.replace("'", "\\'")
print(f" op.create_{view_type}(")
print(f" '{view.name}',")
print(f" '''{definition}''',")
if view.schema != "public":
print(f" schema='{view.schema}',")
print(f" )")
print()
print("def downgrade():")
for view in reversed(views):
view_type = "materialized_view" if view.is_materialized else "view"
print(f" op.drop_{view_type}('{view.name}'", end="")
if view.schema != "public":
print(f", schema='{view.schema}'", end="")
print(")")
```
## Dependency Visualization
```python
def print_dependency_tree(conn, view_name, indent=0):
"""Print a tree of view dependencies."""
prefix = " " * indent
print(f"{prefix}- {view_name}")
deps = get_view_dependencies(conn, view_name)
for dep in deps:
print_dependency_tree(conn, dep.referenced_fullname, indent + 1)
```
Output:
```
- sales_summary
- monthly_sales
- orders
- products
- customer_stats
- customers
- orders
```
## Data Classes
### ViewInfo
| Attribute | Type | Description |
|-----------|------|-------------|
| `name` | `str` | View name |
| `schema` | `str` | Schema name |
| `definition` | `str` | SQL definition |
| `is_materialized` | `bool` | Whether the view is materialized |
| `fullname` | `str` | `schema.name` (property) |
### ViewDependency
| Attribute | Type | Description |
|-----------|------|-------------|
| `dependent_view` | `str` | Name of the dependent view |
| `dependent_schema` | `str` | Schema of the dependent view |
| `referenced_view` | `str` | Name of the referenced object |
| `referenced_schema` | `str` | Schema of the referenced object |
| `dependent_fullname` | `str` | `schema.view` (property) |
| `referenced_fullname` | `str` | `schema.view` (property) |

View File

@@ -0,0 +1,307 @@
# Materialized Views
Materialized views store the query results physically, making them faster to query but requiring explicit refresh to update.
## When to Use Materialized Views
| Use Case | Regular View | Materialized View |
|----------|--------------|-------------------|
| Complex aggregations queried frequently | Slow | **Fast** |
| Data that changes frequently | **Current** | Stale |
| Reports and dashboards | Slow | **Fast** |
| Real-time data requirements | **Yes** | No |
| Large dataset summaries | Slow | **Fast** |
## Creating Materialized Views (Declarative)
The recommended way to create materialized views is using the declarative pattern with multiple inheritance:
```python
from decimal import Decimal
from sqlalchemy import select, func, String, Numeric, Integer, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy_pgview import MaterializedViewBase
class Base(DeclarativeBase):
pass
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
total: Mapped[Decimal] = mapped_column(Numeric(10, 2))
created_at: Mapped[DateTime] = mapped_column(DateTime)
# Define materialized view with data populated immediately
class MonthlySales(MaterializedViewBase, Base):
__tablename__ = "monthly_sales"
__select__ = select(
func.date_trunc('month', Order.created_at).label("month"),
func.count(Order.id).label("order_count"),
func.sum(Order.total).label("revenue"),
).group_by(func.date_trunc('month', Order.created_at))
__with_data__ = True # Default: populate on creation
# Create tables and views together
engine = create_engine("postgresql://user:pass@localhost/mydb")
Base.metadata.create_all(engine)
```
### MaterializedViewBase Attributes
| Attribute | Required | Description |
|-----------|----------|-------------|
| `__tablename__` | Yes | Name of the materialized view in the database |
| `__select__` | Yes | SELECT statement that defines the view |
| `__schema__` | No | Database schema (default: None/public) |
| `__with_data__` | No | Populate data on creation (default: True) |
## Querying Materialized Views
```python
from sqlalchemy import select
with engine.connect() as conn:
# Query all rows
result = conn.execute(select(MonthlySales.as_table())).fetchall()
# Query with filtering using .c accessor
result = conn.execute(
select(MonthlySales.as_table())
.where(MonthlySales.c.order_count > 10)
.order_by(MonthlySales.c.revenue.desc())
).fetchall()
for row in result:
print(f"{row.month}: {row.order_count} orders, ${row.revenue}")
```
## Refreshing Materialized Views
Materialized views become stale when underlying data changes. Refresh to update:
```python
with engine.begin() as conn:
# Using the class method (declarative)
MonthlySales.refresh(conn)
# Or using the view object
MonthlySales.as_view().refresh(conn)
# Concurrent refresh (requires a unique index, doesn't block reads)
MonthlySales.refresh(conn, concurrently=True)
# Refresh without data (empties the view)
MonthlySales.refresh(conn, with_data=False)
```
!!! warning "Concurrent Refresh Requirements"
`REFRESH MATERIALIZED VIEW CONCURRENTLY` requires:
- A unique index on the materialized view
- The view must already contain data (can't be empty)
## Stale Data Behavior
!!! warning "Materialized Views Don't Auto-Update"
Unlike regular views, materialized views show **stale data** until refreshed.
```python
with engine.begin() as conn:
# Insert new data into underlying table
conn.execute(insert(Order.__table__).values(...))
# Materialized view still shows old data!
# Refresh to see new data
MonthlySales.refresh(conn)
# Now the view reflects the changes
result = conn.execute(select(MonthlySales.as_table())).fetchall()
```
## Auto-Refresh (ORM)
SQLAlchemy-PGView can automatically refresh materialized views when watched tables are modified via ORM:
```python
from sqlalchemy.orm import Session
# Enable auto-refresh when Order table changes
MonthlySales.auto_refresh_on(Session, Order.__table__)
# Now ORM commits automatically refresh the view
with Session(engine) as session:
session.add(Order(total=Decimal("99.99"), created_at=datetime.now()))
session.commit() # MonthlySales is refreshed automatically!
```
!!! tip "Custom Session Class"
For isolated testing or specific workflows, create a custom Session subclass:
```python
class AnalyticsSession(Session):
pass
MonthlySales.auto_refresh_on(AnalyticsSession, Order.__table__)
```
## Auto-Refresh (Core)
For SQLAlchemy Core (without ORM), use `AutoRefreshContext`:
```python
from sqlalchemy_pgview import AutoRefreshContext
# Get the underlying view and table objects
mview = MonthlySales.as_view()
orders_table = Order.__table__
with engine.begin() as conn:
with AutoRefreshContext(conn, mview, orders_table):
conn.execute(insert(orders_table).values(total=100, created_at=datetime.now()))
conn.execute(insert(orders_table).values(total=200, created_at=datetime.now()))
# View is refreshed when exiting the context
# View now shows updated data
result = conn.execute(select(MonthlySales.as_table())).fetchone()
```
!!! warning "Auto-Refresh Considerations"
- **Performance**: Each commit triggers a refresh. For high-frequency writes, use scheduled refresh instead.
- **Exceptions**: `AutoRefreshContext` only refreshes on successful exit (no exception).
- **ORM-only**: `auto_refresh_on()` only catches changes made through the ORM Session, not raw SQL.
## Refresh Strategies
| Data Freshness Requirement | Strategy |
|---------------------------|----------|
| Real-time (careful!) | Auto-refresh on commit |
| Minutes | Frequent scheduled refresh |
| Hours | Hourly cron job |
| Daily | Nightly refresh |
| On-demand | Manual refresh before queries |
## Imperative API (Alternative)
For SQLAlchemy Core or when declarative style isn't suitable:
```python
from sqlalchemy import MetaData, Table, Column, Integer, Numeric, DateTime, select, func
from sqlalchemy_pgview import MaterializedView, CreateMaterializedView, RefreshMaterializedView
metadata = MetaData()
orders = Table(
"orders", metadata,
Column("id", Integer, primary_key=True),
Column("total", Numeric(10, 2)),
Column("created_at", DateTime),
)
# Define materialized view
monthly_sales = MaterializedView(
"monthly_sales",
select(
func.date_trunc('month', orders.c.created_at).label("month"),
func.count(orders.c.id).label("order_count"),
func.sum(orders.c.total).label("revenue"),
).group_by(func.date_trunc('month', orders.c.created_at)),
metadata=metadata,
with_data=True,
)
# Create tables and views
metadata.create_all(engine)
# Refresh
with engine.begin() as conn:
monthly_sales.refresh(conn)
# Or using DDL directly
conn.execute(RefreshMaterializedView(monthly_sales, concurrently=True))
```
## Dropping Materialized Views
Materialized views are automatically dropped when using `metadata.drop_all()`. For manual control:
```python
from sqlalchemy_pgview import DropMaterializedView
with engine.begin() as conn:
# Drop using view object
conn.execute(DropMaterializedView(MonthlySales.as_view()))
# Drop by name with options
conn.execute(DropMaterializedView("old_mv", if_exists=True))
# Drop with cascade (drops dependent objects)
conn.execute(DropMaterializedView("base_mv", cascade=True))
```
## List Registered Views
```python
from sqlalchemy_pgview import get_views
# Get all registered views from metadata (includes materialized views)
views = get_views(Base.metadata)
print(views) # {'monthly_sales': <MaterializedView 'monthly_sales'>}
```
## Complete Example
```python
from decimal import Decimal
from datetime import datetime
from sqlalchemy import create_engine, select, func, String, Numeric, Integer, DateTime
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy_pgview import MaterializedViewBase
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
category: Mapped[str] = mapped_column(String(50))
class Sale(Base):
__tablename__ = "sales"
id: Mapped[int] = mapped_column(primary_key=True)
product_id: Mapped[int] = mapped_column(Integer)
quantity: Mapped[int] = mapped_column(Integer)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2))
sold_at: Mapped[datetime] = mapped_column(DateTime)
class CategorySales(MaterializedViewBase, Base):
__tablename__ = "category_sales"
__select__ = select(
Product.category,
func.count(Sale.id).label("sale_count"),
func.sum(Sale.quantity).label("total_quantity"),
func.sum(Sale.amount).label("total_revenue"),
).select_from(
Sale.__table__.join(Product.__table__, Sale.product_id == Product.id)
).group_by(Product.category)
# Setup
engine = create_engine("postgresql://...")
Base.metadata.create_all(engine)
# Enable auto-refresh
CategorySales.auto_refresh_on(Session, Sale.__table__)
# Add data - view refreshes automatically on commit
with Session(engine) as session:
session.add(Sale(product_id=1, quantity=5, amount=Decimal("49.95"), sold_at=datetime.now()))
session.commit()
# Query fresh data
with engine.connect() as conn:
results = conn.execute(
select(CategorySales.as_table()).order_by(CategorySales.c.total_revenue.desc())
).fetchall()
for row in results:
print(f"{row.category}: {row.sale_count} sales, ${row.total_revenue}")
```

242
docs/guide/views.md Normal file
View File

@@ -0,0 +1,242 @@
# Views
Views are virtual tables whose contents are defined by a query. Unlike regular tables, views don't store data - they compute results on each access.
## Creating Views (Declarative)
The recommended way to create views is using the declarative pattern with multiple inheritance:
```python
from sqlalchemy import select, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy_pgview import ViewBase
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
email: Mapped[str] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(default=True)
# Define a view using ViewBase
class ActiveUsers(ViewBase, Base):
__tablename__ = "active_users"
__select__ = select(User.id, User.name, User.email).where(User.is_active == True)
# Create tables and views together
engine = create_engine("postgresql://user:pass@localhost/mydb")
Base.metadata.create_all(engine)
```
### ViewBase Attributes
| Attribute | Required | Description |
|-----------|----------|-------------|
| `__tablename__` | Yes | Name of the view in the database |
| `__select__` | Yes | SELECT statement that defines the view |
| `__schema__` | No | Database schema (default: None/public) |
## Querying Views
```python
from sqlalchemy import select
with engine.connect() as conn:
# Query all rows
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
# Query with filtering using .c accessor
result = conn.execute(
select(ActiveUsers.as_table())
.where(ActiveUsers.c.name.like('A%'))
.order_by(ActiveUsers.c.name)
).fetchall()
for row in result:
print(f"{row.name} ({row.email})")
```
## Views with Joins
Views can encapsulate complex joins:
```python
from sqlalchemy import func
class Author(Base):
__tablename__ = "authors"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
author_id: Mapped[int] = mapped_column(Integer)
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
# View with one-to-many join and aggregation
class AuthorStats(ViewBase, Base):
__tablename__ = "author_stats"
__select__ = select(
Author.id,
Author.name,
func.count(Book.id).label("book_count"),
func.avg(Book.price).label("avg_price"),
).select_from(
Author.__table__.outerjoin(Book.__table__, Author.id == Book.author_id)
).group_by(Author.id, Author.name)
```
## View Update Behavior
!!! info "Views Always Show Current Data"
Regular views are just stored queries. When you INSERT, UPDATE, or DELETE rows in the underlying tables, the view immediately reflects those changes.
```python
from sqlalchemy.orm import Session
with Session(engine) as session:
# Add new user
session.add(User(name="Alice", email="alice@example.com", is_active=True))
session.commit()
# View immediately shows Alice
with engine.connect() as conn:
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
# Alice is included!
```
## Imperative API (Alternative)
For SQLAlchemy Core or when declarative style isn't suitable:
```python
from sqlalchemy import MetaData, Table, Column, Integer, String, Boolean, select
from sqlalchemy_pgview import View
metadata = MetaData()
users = Table(
"users", metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100)),
Column("email", String(100)),
Column("is_active", Boolean, default=True),
)
# Define the view
active_users = View(
name="active_users",
selectable=select(users.c.id, users.c.name, users.c.email)
.where(users.c.is_active == True),
metadata=metadata,
)
# Create tables and views
metadata.create_all(engine)
# Query the view
with engine.connect() as conn:
result = conn.execute(select(active_users.as_table())).fetchall()
```
## Dropping Views
Views are automatically dropped when using `metadata.drop_all()`. For manual control:
```python
from sqlalchemy_pgview import DropView
with engine.begin() as conn:
# Drop using view object
conn.execute(DropView(ActiveUsers.as_view()))
# Drop by name with options
conn.execute(DropView("old_view", if_exists=True))
# Drop with cascade (drops dependent objects)
conn.execute(DropView("base_view", cascade=True))
```
## List Registered Views
```python
from sqlalchemy_pgview import get_views
# Get all registered views from metadata
views = get_views(Base.metadata)
print(views) # {'active_users': <View 'active_users'>}
```
## Complete Example
```python
from decimal import Decimal
from sqlalchemy import create_engine, select, func, String, Numeric, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy_pgview import ViewBase
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
category: Mapped[str] = mapped_column(String(50))
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
in_stock: Mapped[bool] = mapped_column(default=True)
# View for available products only
class AvailableProducts(ViewBase, Base):
__tablename__ = "available_products"
__select__ = select(
Product.id,
Product.name,
Product.category,
Product.price,
).where(Product.in_stock == True)
# View with aggregation by category
class CategoryPricing(ViewBase, Base):
__tablename__ = "category_pricing"
__select__ = select(
Product.category,
func.count(Product.id).label("product_count"),
func.min(Product.price).label("min_price"),
func.max(Product.price).label("max_price"),
func.avg(Product.price).label("avg_price"),
).where(Product.in_stock == True).group_by(Product.category)
# Setup
engine = create_engine("postgresql://...")
Base.metadata.create_all(engine)
# Add products
with Session(engine) as session:
session.add_all([
Product(name="Widget", category="Tools", price=Decimal("9.99")),
Product(name="Gadget", category="Electronics", price=Decimal("49.99")),
Product(name="Doohickey", category="Tools", price=Decimal("19.99")),
])
session.commit()
# Query views
with engine.connect() as conn:
# Get available products
available = conn.execute(select(AvailableProducts.as_table())).fetchall()
print(f"Available products: {len(available)}")
# Get category pricing
pricing = conn.execute(
select(CategoryPricing.as_table())
.order_by(CategoryPricing.c.avg_price.desc())
).fetchall()
for row in pricing:
print(f"{row.category}: {row.product_count} products, avg ${row.avg_price:.2f}")
```

172
docs/index.md Normal file
View File

@@ -0,0 +1,172 @@
# SQLAlchemy-PGView
**A SQLAlchemy 2.0+ extension that provides first-class support for PostgreSQL views and materialized views.**
[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)
[![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![SQLAlchemy 2.0+](https://img.shields.io/badge/SQLAlchemy-2.0+-green.svg)](https://www.sqlalchemy.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
---
## Features
- **Declarative Views** - Class-based view definitions using multiple inheritance with SQLAlchemy ORM
- **View & MaterializedView Classes** - Define PostgreSQL views as Python objects with full DDL support
- **Alembic Integration** - Database migration operations (`op.create_view()`, `op.drop_view()`, etc.)
- **Auto-Refresh** - Automatically refresh materialized views on data changes
- **Async Support** - Works with asyncpg and SQLAlchemy's async engines
- **Dependency Tracking** - Query PostgreSQL system catalogs for view dependencies
- **Type Safety** - Full type annotations for modern Python development
## Requirements
- Python 3.10+
- SQLAlchemy 2.0+
- PostgreSQL 12+
- Alembic 1.10+ (optional, for migrations)
## Installation
=== "Base package"
```bash
uv pip install "sqlalchemy-pgview"
```
=== "With alembic support"
```bash
uv pip install "sqlalchemy-pgview[alembic]"
```
## Quick Start
The recommended way to define views is using the **declarative pattern** with multiple inheritance. This integrates seamlessly with SQLAlchemy ORM models.
### Define Your Models and Views
```python
from decimal import Decimal
from sqlalchemy import create_engine, select, func, String, Numeric, Integer
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
from sqlalchemy_pgview import ViewBase, MaterializedViewBase
# Define your base and models
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
is_active: Mapped[bool] = mapped_column(default=True)
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer)
total: Mapped[Decimal] = mapped_column(Numeric(10, 2))
# Define a regular view (computed on every query)
class ActiveUsers(ViewBase, Base):
__tablename__ = "active_users"
__select__ = select(User.id, User.name).where(User.is_active == True)
# Define a materialized view (cached results, needs refresh)
class UserStats(MaterializedViewBase, Base):
__tablename__ = "user_stats"
__select__ = select(
User.id.label("user_id"),
User.name,
func.count(Order.id).label("order_count"),
func.coalesce(func.sum(Order.total), 0).label("total_spent"),
).select_from(User.__table__.outerjoin(Order.__table__, User.id == Order.user_id)
).group_by(User.id, User.name)
# Create everything (tables + views)
engine = create_engine("postgresql://user:pass@localhost/mydb")
Base.metadata.create_all(engine)
```
### Query Views
```python
from sqlalchemy import select
with engine.connect() as conn:
# Query regular view (always shows current data)
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
for row in result:
print(f"{row.name}")
# Query materialized view (shows cached data)
stats = conn.execute(select(UserStats.as_table())).fetchall()
for stat in stats:
print(f"{stat.name}: {stat.order_count} orders, ${stat.total_spent}")
```
### Refresh Materialized Views
Materialized views store cached results - refresh them when data changes:
```python
with engine.begin() as conn:
UserStats.refresh(conn)
# Concurrent refresh (allows reads during refresh, requires unique index)
UserStats.refresh(conn, concurrently=True)
```
### Auto-Refresh on Data Changes
Automatically refresh materialized views when underlying data changes:
```python
from sqlalchemy.orm import Session
# Enable auto-refresh when Order table changes
UserStats.auto_refresh_on(Session, Order.__table__)
# Now commits automatically refresh the materialized view
with Session(engine) as session:
session.add(Order(user_id=1, total=Decimal("100.00")))
session.commit() # UserStats is automatically refreshed
```
## Next Steps
<div class="grid cards" markdown>
- :material-book-open-variant:{ .lg .middle } **Views**
---
Learn about regular views in depth
[:octicons-arrow-right-24: Views](guide/views.md)
- :material-database-refresh:{ .lg .middle } **Materialized Views**
---
Caching, refreshing, and auto-refresh
[:octicons-arrow-right-24: Materialized views](guide/materialized-views.md)
- :material-source-branch:{ .lg .middle } **Alembic Migrations**
---
Database migrations with autogenerate
[:octicons-arrow-right-24: Alembic guide](guide/alembic.md)
- :material-api:{ .lg .middle } **API Reference**
---
Complete API documentation
[:octicons-arrow-right-24: API reference](api/views/view.md)
</div>