mirror of
https://github.com/d3vyce/sqlalchemy-pgview.git
synced 2026-03-01 19:50:46 +01:00
Initial commit
This commit is contained in:
13
docs/api/alembic/create-materialized-view-op.md
Normal file
13
docs/api/alembic/create-materialized-view-op.md
Normal 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
|
||||
14
docs/api/alembic/create-view-op.md
Normal file
14
docs/api/alembic/create-view-op.md
Normal 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
|
||||
12
docs/api/alembic/drop-materialized-view-op.md
Normal file
12
docs/api/alembic/drop-materialized-view-op.md
Normal 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
|
||||
12
docs/api/alembic/drop-view-op.md
Normal file
12
docs/api/alembic/drop-view-op.md
Normal 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
|
||||
12
docs/api/alembic/refresh-materialized-view-op.md
Normal file
12
docs/api/alembic/refresh-materialized-view-op.md
Normal 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
|
||||
9
docs/api/ddl/create-materialized-view.md
Normal file
9
docs/api/ddl/create-materialized-view.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# `CreateMaterializedView` class
|
||||
|
||||
DDL element to create a PostgreSQL materialized view.
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import CreateMaterializedView
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.CreateMaterializedView
|
||||
9
docs/api/ddl/create-view.md
Normal file
9
docs/api/ddl/create-view.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# `CreateView` class
|
||||
|
||||
DDL element to create a PostgreSQL view.
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import CreateView
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.CreateView
|
||||
12
docs/api/ddl/drop-materialized-view.md
Normal file
12
docs/api/ddl/drop-materialized-view.md
Normal 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
12
docs/api/ddl/drop-view.md
Normal 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
|
||||
12
docs/api/ddl/refresh-materialized-view.md
Normal file
12
docs/api/ddl/refresh-materialized-view.md
Normal 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
|
||||
7
docs/api/dependencies/get-all-views.md
Normal file
7
docs/api/dependencies/get-all-views.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `get_all_views`
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import get_all_views
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.get_all_views
|
||||
7
docs/api/dependencies/get-dependency-order.md
Normal file
7
docs/api/dependencies/get-dependency-order.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `get_dependency_order`
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import get_dependency_order
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.get_dependency_order
|
||||
7
docs/api/dependencies/get-reverse-dependencies.md
Normal file
7
docs/api/dependencies/get-reverse-dependencies.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `get_reverse_dependencies`
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import get_reverse_dependencies
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.get_reverse_dependencies
|
||||
7
docs/api/dependencies/get-view-definition.md
Normal file
7
docs/api/dependencies/get-view-definition.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `get_view_definition`
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import get_view_definition
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.get_view_definition
|
||||
7
docs/api/dependencies/get-view-dependencies.md
Normal file
7
docs/api/dependencies/get-view-dependencies.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `get_view_dependencies`
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import get_view_dependencies
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.get_view_dependencies
|
||||
17
docs/api/dependencies/view-dependency.md
Normal file
17
docs/api/dependencies/view-dependency.md
Normal 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
|
||||
16
docs/api/dependencies/view-info.md
Normal file
16
docs/api/dependencies/view-info.md
Normal 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
|
||||
9
docs/api/views/auto-refresh-context.md
Normal file
9
docs/api/views/auto-refresh-context.md
Normal 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
|
||||
7
docs/api/views/get-materialized-views.md
Normal file
7
docs/api/views/get-materialized-views.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `get_materialized_views`
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import get_materialized_views
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.get_materialized_views
|
||||
7
docs/api/views/get-views.md
Normal file
7
docs/api/views/get-views.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# `get_views`
|
||||
|
||||
```python
|
||||
from sqlalchemy_pgview import get_views
|
||||
```
|
||||
|
||||
::: sqlalchemy_pgview.get_views
|
||||
13
docs/api/views/materialized-view.md
Normal file
13
docs/api/views/materialized-view.md
Normal 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
17
docs/api/views/view.md
Normal 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
352
docs/guide/alembic.md
Normal 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
219
docs/guide/async.md
Normal 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
207
docs/guide/dependencies.md
Normal 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) |
|
||||
307
docs/guide/materialized-views.md
Normal file
307
docs/guide/materialized-views.md
Normal 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
242
docs/guide/views.md
Normal 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
172
docs/index.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# SQLAlchemy-PGView
|
||||
|
||||
**A SQLAlchemy 2.0+ extension that provides first-class support for PostgreSQL views and materialized views.**
|
||||
|
||||
[](https://github.com/astral-sh/ty)
|
||||
[](https://github.com/astral-sh/uv)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://www.sqlalchemy.org/)
|
||||
[](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>
|
||||
Reference in New Issue
Block a user