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

211
.gitignore vendored Normal file
View File

@@ -0,0 +1,211 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# MkDocs
site/

24
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,24 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.11
hooks:
# Run the linter.
- id: ruff-check
# Run the formatter.
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: check-yaml
- id: check-json
- id: check-merge-conflict
- id: end-of-file-fixer
- repo: local
hooks:
- id: ty
name: ty check
entry: uvx ty check src/
language: system
pass_filenames: false
always_run: true

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

193
README.md Normal file
View File

@@ -0,0 +1,193 @@
# 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 database
- 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
```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 the view
with engine.connect() as conn:
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
for row in result:
print(f"{row.name}")
# Refresh materialized view
with engine.begin() as conn:
UserStats.refresh(conn)
```
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
```
## Alembic Integration
SQLAlchemy-PGView integrates with Alembic for automatic view detection and migration generation.
Import the alembic module in your `env.py` to enable autogenerate:
```python
# env.py
import sqlalchemy_pgview.alembic # Registers autogenerate support
```
Then generate migrations automatically:
```bash
alembic revision --autogenerate -m "add user views"
```
Alembic will detect:
- **New views**: Views in metadata but not in database
- **Removed views**: Views in database but not in metadata
You also can manually add refresh in existing migration:
```python
def upgrade():
# After data changes, refresh materialized views
op.refresh_materialized_view("user_stats", concurrently=True)
```
## API Reference
### ViewBase (Declarative)
```python
class MyView(ViewBase, Base):
__tablename__ = "my_view" # Required: view name
__select__ = select(...) # Required: SELECT statement
__schema__ = "public" # Optional: schema name
```
### MaterializedViewBase (Declarative)
```python
class MyMaterializedView(MaterializedViewBase, Base):
__tablename__ = "my_mview" # Required: view name
__select__ = select(...) # Required: SELECT statement
__schema__ = "public" # Optional: schema name
__with_data__ = True # Optional: populate on creation (default: True)
```
### View (Imperative)
```python
View(
name: str, # View name
selectable: Select, # SQLAlchemy SELECT statement
schema: str | None = None, # Schema name (default: public)
metadata: MetaData | None = None, # MetaData for auto-registration
)
```
### MaterializedView (Imperative)
```python
MaterializedView(
name: str, # View name
selectable: Select, # SQLAlchemy SELECT statement
schema: str | None = None, # Schema name (default: public)
metadata: MetaData | None = None, # MetaData for auto-registration
with_data: bool = True, # Populate on creation
)
```
## License
MIT License - see [LICENSE](LICENSE) for details.
## Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
## Documentation
For full documentation, visit the [docs](docs/) directory.

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>

122
mkdocs.yml Normal file
View File

@@ -0,0 +1,122 @@
site_name: SQLAlchemy-PGView
site_description: SQLAlchemy extension for PostgreSQL views and materialized views
site_url: https://d3vyce.github.io/sqlalchemy-pgview
repo_url: https://github.com/d3vyce/sqlalchemy-pgview
repo_name: d3vyce/sqlalchemy-pgview
edit_uri: edit/main/docs/
theme:
name: material
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: indigo
accent: indigo
toggle:
icon: material/brightness-7
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
accent: indigo
toggle:
icon: material/brightness-4
name: Switch to light mode
features:
- navigation.instant
- navigation.instant.prefetch
- navigation.tracking
- navigation.sections
- navigation.expand
- navigation.top
- search.suggest
- search.highlight
- content.code.copy
- content.code.annotate
- content.tabs.link
icon:
repo: fontawesome/brands/github
plugins:
- search
- mkdocstrings:
handlers:
python:
options:
extensions:
- griffe_typingdoc
show_root_heading: true
show_if_no_docstring: true
inherited_members: true
members_order: source
separate_signature: true
unwrap_annotated: true
filters:
- '!^_'
merge_init_into_class: true
docstring_section_style: spacy
signature_crossrefs: true
show_symbol_type_heading: true
show_symbol_type_toc: true
markdown_extensions:
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- admonition
- pymdownx.details
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- attr_list
- md_in_html
- tables
- toc:
permalink: true
nav:
- Home: index.md
- User Guide:
- Views: guide/views.md
- Materialized Views: guide/materialized-views.md
- Async Support: guide/async.md
- Alembic Migrations: guide/alembic.md
- Dependency Tracking: guide/dependencies.md
- API Reference:
- Views:
- View: api/views/view.md
- MaterializedView: api/views/materialized-view.md
- AutoRefreshContext: api/views/auto-refresh-context.md
- get_views: api/views/get-views.md
- get_materialized_views: api/views/get-materialized-views.md
- DDL Operations:
- CreateView: api/ddl/create-view.md
- DropView: api/ddl/drop-view.md
- CreateMaterializedView: api/ddl/create-materialized-view.md
- DropMaterializedView: api/ddl/drop-materialized-view.md
- RefreshMaterializedView: api/ddl/refresh-materialized-view.md
- Dependencies:
- ViewInfo: api/dependencies/view-info.md
- ViewDependency: api/dependencies/view-dependency.md
- get_view_definition: api/dependencies/get-view-definition.md
- get_all_views: api/dependencies/get-all-views.md
- get_view_dependencies: api/dependencies/get-view-dependencies.md
- get_dependency_order: api/dependencies/get-dependency-order.md
- get_reverse_dependencies: api/dependencies/get-reverse-dependencies.md
- Alembic:
- CreateViewOp: api/alembic/create-view-op.md
- DropViewOp: api/alembic/drop-view-op.md
- CreateMaterializedViewOp: api/alembic/create-materialized-view-op.md
- DropMaterializedViewOp: api/alembic/drop-materialized-view-op.md
- RefreshMaterializedViewOp: api/alembic/refresh-materialized-view-op.md
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/d3vyce/sqlalchemy-pgview

79
pyproject.toml Normal file
View File

@@ -0,0 +1,79 @@
[project]
name = "sqlalchemy-pgview"
version = "0.1.0"
description = "SQLAlchemy extension for PostgreSQL views and materialized views"
readme = "README.md"
authors = [
{ name = "d3vyce", email = "nicolas.sudres@proton.me" }
]
requires-python = ">=3.10"
dependencies = [
"annotated-doc>=0.0.1",
"sqlalchemy>=2.0",
]
license = { text = "MIT" }
keywords = ["sqlalchemy", "postgresql", "views", "materialized-views", "database"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Database",
"Typing :: Typed",
]
[project.optional-dependencies]
alembic = ["alembic>=1.10"]
[build-system]
requires = ["uv_build>=0.9.17,<0.10.0"]
build-backend = "uv_build"
[tool.ruff]
target-version = "py310"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
"RUF", # ruff-specific
]
ignore = [
"E501", # line too long (handled by formatter)
]
[tool.ruff.lint.isort]
known-first-party = ["sqlalchemy_pgview"]
[tool.ty.environment]
python-version = "3.10"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
[dependency-groups]
dev = [
"asyncpg>=0.31.0",
"griffe-typingdoc>=0.2",
"mkdocs-material>=9.7.1",
"mkdocstrings[python]>=0.28",
"prek>=0.2.27",
"psycopg2-binary>=2.9.11",
"pytest-asyncio>=0.23",
"pytest-cov>=7.0.0",
"pytest>=8.0",
"ruff>=0.8",
"ty>=0.0.1a6",
]

View File

@@ -0,0 +1,87 @@
"""SQLAlchemy extension for PostgreSQL views and materialized views.
This package provides SQLAlchemy support for creating, managing, and
querying PostgreSQL views and materialized views.
Example:
```python
from sqlalchemy import select, func, MetaData
from sqlalchemy_pgview import View, MaterializedView
metadata = MetaData()
# Create a regular view
user_stats = View(
"user_stats",
select(User.id, func.count(Order.id).label("order_count"))
.join(Order)
.group_by(User.id),
metadata=metadata,
)
# Create a materialized view
monthly_sales = MaterializedView(
"monthly_sales",
select(
func.date_trunc('month', Order.created_at).label("month"),
func.sum(Order.total).label("total_sales")
).group_by(func.date_trunc('month', Order.created_at)),
with_data=True,
metadata=metadata,
)
metadata.create_all(engine)
```
"""
from sqlalchemy_pgview.ddl import (
CreateMaterializedView,
CreateView,
DropMaterializedView,
DropView,
RefreshMaterializedView,
)
from sqlalchemy_pgview.declarative import (
MaterializedViewBase,
ViewBase,
)
from sqlalchemy_pgview.dependencies import (
ViewDependency,
ViewInfo,
get_all_views,
get_dependency_order,
get_reverse_dependencies,
get_view_definition,
get_view_dependencies,
)
from sqlalchemy_pgview.view import (
AutoRefreshContext,
MaterializedView,
View,
get_materialized_views,
get_views,
)
__version__ = "0.1.0"
__all__ = [
"AutoRefreshContext",
"CreateMaterializedView",
"CreateView",
"DropMaterializedView",
"DropView",
"MaterializedView",
"MaterializedViewBase",
"RefreshMaterializedView",
"View",
"ViewBase",
"ViewDependency",
"ViewInfo",
"get_all_views",
"get_dependency_order",
"get_materialized_views",
"get_reverse_dependencies",
"get_view_definition",
"get_view_dependencies",
"get_views",
]

View File

@@ -0,0 +1,42 @@
"""Alembic integration for sqlalchemy-pgview.
Import this module to register Alembic operations and autogenerate support:
import sqlalchemy_pgview.alembic
This enables:
- op.create_view()
- op.drop_view()
- op.create_materialized_view()
- op.drop_materialized_view()
- op.refresh_materialized_view()
- Autogenerate detection of view changes
"""
# Import autogenerate to register comparators and renderers
from sqlalchemy_pgview.alembic import autogenerate as _autogenerate # noqa: F401
from sqlalchemy_pgview.alembic.ops import (
CreateMaterializedViewOp,
CreateViewOp,
DropMaterializedViewOp,
DropViewOp,
RefreshMaterializedViewOp,
create_materialized_view,
create_view,
drop_materialized_view,
drop_view,
refresh_materialized_view,
)
__all__ = [
"CreateMaterializedViewOp",
"CreateViewOp",
"DropMaterializedViewOp",
"DropViewOp",
"RefreshMaterializedViewOp",
"create_materialized_view",
"create_view",
"drop_materialized_view",
"drop_view",
"refresh_materialized_view",
]

View File

@@ -0,0 +1,215 @@
"""Alembic autogenerate support for views.
This module provides automatic detection of view changes for Alembic migrations.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from alembic.autogenerate import comparators, renderers
from sqlalchemy_pgview.alembic.ops import (
CreateMaterializedViewOp,
CreateViewOp,
DropMaterializedViewOp,
DropViewOp,
)
from sqlalchemy_pgview.view import (
_MATERIALIZED_VIEWS_KEY,
_VIEWS_KEY,
)
if TYPE_CHECKING:
from alembic.autogenerate.api import AutogenContext
from alembic.operations.ops import UpgradeOps
from sqlalchemy import MetaData
from sqlalchemy.engine import Connection
def _get_db_views(connection: Connection, schema: str | None = None) -> dict[str, dict[str, Any]]:
"""Get all views from the database.
Returns dict mapping view key to {name, schema, definition, is_materialized}
"""
from sqlalchemy import text
query = """
SELECT
c.relname as name,
n.nspname as schema,
pg_get_viewdef(c.oid, true) as definition,
c.relkind = 'm' as is_materialized
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('v', 'm')
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
"""
params: dict[str, str] = {}
if schema:
query += " AND n.nspname = :schema"
params["schema"] = schema
result = connection.execute(text(query), params)
views = {}
for row in result.fetchall():
key = f"{row[1]}.{row[0]}" if row[1] != "public" else row[0]
views[key] = {
"name": row[0],
"schema": row[1] if row[1] != "public" else None,
"definition": row[2],
"is_materialized": row[3],
}
return views
def _normalize_definition(definition: str | None) -> str:
"""Normalize a view definition for comparison."""
if not definition:
return ""
# Remove whitespace variations
import re
normalized = re.sub(r"\s+", " ", definition.strip())
# Remove trailing semicolon
normalized = normalized.rstrip(";").strip()
return normalized.lower()
@comparators.dispatch_for("schema")
def compare_views(
autogen_context: AutogenContext,
upgrade_ops: UpgradeOps,
schemas: list[str | None],
) -> None:
"""Compare views between metadata and database.
This is called by Alembic's autogenerate to detect view changes.
"""
metadata: MetaData = autogen_context.metadata # type: ignore[invalid-assignment]
connection: Connection = autogen_context.connection # type: ignore[invalid-assignment]
if connection is None or connection.dialect.name != "postgresql":
return
# Get views from metadata
metadata_views = metadata.info.get(_VIEWS_KEY, {})
metadata_mviews = metadata.info.get(_MATERIALIZED_VIEWS_KEY, {})
# Get views from database
db_views = _get_db_views(connection)
# Separate db views by type
db_regular_views = {k: v for k, v in db_views.items() if not v["is_materialized"]}
db_materialized_views = {k: v for k, v in db_views.items() if v["is_materialized"]}
# Find views to create (in metadata but not in db)
for key, view in metadata_views.items():
if key not in db_regular_views:
# Compile the selectable to SQL
select_sql = view.selectable.compile(
dialect=connection.dialect,
compile_kwargs={"literal_binds": True},
)
upgrade_ops.ops.append(
CreateViewOp(
view.name,
str(select_sql),
schema=view.schema,
or_replace=True,
)
)
for key, mview in metadata_mviews.items():
if key not in db_materialized_views:
select_sql = mview.selectable.compile(
dialect=connection.dialect,
compile_kwargs={"literal_binds": True},
)
upgrade_ops.ops.append(
CreateMaterializedViewOp(
mview.name,
str(select_sql),
schema=mview.schema,
with_data=mview.with_data,
)
)
# Find views to drop (in db but not in metadata)
for key, db_view in db_regular_views.items():
if key not in metadata_views:
upgrade_ops.ops.append(
DropViewOp(
db_view["name"],
schema=db_view["schema"],
if_exists=True,
)
)
for key, db_mview in db_materialized_views.items():
if key not in metadata_mviews:
upgrade_ops.ops.append(
DropMaterializedViewOp(
db_mview["name"],
schema=db_mview["schema"],
if_exists=True,
)
)
# Renderers for generating migration code
@renderers.dispatch_for(CreateViewOp)
def render_create_view(autogen_context: AutogenContext, op: CreateViewOp) -> str:
"""Render CreateViewOp as Python code."""
args = [repr(op.view_name), f'"""\n{op.select_query}\n"""']
if op.schema:
args.append(f"schema={op.schema!r}")
if op.or_replace:
args.append("or_replace=True")
return f"op.create_view({', '.join(args)})"
@renderers.dispatch_for(DropViewOp)
def render_drop_view(autogen_context: AutogenContext, op: DropViewOp) -> str:
"""Render DropViewOp as Python code."""
args = [repr(op.view_name)]
if op.schema:
args.append(f"schema={op.schema!r}")
if not op.if_exists:
args.append("if_exists=False")
if op.cascade:
args.append("cascade=True")
return f"op.drop_view({', '.join(args)})"
@renderers.dispatch_for(CreateMaterializedViewOp)
def render_create_materialized_view(
autogen_context: AutogenContext, op: CreateMaterializedViewOp
) -> str:
"""Render CreateMaterializedViewOp as Python code."""
args = [repr(op.view_name), f'"""\n{op.select_query}\n"""']
if op.schema:
args.append(f"schema={op.schema!r}")
if not op.with_data:
args.append("with_data=False")
return f"op.create_materialized_view({', '.join(args)})"
@renderers.dispatch_for(DropMaterializedViewOp)
def render_drop_materialized_view(
autogen_context: AutogenContext, op: DropMaterializedViewOp
) -> str:
"""Render DropMaterializedViewOp as Python code."""
args = [repr(op.view_name)]
if op.schema:
args.append(f"schema={op.schema!r}")
if not op.if_exists:
args.append("if_exists=False")
if op.cascade:
args.append("cascade=True")
return f"op.drop_materialized_view({', '.join(args)})"

View File

@@ -0,0 +1,349 @@
"""Alembic operations for PostgreSQL views."""
from __future__ import annotations
from typing import Annotated
from alembic.operations import MigrateOperation, Operations
from annotated_doc import Doc
@Operations.register_operation("create_view")
class CreateViewOp(MigrateOperation):
"""Alembic operation to create a view."""
def __init__(
self,
view_name: Annotated[str, Doc("Name of the view to create.")],
select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
or_replace: Annotated[bool, Doc("If True, use CREATE OR REPLACE VIEW.")] = False,
) -> None:
self.view_name = view_name
self.select_query = select_query
self.schema = schema
self.or_replace = or_replace
@classmethod
def create_view(
cls,
operations: Annotated[Operations, Doc("Alembic Operations context.")],
view_name: Annotated[str, Doc("Name of the view to create.")],
select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
or_replace: Annotated[bool, Doc("If True, use CREATE OR REPLACE VIEW.")] = False,
) -> None:
"""Create a PostgreSQL view.
Example in migration::
def upgrade():
op.create_view(
"user_stats",
"SELECT user_id, COUNT(*) as order_count FROM orders GROUP BY user_id",
schema="public",
)
def downgrade():
op.drop_view("user_stats", schema="public")
"""
operations.invoke(
cls(
view_name,
select_query,
schema=schema,
or_replace=or_replace,
)
)
def reverse(self) -> DropViewOp:
"""Return the reverse operation (drop view)."""
return DropViewOp(self.view_name, schema=self.schema)
@Operations.register_operation("drop_view")
class DropViewOp(MigrateOperation):
"""Alembic operation to drop a view."""
def __init__(
self,
view_name: Annotated[str, Doc("Name of the view to drop.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True,
cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False,
) -> None:
self.view_name = view_name
self.schema = schema
self.if_exists = if_exists
self.cascade = cascade
@classmethod
def drop_view(
cls,
operations: Annotated[Operations, Doc("Alembic Operations context.")],
view_name: Annotated[str, Doc("Name of the view to drop.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True,
cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False,
) -> None:
"""Drop a PostgreSQL view.
Example in migration::
def upgrade():
op.drop_view("old_view", schema="public", cascade=True)
"""
operations.invoke(
cls(
view_name,
schema=schema,
if_exists=if_exists,
cascade=cascade,
)
)
@Operations.register_operation("create_materialized_view")
class CreateMaterializedViewOp(MigrateOperation):
"""Alembic operation to create a materialized view."""
def __init__(
self,
view_name: Annotated[str, Doc("Name of the materialized view to create.")],
select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
with_data: Annotated[bool, Doc("If True, populate with data on creation.")] = True,
if_not_exists: Annotated[bool, Doc("If True, don't error if view already exists.")] = False,
) -> None:
self.view_name = view_name
self.select_query = select_query
self.schema = schema
self.with_data = with_data
self.if_not_exists = if_not_exists
@classmethod
def create_materialized_view(
cls,
operations: Annotated[Operations, Doc("Alembic Operations context.")],
view_name: Annotated[str, Doc("Name of the materialized view to create.")],
select_query: Annotated[str, Doc("The SELECT SQL string that defines the view.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
with_data: Annotated[bool, Doc("If True, populate with data on creation.")] = True,
if_not_exists: Annotated[bool, Doc("If True, don't error if view already exists.")] = False,
) -> None:
"""Create a PostgreSQL materialized view.
Example in migration::
def upgrade():
op.create_materialized_view(
"monthly_sales",
'''
SELECT
date_trunc('month', created_at) as month,
SUM(total) as total_sales
FROM orders
GROUP BY date_trunc('month', created_at)
''',
schema="public",
with_data=True,
)
def downgrade():
op.drop_materialized_view("monthly_sales", schema="public")
"""
operations.invoke(
cls(
view_name,
select_query,
schema=schema,
with_data=with_data,
if_not_exists=if_not_exists,
)
)
def reverse(self) -> DropMaterializedViewOp:
"""Return the reverse operation (drop materialized view)."""
return DropMaterializedViewOp(self.view_name, schema=self.schema)
@Operations.register_operation("drop_materialized_view")
class DropMaterializedViewOp(MigrateOperation):
"""Alembic operation to drop a materialized view."""
def __init__(
self,
view_name: Annotated[str, Doc("Name of the materialized view to drop.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True,
cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False,
) -> None:
self.view_name = view_name
self.schema = schema
self.if_exists = if_exists
self.cascade = cascade
@classmethod
def drop_materialized_view(
cls,
operations: Annotated[Operations, Doc("Alembic Operations context.")],
view_name: Annotated[str, Doc("Name of the materialized view to drop.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = True,
cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False,
) -> None:
"""Drop a PostgreSQL materialized view.
Example in migration::
def upgrade():
op.drop_materialized_view("old_mview", schema="public", cascade=True)
"""
operations.invoke(
cls(
view_name,
schema=schema,
if_exists=if_exists,
cascade=cascade,
)
)
@Operations.register_operation("refresh_materialized_view")
class RefreshMaterializedViewOp(MigrateOperation):
"""Alembic operation to refresh a materialized view."""
def __init__(
self,
view_name: Annotated[str, Doc("Name of the materialized view to refresh.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
concurrently: Annotated[bool, Doc("If True, refresh without locking reads.")] = False,
with_data: Annotated[bool, Doc("If True, populate with fresh data.")] = True,
) -> None:
self.view_name = view_name
self.schema = schema
self.concurrently = concurrently
self.with_data = with_data
@classmethod
def refresh_materialized_view(
cls,
operations: Annotated[Operations, Doc("Alembic Operations context.")],
view_name: Annotated[str, Doc("Name of the materialized view to refresh.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
concurrently: Annotated[bool, Doc("If True, refresh without locking reads.")] = False,
with_data: Annotated[bool, Doc("If True, populate with fresh data.")] = True,
) -> None:
"""Refresh a PostgreSQL materialized view.
Example in migration::
def upgrade():
op.refresh_materialized_view(
"monthly_sales",
schema="public",
concurrently=True,
)
"""
operations.invoke(
cls(
view_name,
schema=schema,
concurrently=concurrently,
with_data=with_data,
)
)
# Implementation functions called by Alembic
@Operations.implementation_for(CreateViewOp)
def _create_view_impl(operations: Operations, operation: CreateViewOp) -> None:
"""Execute CreateViewOp."""
fullname = (
f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name
)
create_stmt = "CREATE OR REPLACE VIEW" if operation.or_replace else "CREATE VIEW"
sql = f"{create_stmt} {fullname} AS {operation.select_query}"
operations.execute(sql)
@Operations.implementation_for(DropViewOp)
def _drop_view_impl(operations: Operations, operation: DropViewOp) -> None:
"""Execute DropViewOp."""
fullname = (
f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name
)
sql = "DROP VIEW"
if operation.if_exists:
sql += " IF EXISTS"
sql += f" {fullname}"
if operation.cascade:
sql += " CASCADE"
operations.execute(sql)
@Operations.implementation_for(CreateMaterializedViewOp)
def _create_materialized_view_impl(
operations: Operations, operation: CreateMaterializedViewOp
) -> None:
"""Execute CreateMaterializedViewOp."""
fullname = (
f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name
)
sql = "CREATE MATERIALIZED VIEW"
if operation.if_not_exists:
sql += " IF NOT EXISTS"
sql += f" {fullname} AS {operation.select_query}"
sql += " WITH DATA" if operation.with_data else " WITH NO DATA"
operations.execute(sql)
@Operations.implementation_for(DropMaterializedViewOp)
def _drop_materialized_view_impl(operations: Operations, operation: DropMaterializedViewOp) -> None:
"""Execute DropMaterializedViewOp."""
fullname = (
f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name
)
sql = "DROP MATERIALIZED VIEW"
if operation.if_exists:
sql += " IF EXISTS"
sql += f" {fullname}"
if operation.cascade:
sql += " CASCADE"
operations.execute(sql)
@Operations.implementation_for(RefreshMaterializedViewOp)
def _refresh_materialized_view_impl(
operations: Operations, operation: RefreshMaterializedViewOp
) -> None:
"""Execute RefreshMaterializedViewOp."""
fullname = (
f"{operation.schema}.{operation.view_name}" if operation.schema else operation.view_name
)
sql = "REFRESH MATERIALIZED VIEW"
if operation.concurrently:
sql += " CONCURRENTLY"
sql += f" {fullname}"
sql += " WITH DATA" if operation.with_data else " WITH NO DATA"
operations.execute(sql)
# Convenience function aliases
create_view = CreateViewOp.create_view
drop_view = DropViewOp.drop_view
create_materialized_view = CreateMaterializedViewOp.create_materialized_view
drop_materialized_view = DropMaterializedViewOp.drop_materialized_view
refresh_materialized_view = RefreshMaterializedViewOp.refresh_materialized_view

View File

@@ -0,0 +1,305 @@
"""DDL operations for PostgreSQL views and materialized views."""
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated, Any
from annotated_doc import Doc
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.schema import DDLElement
if TYPE_CHECKING:
from sqlalchemy.sql.compiler import DDLCompiler
from sqlalchemy_pgview.view import MaterializedView, View
from sqlalchemy_pgview.util import get_table_key
class CreateView(DDLElement):
"""DDL element to create a PostgreSQL view.
Example:
```python
from sqlalchemy_pgview import View
from sqlalchemy_pgview.ddl import CreateView
view = View("user_stats", select(...))
engine.execute(CreateView(view))
```
"""
inherit_cache = False
def __init__(
self,
view: Annotated[View, Doc("The View to create.")],
*,
or_replace: Annotated[bool, Doc("If True, use CREATE OR REPLACE VIEW.")] = False,
) -> None:
self.view = view
self.or_replace = or_replace
@compiles(CreateView)
def _compile_create_view_default(element: CreateView, compiler: DDLCompiler, **kw: Any) -> str:
"""Default CreateView compiler - raises error for non-PostgreSQL."""
raise NotImplementedError(
f"CreateView is only supported on PostgreSQL, not {compiler.dialect.name}"
)
@compiles(CreateView, "postgresql")
def _compile_create_view(element: CreateView, compiler: DDLCompiler, **kw: Any) -> str:
"""Compile CreateView to PostgreSQL DDL."""
view = element.view
create_stmt = "CREATE OR REPLACE VIEW" if element.or_replace else "CREATE VIEW"
select_sql = compiler.sql_compiler.process(obj=view.selectable, literal_binds=True)
return f"{create_stmt} {view.fullname} AS {select_sql}"
class DropView(DDLElement):
"""DDL element to drop a PostgreSQL view.
Example:
```python
from sqlalchemy_pgview.ddl import DropView
engine.execute(DropView("user_stats"))
```
"""
inherit_cache = False
def __init__(
self,
view: Annotated[View | str, Doc("The View object or view name to drop.")],
*,
schema: Annotated[str | None, Doc("Schema name (only used if view is a string).")] = None,
if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = False,
cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False,
) -> None:
if isinstance(view, str):
self.name = view
self.schema = schema
else:
self.name = view.name
self.schema = view.schema
self.if_exists = if_exists
self.cascade = cascade
@property
def fullname(self) -> str:
"""Return the fully qualified name of the view."""
return get_table_key(name=self.name, schema=self.schema)
@compiles(DropView)
def _compile_drop_view_default(element: DropView, compiler: DDLCompiler, **kw: Any) -> str:
"""Default DropView compiler - raises error for non-PostgreSQL."""
raise NotImplementedError(
f"DropView is only supported on PostgreSQL, not {compiler.dialect.name}"
)
@compiles(DropView, "postgresql")
def _compile_drop_view(element: DropView, compiler: DDLCompiler, **kw: Any) -> str:
"""Compile DropView to PostgreSQL DDL."""
stmt = "DROP VIEW"
if element.if_exists:
stmt += " IF EXISTS"
stmt += f" {element.fullname}"
if element.cascade:
stmt += " CASCADE"
return stmt
class CreateMaterializedView(DDLElement):
"""DDL element to create a PostgreSQL materialized view.
Example:
```python
from sqlalchemy_pgview import MaterializedView
from sqlalchemy_pgview.ddl import CreateMaterializedView
mview = MaterializedView("monthly_sales", select(...))
engine.execute(CreateMaterializedView(mview))
```
"""
inherit_cache = False
def __init__(
self,
view: Annotated[MaterializedView, Doc("The MaterializedView to create.")],
*,
if_not_exists: Annotated[
bool, Doc("If True, don't error if view already exists. Requires PostgreSQL 9.3+.")
] = False,
) -> None:
self.view = view
self.if_not_exists = if_not_exists
@compiles(CreateMaterializedView)
def _compile_create_materialized_view_default(
element: CreateMaterializedView, compiler: DDLCompiler, **kw: Any
) -> str:
"""Default CreateMaterializedView compiler - raises error for non-PostgreSQL."""
raise NotImplementedError(
f"CreateMaterializedView is only supported on PostgreSQL, not {compiler.dialect.name}"
)
@compiles(CreateMaterializedView, "postgresql")
def _compile_create_materialized_view(
element: CreateMaterializedView, compiler: DDLCompiler, **kw: Any
) -> str:
"""Compile CreateMaterializedView to PostgreSQL DDL."""
view = element.view
stmt = "CREATE MATERIALIZED VIEW"
if element.if_not_exists:
stmt += " IF NOT EXISTS"
stmt += f" {view.fullname}"
select_sql = compiler.sql_compiler.process(obj=view.selectable, literal_binds=True)
stmt += f" AS {select_sql}"
if view.with_data:
stmt += " WITH DATA"
else:
stmt += " WITH NO DATA"
return stmt
class DropMaterializedView(DDLElement):
"""DDL element to drop a PostgreSQL materialized view.
Example:
```python
from sqlalchemy_pgview.ddl import DropMaterializedView
engine.execute(DropMaterializedView("monthly_sales"))
```
"""
inherit_cache = False
def __init__(
self,
view: Annotated[
MaterializedView | str, Doc("The MaterializedView object or view name to drop.")
],
*,
schema: Annotated[str | None, Doc("Schema name (only used if view is a string).")] = None,
if_exists: Annotated[bool, Doc("If True, don't error if view doesn't exist.")] = False,
cascade: Annotated[bool, Doc("If True, drop dependent objects.")] = False,
) -> None:
if isinstance(view, str):
self.name = view
self.schema = schema
else:
self.name = view.name
self.schema = view.schema
self.if_exists = if_exists
self.cascade = cascade
@property
def fullname(self) -> str:
"""Return the fully qualified name of the view."""
return get_table_key(name=self.name, schema=self.schema)
@compiles(DropMaterializedView)
def _compile_drop_materialized_view_default(
element: DropMaterializedView, compiler: DDLCompiler, **kw: Any
) -> str:
"""Default DropMaterializedView compiler - raises error for non-PostgreSQL."""
raise NotImplementedError(
f"DropMaterializedView is only supported on PostgreSQL, not {compiler.dialect.name}"
)
@compiles(DropMaterializedView, "postgresql")
def _compile_drop_materialized_view(
element: DropMaterializedView, compiler: DDLCompiler, **kw: Any
) -> str:
"""Compile DropMaterializedView to PostgreSQL DDL."""
stmt = "DROP MATERIALIZED VIEW"
if element.if_exists:
stmt += " IF EXISTS"
stmt += f" {element.fullname}"
if element.cascade:
stmt += " CASCADE"
return stmt
class RefreshMaterializedView(DDLElement):
"""DDL element to refresh a PostgreSQL materialized view.
Example:
```python
from sqlalchemy_pgview.ddl import RefreshMaterializedView
engine.execute(RefreshMaterializedView("monthly_sales", concurrently=True))
```
"""
inherit_cache = False
def __init__(
self,
view: Annotated[
MaterializedView | str, Doc("The MaterializedView object or view name to refresh.")
],
*,
schema: Annotated[str | None, Doc("Schema name (only used if view is a string).")] = None,
concurrently: Annotated[
bool,
Doc(
"If True, refresh without locking out concurrent reads. "
"Requires a unique index on the view."
),
] = False,
with_data: Annotated[
bool, Doc("If True, populate with fresh data. If False, empty the view.")
] = True,
) -> None:
if isinstance(view, str):
self.name = view
self.schema = schema
else:
self.name = view.name
self.schema = view.schema
self.concurrently = concurrently
self.with_data = with_data
@property
def fullname(self) -> str:
"""Return the fully qualified name of the view."""
return get_table_key(name=self.name, schema=self.schema)
@compiles(RefreshMaterializedView)
def _compile_refresh_materialized_view_default(
element: RefreshMaterializedView, compiler: DDLCompiler, **kw: Any
) -> str:
"""Default RefreshMaterializedView compiler - raises error for non-PostgreSQL."""
raise NotImplementedError(
f"RefreshMaterializedView is only supported on PostgreSQL, not {compiler.dialect.name}"
)
@compiles(RefreshMaterializedView, "postgresql")
def _compile_refresh_materialized_view(
element: RefreshMaterializedView, compiler: DDLCompiler, **kw: Any
) -> str:
"""Compile RefreshMaterializedView to PostgreSQL DDL."""
stmt = "REFRESH MATERIALIZED VIEW"
if element.concurrently:
stmt += " CONCURRENTLY"
stmt += f" {element.fullname}"
if element.with_data:
stmt += " WITH DATA"
else:
stmt += " WITH NO DATA"
return stmt

View File

@@ -0,0 +1,298 @@
"""Declarative base classes for PostgreSQL views.
This module provides class-based view declarations similar to SQLAlchemy's
declarative ORM models. Use multiple inheritance with DeclarativeBase to
share metadata.
Example:
```python
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy_pgview import ViewBase, MaterializedViewBase
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
class ActiveUsers(ViewBase, Base):
__tablename__ = "active_users"
__select__ = select(User.id, User.name).where(User.is_active == True)
class UserStats(MaterializedViewBase, Base):
__tablename__ = "user_stats"
__select__ = select(func.count(User.id).label("count"))
# Create tables AND views together
Base.metadata.create_all(engine)
```
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated, Any, ClassVar
from annotated_doc import Doc
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy_pgview.view import (
MaterializedView,
View,
)
if TYPE_CHECKING:
from sqlalchemy import Table
from sqlalchemy.orm import Session
from sqlalchemy.sql import Select
# Get SQLAlchemy's DeclarativeMeta for compatibility
_SQLAlchemyDeclarativeMeta = type(DeclarativeBase)
def _find_metadata_from_bases(bases: tuple[type, ...]) -> Any:
"""Find MetaData instance from parent classes.
Searches through base classes for a metadata attribute, checking both
direct metadata attributes and registry.metadata for DeclarativeBase subclasses.
"""
for base in bases:
if hasattr(base, "metadata"):
inherited_meta = base.metadata
if inherited_meta is not None and hasattr(inherited_meta, "tables"):
return inherited_meta
if hasattr(base, "registry") and hasattr(base.registry, "metadata"):
return base.registry.metadata
return None
class ViewDeclarativeMeta(_SQLAlchemyDeclarativeMeta):
"""Metaclass for declarative view classes.
Inherits from SQLAlchemy's DeclarativeMeta to allow multiple inheritance
with DeclarativeBase subclasses.
"""
def __new__(
mcs,
name: str,
bases: tuple[type, ...],
namespace: dict[str, Any],
**kwargs: Any,
) -> ViewDeclarativeMeta:
# Mark as abstract to prevent SQLAlchemy from creating a mapper
# This allows inheriting from DeclarativeBase without ORM mapping
if "__abstract__" not in namespace:
namespace["__abstract__"] = True
cls = super().__new__(mcs, name, bases, namespace)
# Skip base classes
if name in ("ViewBase", "MaterializedViewBase"):
return cls
# Get required attributes
tablename = namespace.get("__tablename__")
select_stmt = namespace.get("__select__")
if tablename is None:
raise TypeError(f"View class {name} must define __tablename__")
if select_stmt is None:
raise TypeError(f"View class {name} must define __select__")
# Get optional attributes
schema = namespace.get("__schema__")
# Get metadata from parent classes (via multiple inheritance)
metadata = _find_metadata_from_bases(bases=bases)
# Determine if this is a materialized view
is_materialized = any(
hasattr(base, "_is_materialized_view") and base._is_materialized_view for base in bases
)
# Create the underlying View or MaterializedView
if is_materialized:
with_data = namespace.get("__with_data__", True)
view = MaterializedView(
name=tablename,
selectable=select_stmt,
schema=schema,
metadata=metadata,
)
view.with_data = with_data
else:
view = View(
name=tablename,
selectable=select_stmt,
schema=schema,
metadata=metadata,
)
cls._view = view
cls._is_materialized_view = is_materialized
cls._table = view.as_table()
return cls
@property
def c(cls) -> Any:
"""Column collection for the view (shorthand for as_table().c)."""
return cls._table.c # type: ignore[attr-defined]
@property
def columns(cls) -> Any:
"""Column collection for the view."""
return cls._table.columns # type: ignore[attr-defined]
class ViewBase(metaclass=ViewDeclarativeMeta):
"""Base class for declarative view definitions.
Subclass this along with your DeclarativeBase to define a PostgreSQL view.
Required class attributes:
__tablename__: The name of the view in the database.
__select__: The SELECT statement that defines the view.
Optional class attributes:
__schema__: The database schema (default: None).
Example:
```python
from sqlalchemy.orm import DeclarativeBase
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]
is_active: Mapped[bool]
class ActiveUsers(ViewBase, Base):
__tablename__ = "active_users"
__select__ = select(User.id, User.name).where(User.is_active == True)
# Create tables AND views
Base.metadata.create_all(engine)
# Query the view
with engine.connect() as conn:
result = conn.execute(select(ActiveUsers.c.name)).fetchall()
```
"""
__tablename__: ClassVar[str]
__select__: ClassVar[Select[Any]]
__schema__: ClassVar[str | None] = None
_view: ClassVar[View]
_table: ClassVar[Table]
_is_materialized_view: ClassVar[bool] = False
def __class_getitem__(cls, item: Any) -> Any:
"""Support for generic type hints."""
return cls
@classmethod
def as_table(cls) -> Table:
"""Return the Table representation for querying."""
return cls._table
@classmethod
def as_view(cls) -> View:
"""Return the underlying View instance."""
return cls._view
class MaterializedViewBase(metaclass=ViewDeclarativeMeta):
"""Base class for declarative materialized view definitions.
Subclass this along with your DeclarativeBase to define a materialized view.
Required class attributes:
__tablename__: The name of the materialized view.
__select__: The SELECT statement that defines the view.
Optional class attributes:
__schema__: The database schema (default: None).
__with_data__: Whether to populate on creation (default: True).
Example:
```python
from sqlalchemy.orm import DeclarativeBase
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]
class OrderStats(MaterializedViewBase, Base):
__tablename__ = "order_stats"
__select__ = select(
func.count(Order.id).label("count"),
func.sum(Order.total).label("total"),
)
# Create tables AND views
Base.metadata.create_all(engine)
# Refresh the materialized view
with engine.begin() as conn:
OrderStats.refresh(conn)
```
"""
__tablename__: ClassVar[str]
__select__: ClassVar[Select[Any]]
__schema__: ClassVar[str | None] = None
__with_data__: ClassVar[bool] = True
_view: ClassVar[MaterializedView]
_table: ClassVar[Table]
_is_materialized_view: ClassVar[bool] = True
def __class_getitem__(cls, item: Any) -> Any:
"""Support for generic type hints."""
return cls
@classmethod
def as_table(cls) -> Table:
"""Return the Table representation for querying."""
return cls._table
@classmethod
def as_view(cls) -> MaterializedView:
"""Return the underlying MaterializedView instance."""
return cls._view
@classmethod
def refresh(
cls,
connection: Annotated[Any, Doc("SQLAlchemy connection.")],
*,
concurrently: Annotated[bool, Doc("If True, refresh without blocking reads.")] = False,
with_data: Annotated[bool, Doc("If True, populate with fresh data.")] = True,
) -> None:
"""Refresh the materialized view."""
cls._view.refresh(connection=connection, concurrently=concurrently, with_data=with_data)
@classmethod
def auto_refresh_on(
cls,
session_class: Annotated[type[Session], Doc("The Session class to attach events to.")],
*tables: Annotated[Table, Doc("Tables to watch for changes.")],
concurrently: Annotated[bool, Doc("If True, use CONCURRENTLY when refreshing.")] = False,
) -> None:
"""Enable automatic refresh when watched tables are modified."""
cls._view.auto_refresh_on(session_class, *tables, concurrently=concurrently)

View File

@@ -0,0 +1,274 @@
"""Dependency tracking for PostgreSQL views."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from annotated_doc import Doc
from sqlalchemy import text
if TYPE_CHECKING:
from sqlalchemy.engine import Connection
from sqlalchemy_pgview.util import get_table_key
@dataclass
class ViewInfo:
"""Information about a view from the database."""
name: Annotated[str, Doc("View name.")]
schema: Annotated[str | None, Doc("Schema name.")]
definition: Annotated[str, Doc("SQL definition of the view.")]
is_materialized: Annotated[bool, Doc("Whether this is a materialized view.")]
@property
def fullname(self) -> str:
"""Return the fully qualified name of the view."""
return get_table_key(name=self.name, schema=self.schema)
@dataclass
class ViewDependency:
"""Represents a dependency between views."""
dependent_view: Annotated[str, Doc("Name of the dependent view.")]
dependent_schema: Annotated[str, Doc("Schema of the dependent view.")]
referenced_view: Annotated[str, Doc("Name of the referenced view.")]
referenced_schema: Annotated[str, Doc("Schema of the referenced view.")]
@property
def dependent_fullname(self) -> str:
"""Return the fully qualified name of the dependent view."""
return get_table_key(name=self.dependent_view, schema=self.dependent_schema)
@property
def referenced_fullname(self) -> str:
"""Return the fully qualified name of the referenced view."""
return get_table_key(name=self.referenced_view, schema=self.referenced_schema)
def get_view_definition(
connection: Annotated[Connection, Doc("SQLAlchemy connection.")],
view_name: Annotated[str, Doc("Name of the view.")],
schema: Annotated[str, Doc("Schema name.")] = "public",
) -> str | None:
"""Get the SQL definition of a view."""
result = connection.execute(
text("""
SELECT pg_get_viewdef(c.oid, true) as definition
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = :view_name
AND n.nspname = :schema
AND c.relkind IN ('v', 'm')
"""),
{"view_name": view_name, "schema": schema},
)
row = result.fetchone()
return row[0] if row else None
def get_all_views(
connection: Annotated[Connection, Doc("SQLAlchemy connection.")],
schema: Annotated[str | None, Doc("If provided, only return views in this schema.")] = None,
include_materialized: Annotated[bool, Doc("If True, include materialized views.")] = True,
) -> list[ViewInfo]:
"""Get all views in the database."""
relkinds = "('v', 'm')" if include_materialized else "('v')"
query = f"""
SELECT
c.relname as name,
n.nspname as schema,
pg_get_viewdef(c.oid, true) as definition,
c.relkind = 'm' as is_materialized
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN {relkinds}
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
"""
params: dict[str, str] = {}
if schema:
query += " AND n.nspname = :schema"
params["schema"] = schema
query += " ORDER BY n.nspname, c.relname"
result = connection.execute(text(query), params)
return [
ViewInfo(
name=row[0],
schema=row[1],
definition=row[2],
is_materialized=row[3],
)
for row in result.fetchall()
]
def get_view_dependencies(
connection: Annotated[Connection, Doc("SQLAlchemy connection.")],
view_name: Annotated[
str | None, Doc("If provided, only return dependencies for this view.")
] = None,
schema: Annotated[str, Doc("Schema name for filtering.")] = "public",
) -> list[ViewDependency]:
"""Get dependencies between views.
This queries the pg_depend system catalog to find which views
depend on other views.
"""
query = """
SELECT DISTINCT
dependent.relname as dependent_view,
dep_ns.nspname as dependent_schema,
referenced.relname as referenced_view,
ref_ns.nspname as referenced_schema
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class dependent ON dependent.oid = r.ev_class
JOIN pg_class referenced ON referenced.oid = d.refobjid
JOIN pg_namespace dep_ns ON dep_ns.oid = dependent.relnamespace
JOIN pg_namespace ref_ns ON ref_ns.oid = referenced.relnamespace
WHERE d.classid = 'pg_rewrite'::regclass
AND d.deptype = 'n'
AND dependent.relkind IN ('v', 'm')
AND referenced.relkind IN ('v', 'm', 'r')
AND dependent.relname != referenced.relname
AND dep_ns.nspname NOT IN ('pg_catalog', 'information_schema')
AND ref_ns.nspname NOT IN ('pg_catalog', 'information_schema')
"""
params: dict[str, str] = {}
if view_name:
query += " AND dependent.relname = :view_name AND dep_ns.nspname = :schema"
params["view_name"] = view_name
params["schema"] = schema
query += " ORDER BY dep_ns.nspname, dependent.relname"
result = connection.execute(text(query), params)
return [
ViewDependency(
dependent_view=row[0],
dependent_schema=row[1],
referenced_view=row[2],
referenced_schema=row[3],
)
for row in result.fetchall()
]
def get_dependency_order(
connection: Annotated[Connection, Doc("SQLAlchemy connection.")],
schema: Annotated[str | None, Doc("If provided, only consider views in this schema.")] = None,
) -> list[ViewInfo]:
"""Get views in dependency order (dependencies first).
This returns views sorted so that a view appears after all views
it depends on. This is useful for migrations where views need to
be created in the correct order.
"""
views = get_all_views(connection, schema=schema)
dependencies = get_view_dependencies(connection)
# Build a dependency graph
view_map = {v.fullname: v for v in views}
dependents: dict[str, set[str]] = {v.fullname: set() for v in views}
for dep in dependencies:
if dep.dependent_fullname in dependents and dep.referenced_fullname in view_map:
dependents[dep.dependent_fullname].add(dep.referenced_fullname)
# Topological sort using Kahn's algorithm
in_degree = {name: len(deps) for name, deps in dependents.items()}
queue = [name for name, degree in in_degree.items() if degree == 0]
result: list[ViewInfo] = []
while queue:
current = queue.pop(0)
if current in view_map:
result.append(view_map[current])
for name, deps in dependents.items():
if current in deps:
in_degree[name] -= 1
if in_degree[name] == 0:
queue.append(name)
return result
def get_reverse_dependencies(
connection: Annotated[Connection, Doc("SQLAlchemy connection.")],
view_name: Annotated[str, Doc("Name of the view to check.")],
schema: Annotated[str, Doc("Schema name.")] = "public",
) -> list[ViewInfo]:
"""Get all views that depend on the given view.
This is useful when you need to drop or modify a view and need
to know what other views would be affected.
"""
result = connection.execute(
text("""
WITH RECURSIVE dependent_views AS (
SELECT DISTINCT
dependent.relname as name,
dep_ns.nspname as schema,
pg_get_viewdef(dependent.oid, true) as definition,
dependent.relkind = 'm' as is_materialized,
1 as level
FROM pg_depend d
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class dependent ON dependent.oid = r.ev_class
JOIN pg_class referenced ON referenced.oid = d.refobjid
JOIN pg_namespace dep_ns ON dep_ns.oid = dependent.relnamespace
JOIN pg_namespace ref_ns ON ref_ns.oid = referenced.relnamespace
WHERE d.classid = 'pg_rewrite'::regclass
AND d.deptype = 'n'
AND dependent.relkind IN ('v', 'm')
AND referenced.relname = :view_name
AND ref_ns.nspname = :schema
AND dependent.relname != referenced.relname
UNION
SELECT DISTINCT
dependent.relname,
dep_ns.nspname,
pg_get_viewdef(dependent.oid, true),
dependent.relkind = 'm',
dv.level + 1
FROM dependent_views dv
JOIN pg_class referenced ON referenced.relname = dv.name
JOIN pg_namespace ref_ns ON ref_ns.oid = referenced.relnamespace
AND ref_ns.nspname = dv.schema
JOIN pg_depend d ON d.refobjid = referenced.oid
JOIN pg_rewrite r ON r.oid = d.objid
JOIN pg_class dependent ON dependent.oid = r.ev_class
JOIN pg_namespace dep_ns ON dep_ns.oid = dependent.relnamespace
WHERE d.classid = 'pg_rewrite'::regclass
AND d.deptype = 'n'
AND dependent.relkind IN ('v', 'm')
AND dependent.relname != referenced.relname
)
SELECT DISTINCT name, schema, definition, is_materialized
FROM dependent_views
ORDER BY schema, name
"""),
{"view_name": view_name, "schema": schema},
)
return [
ViewInfo(
name=row[0],
schema=row[1],
definition=row[2],
is_materialized=row[3],
)
for row in result.fetchall()
]

View File

View File

@@ -0,0 +1,8 @@
"""Util functions."""
def get_table_key(name: str, schema: str | None) -> str:
"""Generate a unique key for a view."""
if schema:
return f"{schema}.{name}"
return name

View File

@@ -0,0 +1,407 @@
"""Core view classes for PostgreSQL views and materialized views."""
from __future__ import annotations
import weakref
from typing import TYPE_CHECKING, Annotated, Any
from annotated_doc import Doc
from sqlalchemy import Column, MetaData, Table, event
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session # noqa: TC002
if TYPE_CHECKING:
from sqlalchemy.engine import Connection
from sqlalchemy.sql import FromClause, Select
from sqlalchemy_pgview.util import get_table_key
_VIEWS_KEY = "pgview_views"
_MATERIALIZED_VIEWS_KEY = "pgview_materialized_views"
_EVENTS_REGISTERED_KEY = "pgview_events_registered"
def get_views(
metadata: Annotated[MetaData, Doc("The MetaData instance.")],
) -> dict[str, View]:
"""Get all registered views for a metadata instance."""
return dict(metadata.info.get(_VIEWS_KEY, {}))
def get_materialized_views(
metadata: Annotated[MetaData, Doc("The MetaData instance.")],
) -> dict[str, MaterializedView]:
"""Get all registered materialized views for a metadata instance."""
return dict(metadata.info.get(_MATERIALIZED_VIEWS_KEY, {}))
def _register_metadata_events(metadata: MetaData) -> None:
"""Register create/drop events on metadata (once per metadata instance)."""
if metadata.info.get(_EVENTS_REGISTERED_KEY):
return
metadata.info[_EVENTS_REGISTERED_KEY] = True
@event.listens_for(metadata, "after_create")
def _create_views(target: MetaData, connection: Connection, **kw: Any) -> None:
from sqlalchemy_pgview.ddl import CreateMaterializedView, CreateView
# Create regular views first
for view in get_views(metadata=target).values():
connection.execute(CreateView(view=view, or_replace=True))
# Then materialized views
for mview in get_materialized_views(metadata=target).values():
connection.execute(CreateMaterializedView(view=mview, if_not_exists=True))
@event.listens_for(metadata, "before_drop")
def _drop_views(target: MetaData, connection: Connection, **kw: Any) -> None:
from sqlalchemy_pgview.ddl import DropMaterializedView, DropView
# Drop materialized views first (they might depend on regular views)
for mview in reversed(list(get_materialized_views(metadata=target).values())):
connection.execute(DropMaterializedView(view=mview, if_exists=True, cascade=True))
# Then regular views
for view in reversed(list(get_views(metadata=target).values())):
connection.execute(DropView(view=view, if_exists=True, cascade=True))
class View:
"""Represents a PostgreSQL view.
A view is a virtual table based on a SELECT query. This class provides
SQLAlchemy integration for creating, dropping, and querying views.
When a MetaData instance is provided, the view is automatically registered
and will be created/dropped with metadata.create_all()/metadata.drop_all().
Example:
```python
from sqlalchemy import select, func, MetaData
from sqlalchemy_pgview import View
metadata = MetaData()
# View is auto-registered with metadata
user_stats = View(
name="user_stats",
metadata=metadata,
selectable=select(User.id, func.count(Order.id).label("order_count"))
.join(Order)
.group_by(User.id),
)
# Creates tables AND views
metadata.create_all(bind=engine)
```
"""
__slots__ = ("__weakref__", "_columns", "_table", "metadata", "name", "schema", "selectable")
def __init__(
self,
name: Annotated[str, Doc("The name of the view.")],
selectable: Annotated[Select[Any], Doc("The SELECT statement that defines the view.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
metadata: Annotated[
MetaData | None,
Doc(
"SQLAlchemy MetaData instance. If provided, the view is automatically "
"registered for create_all/drop_all."
),
] = None,
) -> None:
self.name = name
self.selectable = selectable
self.schema = schema
self.metadata = metadata or MetaData()
self._columns: tuple[Column[Any], ...] | None = None
self._table: Table | None = None
# Auto-register with metadata if provided
if metadata is not None:
self._register_with_metadata()
def _register_with_metadata(self) -> None:
"""Register this view with its metadata."""
_register_metadata_events(metadata=self.metadata)
if _VIEWS_KEY not in self.metadata.info:
self.metadata.info[_VIEWS_KEY] = {}
key = get_table_key(name=self.name, schema=self.schema)
self.metadata.info[_VIEWS_KEY][key] = self
@property
def fullname(self) -> str:
"""Return the fully qualified name of the view."""
return get_table_key(name=self.name, schema=self.schema)
@property
def columns(self) -> tuple[Column[Any], ...]:
"""Return the columns derived from the selectable."""
if self._columns is None:
self._columns = tuple(Column(c.name, c.type) for c in self.selectable.selected_columns)
return self._columns
def as_table(self) -> Table:
"""Return a Table representation for querying the view.
This allows using the view in SELECT statements as if it were a table.
The Table is cached after first creation.
Note: The returned Table uses a separate MetaData to avoid conflicts
with the view's MetaData during create_all/drop_all operations.
Returns:
A SQLAlchemy Table object representing the view.
"""
if self._table is None:
# Use a separate MetaData to avoid the table being included
# in create_all/drop_all operations on the view's metadata
table_metadata = MetaData()
self._table = Table(
self.name,
table_metadata,
*self.columns,
schema=self.schema,
)
return self._table
def as_from_clause(self) -> FromClause:
"""Return a FromClause for use in queries."""
return self.as_table()
def __repr__(self) -> str:
return f"View({self.name!r}, schema={self.schema!r})"
class MaterializedView(View):
"""Represents a PostgreSQL materialized view.
A materialized view stores the result of a query physically, unlike
regular views which are computed on each access. Materialized views
can be refreshed to update their contents.
When a MetaData instance is provided, the view is automatically registered
and will be created/dropped with metadata.create_all()/metadata.drop_all().
Auto-refresh can be enabled to automatically refresh the view when
watched tables are modified.
Example:
```python
from sqlalchemy import select, func, MetaData
from sqlalchemy_pgview import MaterializedView
metadata = MetaData()
monthly_sales = MaterializedView(
name="monthly_sales",
metadata=metadata,
selectable=select(
func.date_trunc('month', Order.created_at).label("month"),
func.sum(Order.total).label("total_sales")
).group_by(func.date_trunc('month', Order.created_at)),
with_data=True,
)
# Enable auto-refresh when orders table changes
monthly_sales.auto_refresh_on(orders_table)
# Creates tables AND materialized views
metadata.create_all(engine)
```
"""
__slots__ = ("_watched_tables", "indexes", "with_data")
def __init__(
self,
name: Annotated[str, Doc("The name of the materialized view.")],
selectable: Annotated[Select[Any], Doc("The SELECT statement that defines the view.")],
*,
schema: Annotated[str | None, Doc("Optional schema name.")] = None,
metadata: Annotated[
MetaData | None,
Doc(
"SQLAlchemy MetaData instance. If provided, the view is automatically "
"registered for create_all/drop_all."
),
] = None,
with_data: Annotated[bool, Doc("If True, populate the view with data on creation.")] = True,
indexes: Annotated[
list[str] | None, Doc("List of column names to create indexes on.")
] = None,
) -> None:
# Initialize base attributes without auto-registration
self.name = name
self.selectable = selectable
self.schema = schema
self.metadata = metadata or MetaData()
self._columns: tuple[Column[Any], ...] | None = None
self._table: Table | None = None
# Materialized view specific attributes
self.with_data = with_data
self.indexes = indexes or []
self._watched_tables: list[Table] = []
# Register with materialized views registry
if metadata is not None:
self._register_with_metadata()
def _register_with_metadata(self) -> None:
"""Register this materialized view with its metadata."""
_register_metadata_events(self.metadata)
if _MATERIALIZED_VIEWS_KEY not in self.metadata.info:
self.metadata.info[_MATERIALIZED_VIEWS_KEY] = {}
key = get_table_key(self.name, self.schema)
self.metadata.info[_MATERIALIZED_VIEWS_KEY][key] = self
def refresh(
self,
connection: Annotated[Connection, Doc("SQLAlchemy connection to execute the refresh.")],
*,
concurrently: Annotated[
bool,
Doc(
"If True, refresh without locking out concurrent reads. "
"Requires a unique index on the view."
),
] = False,
with_data: Annotated[
bool, Doc("If True, populate with fresh data. If False, empty the view.")
] = True,
) -> None:
"""Refresh the materialized view."""
from sqlalchemy_pgview.ddl import RefreshMaterializedView
stmt = RefreshMaterializedView(view=self, concurrently=concurrently, with_data=with_data)
connection.execute(statement=stmt)
def auto_refresh_on(
self,
session_class: Annotated[
type[Session], Doc("The Session class to attach events to (usually Session).")
],
*tables: Annotated[Table, Doc("One or more Table objects to watch for changes.")],
concurrently: Annotated[
bool,
Doc("If True, use CONCURRENTLY when refreshing. Requires a unique index on the view."),
] = False,
) -> None:
"""Enable automatic refresh when watched tables are modified via ORM.
Sets up SQLAlchemy ORM event listeners to automatically refresh this
materialized view after INSERT, UPDATE, or DELETE operations on
ORM models mapped to the specified tables.
Example:
```python
from sqlalchemy.orm import Session
order_stats = MaterializedView(
"order_stats",
select(func.count(orders.c.id)),
metadata=metadata,
)
# Enable auto-refresh for all sessions
order_stats.auto_refresh_on(Session, orders)
# Now when you commit changes via ORM, the view refreshes
with Session(engine) as session:
session.add(Order(total=100))
session.commit() # View is refreshed here
```
Note:
- Works with ORM mapped classes only
- Refresh happens after each commit that modified watched tables
- For high-frequency writes, consider manual refresh instead
- For Core-only usage, use `setup_auto_refresh()` function instead
"""
mview_ref = weakref.ref(self)
table_names = {t.name for t in tables}
for table in tables:
if table in self._watched_tables:
continue
self._watched_tables.append(table)
# Key to track pending refresh in session
pending_key = f"_pgview_pending_{self.fullname}"
@event.listens_for(session_class, "after_flush")
def after_flush(sess: Session, flush_context: Any) -> None:
"""Check if watched tables were modified."""
for obj in list(sess.new) + list(sess.dirty) + list(sess.deleted):
mapper = getattr(obj.__class__, "__mapper__", None)
if mapper is not None:
tbl = mapper.persist_selectable
if tbl.name in table_names:
sess.info[pending_key] = True
return
@event.listens_for(session_class, "after_commit")
def after_commit(sess: Session) -> None:
"""Refresh if needed after commit."""
if sess.info.pop(pending_key, False):
mview = mview_ref()
if mview is not None:
bind = sess.get_bind()
# Handle both Engine and Connection
if isinstance(bind, Engine):
with bind.connect() as conn:
mview.refresh(connection=conn, concurrently=concurrently)
conn.commit()
else:
# bind is already a Connection
mview.refresh(connection=bind, concurrently=concurrently)
def __repr__(self) -> str:
return (
f"MaterializedView({self.name!r}, schema={self.schema!r}, with_data={self.with_data})"
)
class AutoRefreshContext:
"""Context manager for auto-refreshing materialized views (Core usage).
Use this when working with SQLAlchemy Core (without ORM) to automatically
refresh materialized views after modifications to watched tables.
Example:
```python
with engine.begin() as conn:
with AutoRefreshContext(conn, order_stats, orders_table):
conn.execute(insert(orders_table).values(total=100))
# View is refreshed when exiting the context
```
"""
def __init__(
self,
connection: Annotated[Connection, Doc("Active SQLAlchemy Connection.")],
mview: Annotated[MaterializedView, Doc("The MaterializedView to refresh.")],
*tables: Annotated[Table, Doc("Tables to watch (currently informational only).")],
concurrently: Annotated[bool, Doc("If True, use CONCURRENTLY when refreshing.")] = False,
) -> None:
self.connection = connection
self.mview = mview
self.tables = tables
self.concurrently = concurrently
def __enter__(self) -> AutoRefreshContext:
return self
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
if exc_type is None:
# No exception - refresh the view
self.mview.refresh(connection=self.connection, concurrently=self.concurrently)

59
test.py Normal file
View File

@@ -0,0 +1,59 @@
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Integer,
MetaData,
Numeric,
String,
Table,
create_engine,
func,
select,
)
from sqlalchemy_pgview import MaterializedView, View
# Create engine and metadata
engine = create_engine("postgresql://postgres:postgres@localhost/postgres")
metadata = MetaData()
# Define some tables
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100)),
Column("email", String(100)),
)
orders = Table(
"orders",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("users.id")),
Column("total", Numeric(10, 2)),
Column("created_at", DateTime, server_default=func.now()),
)
active_users = View(
"active_users",
select(users.c.id, users.c.name, users.c.email),
metadata=metadata,
)
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)),
with_data=True,
metadata=metadata,
)
# Create tables
metadata.create_all(engine)
# metadata.drop_all(engine)

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Tests for sqlalchemy-pgview."""

270
tests/conftest.py Normal file
View File

@@ -0,0 +1,270 @@
"""Pytest configuration and fixtures."""
import pytest
from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Integer,
MetaData,
Numeric,
String,
Table,
create_engine,
func,
insert,
)
from sqlalchemy.engine import Engine
@pytest.fixture
def metadata() -> MetaData:
"""Create a fresh MetaData instance."""
return MetaData()
@pytest.fixture
def pg_engine() -> Engine | None:
"""Create a PostgreSQL engine if available.
Set the POSTGRES_URL environment variable to run PostgreSQL tests.
Example: postgresql://user:pass@localhost:5432/testdb
"""
import os
url = os.environ.get("POSTGRES_URL")
if not url:
pytest.skip("POSTGRES_URL not set")
return create_engine(url)
@pytest.fixture
def sample_tables(metadata: MetaData) -> tuple[Table, Table]:
"""Create sample tables for testing."""
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100)),
Column("email", String(100)),
)
orders = Table(
"orders",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer),
Column("total", Integer),
)
return users, orders
@pytest.fixture
def one_to_many_tables(metadata: MetaData) -> dict[str, Table]:
"""Create tables with one-to-many relationships.
Schema:
authors (1) --< (many) books
books (1) --< (many) reviews
"""
authors = Table(
"authors",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100), nullable=False),
Column("country", String(50)),
)
books = Table(
"books",
metadata,
Column("id", Integer, primary_key=True),
Column("title", String(200), nullable=False),
Column("author_id", Integer, ForeignKey("authors.id"), nullable=False),
Column("price", Numeric(10, 2)),
Column("published_at", DateTime),
)
reviews = Table(
"reviews",
metadata,
Column("id", Integer, primary_key=True),
Column("book_id", Integer, ForeignKey("books.id"), nullable=False),
Column("rating", Integer, nullable=False),
Column("comment", String(500)),
)
return {"authors": authors, "books": books, "reviews": reviews}
@pytest.fixture
def many_to_many_tables(metadata: MetaData) -> dict[str, Table]:
"""Create tables with many-to-many relationships.
Schema:
students (many) --< student_courses >-- (many) courses
courses (many) --< course_tags >-- (many) tags
"""
students = Table(
"students",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100), nullable=False),
Column("email", String(100), unique=True),
)
courses = Table(
"courses",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100), nullable=False),
Column("credits", Integer, default=3),
)
student_courses = Table(
"student_courses",
metadata,
Column("student_id", Integer, ForeignKey("students.id"), primary_key=True),
Column("course_id", Integer, ForeignKey("courses.id"), primary_key=True),
Column("grade", Numeric(3, 2)),
Column("enrolled_at", DateTime, server_default=func.now()),
)
tags = Table(
"tags",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(50), unique=True, nullable=False),
)
course_tags = Table(
"course_tags",
metadata,
Column("course_id", Integer, ForeignKey("courses.id"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True),
)
return {
"students": students,
"courses": courses,
"student_courses": student_courses,
"tags": tags,
"course_tags": course_tags,
}
@pytest.fixture
def pg_one_to_many_tables(
pg_engine: Engine, one_to_many_tables: dict[str, Table], metadata: MetaData
) -> dict[str, Table]:
"""Create one-to-many tables in PostgreSQL with sample data."""
metadata.create_all(pg_engine)
with pg_engine.begin() as conn:
# Insert authors
conn.execute(
insert(one_to_many_tables["authors"]),
[
{"id": 1, "name": "George Orwell", "country": "UK"},
{"id": 2, "name": "Gabriel Garcia Marquez", "country": "Colombia"},
{"id": 3, "name": "Haruki Murakami", "country": "Japan"},
],
)
# Insert books
conn.execute(
insert(one_to_many_tables["books"]),
[
{"id": 1, "title": "1984", "author_id": 1, "price": 15.99},
{"id": 2, "title": "Animal Farm", "author_id": 1, "price": 12.99},
{"id": 3, "title": "One Hundred Years of Solitude", "author_id": 2, "price": 18.99},
{"id": 4, "title": "Norwegian Wood", "author_id": 3, "price": 14.99},
{"id": 5, "title": "Kafka on the Shore", "author_id": 3, "price": 16.99},
],
)
# Insert reviews
conn.execute(
insert(one_to_many_tables["reviews"]),
[
{"id": 1, "book_id": 1, "rating": 5, "comment": "A masterpiece"},
{"id": 2, "book_id": 1, "rating": 4, "comment": "Thought-provoking"},
{"id": 3, "book_id": 1, "rating": 5, "comment": "Must read"},
{"id": 4, "book_id": 2, "rating": 4, "comment": "Great allegory"},
{"id": 5, "book_id": 3, "rating": 5, "comment": "Beautiful prose"},
{"id": 6, "book_id": 4, "rating": 4, "comment": "Melancholic"},
{"id": 7, "book_id": 5, "rating": 5, "comment": "Surreal and captivating"},
],
)
yield one_to_many_tables
metadata.drop_all(pg_engine)
@pytest.fixture
def pg_many_to_many_tables(
pg_engine: Engine, many_to_many_tables: dict[str, Table], metadata: MetaData
) -> dict[str, Table]:
"""Create many-to-many tables in PostgreSQL with sample data."""
metadata.create_all(pg_engine)
with pg_engine.begin() as conn:
# Insert students
conn.execute(
insert(many_to_many_tables["students"]),
[
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
{"id": 3, "name": "Charlie", "email": "charlie@example.com"},
],
)
# Insert courses
conn.execute(
insert(many_to_many_tables["courses"]),
[
{"id": 1, "name": "Database Systems", "credits": 4},
{"id": 2, "name": "Web Development", "credits": 3},
{"id": 3, "name": "Machine Learning", "credits": 4},
],
)
# Insert student_courses (enrollments)
conn.execute(
insert(many_to_many_tables["student_courses"]),
[
{"student_id": 1, "course_id": 1, "grade": 3.8},
{"student_id": 1, "course_id": 2, "grade": 4.0},
{"student_id": 1, "course_id": 3, "grade": 3.5},
{"student_id": 2, "course_id": 1, "grade": 3.2},
{"student_id": 2, "course_id": 3, "grade": 3.9},
{"student_id": 3, "course_id": 2, "grade": 3.7},
],
)
# Insert tags
conn.execute(
insert(many_to_many_tables["tags"]),
[
{"id": 1, "name": "programming"},
{"id": 2, "name": "data"},
{"id": 3, "name": "ai"},
],
)
# Insert course_tags
conn.execute(
insert(many_to_many_tables["course_tags"]),
[
{"course_id": 1, "tag_id": 2}, # Database -> data
{"course_id": 2, "tag_id": 1}, # Web Dev -> programming
{"course_id": 3, "tag_id": 2}, # ML -> data
{"course_id": 3, "tag_id": 3}, # ML -> ai
],
)
yield many_to_many_tables
metadata.drop_all(pg_engine)

1061
tests/test_alembic.py Normal file

File diff suppressed because it is too large Load Diff

396
tests/test_async.py Normal file
View File

@@ -0,0 +1,396 @@
"""Tests for async engine support with asyncpg."""
import os
from decimal import Decimal
import pytest
from sqlalchemy import (
Column,
ForeignKey,
Integer,
MetaData,
Numeric,
String,
Table,
func,
insert,
select,
)
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy_pgview import (
CreateMaterializedView,
CreateView,
DropMaterializedView,
DropView,
MaterializedView,
RefreshMaterializedView,
View,
)
@pytest.fixture
async def async_engine() -> AsyncEngine:
"""Create an async PostgreSQL engine."""
url = os.environ.get("POSTGRES_URL")
if not url:
pytest.skip("POSTGRES_URL not set")
# Convert postgresql:// to postgresql+asyncpg://
async_url = url.replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(async_url)
yield engine
await engine.dispose()
@pytest.fixture
async def async_tables(async_engine: AsyncEngine) -> dict[str, Table]:
"""Create test tables with async engine."""
metadata = MetaData()
users = Table(
"async_users",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100)),
Column("email", String(100)),
)
orders = Table(
"async_orders",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("async_users.id")),
Column("total", Numeric(10, 2)),
)
async with async_engine.begin() as conn:
await conn.run_sync(metadata.create_all)
# Insert test data
await conn.execute(
insert(users),
[
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"},
],
)
await conn.execute(
insert(orders),
[
{"id": 1, "user_id": 1, "total": Decimal("100.00")},
{"id": 2, "user_id": 1, "total": Decimal("200.00")},
{"id": 3, "user_id": 2, "total": Decimal("150.00")},
],
)
yield {"users": users, "orders": orders, "metadata": metadata}
async with async_engine.begin() as conn:
await conn.run_sync(metadata.drop_all)
class TestAsyncView:
"""Tests for View with async engine."""
@pytest.mark.asyncio
async def test_create_view_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test creating a view with async engine."""
users = async_tables["users"]
orders = async_tables["orders"]
user_stats = View(
"async_user_stats",
select(
users.c.id,
users.c.name,
func.count(orders.c.id).label("order_count"),
func.coalesce(func.sum(orders.c.total), 0).label("total_spent"),
)
.select_from(users.outerjoin(orders, users.c.id == orders.c.user_id))
.group_by(users.c.id, users.c.name),
)
async with async_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()).order_by(user_stats.as_table().c.name)
)
rows = result.fetchall()
assert len(rows) == 2
assert rows[0].name == "Alice"
assert rows[0].order_count == 2
assert rows[0].total_spent == Decimal("300.00")
assert rows[1].name == "Bob"
assert rows[1].order_count == 1
assert rows[1].total_spent == Decimal("150.00")
# Drop view
await conn.execute(DropView(user_stats, if_exists=True))
@pytest.mark.asyncio
async def test_view_reflects_changes_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test that view reflects changes immediately with async engine."""
orders = async_tables["orders"]
order_count_view = View(
"async_order_count",
select(func.count(orders.c.id).label("total_orders")),
)
async with async_engine.begin() as conn:
await conn.execute(CreateView(order_count_view, or_replace=True))
# Check initial count
result = await conn.execute(select(order_count_view.as_table()))
row = result.fetchone()
assert row.total_orders == 3
# Insert new order
await conn.execute(
insert(orders).values(id=100, user_id=1, total=Decimal("50.00"))
)
# View should show updated count
result = await conn.execute(select(order_count_view.as_table()))
row = result.fetchone()
assert row.total_orders == 4
await conn.execute(DropView(order_count_view, if_exists=True))
class TestAsyncMaterializedView:
"""Tests for MaterializedView with async engine."""
@pytest.mark.asyncio
async def test_create_materialized_view_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test creating a materialized view with async engine."""
users = async_tables["users"]
orders = async_tables["orders"]
user_summary_mv = MaterializedView(
"async_user_summary_mv",
select(
users.c.id,
users.c.name,
func.count(orders.c.id).label("order_count"),
)
.select_from(users.outerjoin(orders, users.c.id == orders.c.user_id))
.group_by(users.c.id, users.c.name),
with_data=True,
)
async with async_engine.begin() as conn:
# Create materialized view
await conn.execute(CreateMaterializedView(user_summary_mv))
# Query materialized view
result = await conn.execute(
select(user_summary_mv.as_table()).order_by(
user_summary_mv.as_table().c.name
)
)
rows = result.fetchall()
assert len(rows) == 2
assert rows[0].name == "Alice"
assert rows[0].order_count == 2
# Drop materialized view
await conn.execute(DropMaterializedView(user_summary_mv, if_exists=True))
@pytest.mark.asyncio
async def test_materialized_view_stale_until_refresh_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test that materialized view is stale until refreshed with async engine."""
orders = async_tables["orders"]
order_count_mv = MaterializedView(
"async_order_count_mv",
select(func.count(orders.c.id).label("total_orders")),
with_data=True,
)
async with async_engine.begin() as conn:
await conn.execute(CreateMaterializedView(order_count_mv))
# Check initial count
result = await conn.execute(select(order_count_mv.as_table()))
row = result.fetchone()
assert row.total_orders == 3
# Insert new order
await conn.execute(
insert(orders).values(id=101, user_id=1, total=Decimal("75.00"))
)
# Materialized view still shows old count (stale)
result = await conn.execute(select(order_count_mv.as_table()))
row = result.fetchone()
assert row.total_orders == 3 # Still 3!
# Refresh materialized view
await conn.execute(RefreshMaterializedView(order_count_mv))
# Now shows updated count
result = await conn.execute(select(order_count_mv.as_table()))
row = result.fetchone()
assert row.total_orders == 4
await conn.execute(DropMaterializedView(order_count_mv, if_exists=True))
@pytest.mark.asyncio
async def test_materialized_view_refresh_method_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test MaterializedView.refresh() method with async engine."""
orders = async_tables["orders"]
count_mv = MaterializedView(
"async_count_mv",
select(func.count(orders.c.id).label("cnt")),
with_data=True,
)
async with async_engine.begin() as conn:
await conn.execute(CreateMaterializedView(count_mv))
result = await conn.execute(select(count_mv.as_table()))
assert result.fetchone().cnt == 3
# Insert data
await conn.execute(
insert(orders).values(id=102, user_id=2, total=Decimal("25.00"))
)
# Use run_sync to call the synchronous refresh method
def refresh_sync(sync_conn):
count_mv.refresh(sync_conn)
await conn.run_sync(refresh_sync)
result = await conn.execute(select(count_mv.as_table()))
assert result.fetchone().cnt == 4
await conn.execute(DropMaterializedView(count_mv, if_exists=True))
class TestAsyncDependencies:
"""Tests for dependency functions with async engine."""
@pytest.mark.asyncio
async def test_get_all_views_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test get_all_views with async engine using run_sync."""
from sqlalchemy_pgview import get_all_views
users = async_tables["users"]
test_view = View(
"async_test_view_deps",
select(users.c.id, users.c.name),
)
async with async_engine.begin() as conn:
await conn.execute(CreateView(test_view, or_replace=True))
# Use run_sync for dependency functions
def get_views_sync(sync_conn):
return get_all_views(sync_conn)
views = await conn.run_sync(get_views_sync)
view_names = [v.name for v in views]
assert "async_test_view_deps" in view_names
await conn.execute(DropView(test_view, if_exists=True))
@pytest.mark.asyncio
async def test_get_view_definition_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test get_view_definition with async engine."""
from sqlalchemy_pgview import get_view_definition
users = async_tables["users"]
test_view = View(
"async_def_test_view",
select(users.c.id, users.c.name).where(users.c.id > 0),
)
async with async_engine.begin() as conn:
await conn.execute(CreateView(test_view, or_replace=True))
def get_def_sync(sync_conn):
return get_view_definition(sync_conn, "async_def_test_view")
definition = await conn.run_sync(get_def_sync)
assert definition is not None
assert "async_users" in definition.lower()
await conn.execute(DropView(test_view, if_exists=True))
class TestAsyncViewWithJoins:
"""Tests for views with complex joins using async engine."""
@pytest.mark.asyncio
async def test_view_with_aggregation_async(
self, async_engine: AsyncEngine, async_tables: dict
) -> None:
"""Test view with GROUP BY and aggregation functions."""
users = async_tables["users"]
orders = async_tables["orders"]
stats_view = View(
"async_stats_view",
select(
users.c.name,
func.count(orders.c.id).label("orders"),
func.sum(orders.c.total).label("total"),
func.avg(orders.c.total).label("avg_order"),
)
.select_from(users.join(orders, users.c.id == orders.c.user_id))
.group_by(users.c.name),
)
async with async_engine.begin() as conn:
await conn.execute(CreateView(stats_view, or_replace=True))
result = await conn.execute(
select(stats_view.as_table()).order_by(stats_view.as_table().c.total.desc())
)
rows = result.fetchall()
assert len(rows) == 2
# Alice: 2 orders, total 300
alice = next(r for r in rows if r.name == "Alice")
assert alice.orders == 2
assert alice.total == Decimal("300.00")
assert alice.avg_order == Decimal("150.00")
# Bob: 1 order, total 150
bob = next(r for r in rows if r.name == "Bob")
assert bob.orders == 1
assert bob.total == Decimal("150.00")
await conn.execute(DropView(stats_view, if_exists=True))

369
tests/test_auto_refresh.py Normal file
View File

@@ -0,0 +1,369 @@
"""Tests for auto-refresh functionality."""
from __future__ import annotations
import os
from decimal import Decimal
from typing import TYPE_CHECKING
import pytest
from sqlalchemy import (
Column,
Integer,
MetaData,
Numeric,
String,
Table,
create_engine,
func,
insert,
select,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from sqlalchemy_pgview import AutoRefreshContext, MaterializedView
from sqlalchemy_pgview.ddl import DropMaterializedView
if TYPE_CHECKING:
from sqlalchemy.engine import Engine
@pytest.fixture
def pg_engine() -> Engine:
"""Create a PostgreSQL engine for testing."""
url = os.environ.get("POSTGRES_URL", "postgresql://test:test@localhost:5432/testdb")
return create_engine(url)
class TestAutoRefreshContext:
"""Tests for AutoRefreshContext (Core usage)."""
def test_auto_refresh_context_refreshes_on_exit(self, pg_engine: Engine) -> None:
"""Test that AutoRefreshContext refreshes view on successful exit."""
metadata = MetaData()
orders = Table(
"test_orders_arc",
metadata,
Column("id", Integer, primary_key=True),
Column("total", Numeric(10, 2)),
)
order_stats = MaterializedView(
"test_order_stats_arc",
select(
func.count(orders.c.id).label("order_count"),
func.sum(orders.c.total).label("total_revenue"),
),
metadata=metadata,
)
try:
metadata.create_all(pg_engine)
with pg_engine.begin() as conn:
# Initial state - empty
result = conn.execute(select(order_stats.as_table())).fetchone()
assert result.order_count == 0
# Use AutoRefreshContext to auto-refresh after changes
with AutoRefreshContext(conn, order_stats, orders):
conn.execute(insert(orders).values(id=1, total=100))
conn.execute(insert(orders).values(id=2, total=200))
# View is refreshed here
# Check the view was refreshed
result = conn.execute(select(order_stats.as_table())).fetchone()
assert result.order_count == 2
assert result.total_revenue == Decimal("300")
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(order_stats, if_exists=True))
metadata.drop_all(pg_engine)
def test_auto_refresh_context_no_refresh_on_exception(
self, pg_engine: Engine
) -> None:
"""Test that AutoRefreshContext doesn't refresh on exception."""
metadata = MetaData()
items = Table(
"test_items_arc",
metadata,
Column("id", Integer, primary_key=True),
Column("value", Integer),
)
item_stats = MaterializedView(
"test_item_stats_arc",
select(func.count(items.c.id).label("item_count")),
metadata=metadata,
)
try:
metadata.create_all(pg_engine)
with pg_engine.begin() as conn:
# Insert initial data and refresh
conn.execute(insert(items).values(id=1, value=10))
item_stats.refresh(conn)
result = conn.execute(select(item_stats.as_table())).fetchone()
assert result.item_count == 1
# Try with exception - should not refresh
try:
with pg_engine.begin() as conn, AutoRefreshContext(conn, item_stats, items):
conn.execute(insert(items).values(id=2, value=20))
raise ValueError("Simulated error")
except ValueError:
pass
# View should still show old data (not refreshed due to exception)
with pg_engine.connect() as conn:
result = conn.execute(select(item_stats.as_table())).fetchone()
# Note: The insert was rolled back, so count is still 1
assert result.item_count == 1
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(item_stats, if_exists=True))
metadata.drop_all(pg_engine)
class TestAutoRefreshORM:
"""Tests for auto_refresh_on with ORM."""
def test_auto_refresh_on_commit(self, pg_engine: Engine) -> None:
"""Test that materialized view refreshes after ORM commit."""
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "test_products_ar"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
metadata = Base.metadata
# Create MV based on products table
product_stats = MaterializedView(
"test_product_stats_ar",
select(
func.count(Product.id).label("product_count"),
func.avg(Product.price).label("avg_price"),
),
metadata=metadata,
)
# Create a custom Session class for this test
class TestSession(Session):
pass
# Enable auto-refresh
product_stats.auto_refresh_on(TestSession, Product.__table__)
try:
metadata.create_all(pg_engine)
# Initial state
with pg_engine.connect() as conn:
result = conn.execute(select(product_stats.as_table())).fetchone()
assert result.product_count == 0
# Add product via ORM
with TestSession(pg_engine) as session:
session.add(Product(id=1, name="Widget", price=Decimal("19.99")))
session.commit() # Should trigger refresh
# Check view was refreshed
with pg_engine.connect() as conn:
result = conn.execute(select(product_stats.as_table())).fetchone()
assert result.product_count == 1
assert result.avg_price == Decimal("19.99")
# Add more products
with TestSession(pg_engine) as session:
session.add(Product(id=2, name="Gadget", price=Decimal("29.99")))
session.add(Product(id=3, name="Gizmo", price=Decimal("9.99")))
session.commit()
# Check view updated
with pg_engine.connect() as conn:
result = conn.execute(select(product_stats.as_table())).fetchone()
assert result.product_count == 3
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(product_stats, if_exists=True))
metadata.drop_all(pg_engine)
def test_auto_refresh_on_update(self, pg_engine: Engine) -> None:
"""Test that materialized view refreshes after ORM update."""
class Base(DeclarativeBase):
pass
class Counter(Base):
__tablename__ = "test_counters_ar"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer)
metadata = Base.metadata
counter_sum = MaterializedView(
"test_counter_sum_ar",
select(func.sum(Counter.value).label("total")),
metadata=metadata,
)
class TestSession(Session):
pass
counter_sum.auto_refresh_on(TestSession, Counter.__table__)
try:
metadata.create_all(pg_engine)
# Add initial data
with TestSession(pg_engine) as session:
session.add(Counter(id=1, value=10))
session.add(Counter(id=2, value=20))
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(counter_sum.as_table())).fetchone()
assert result.total == 30
# Update via ORM
with TestSession(pg_engine) as session:
counter = session.get(Counter, 1)
counter.value = 50
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(counter_sum.as_table())).fetchone()
assert result.total == 70 # 50 + 20
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(counter_sum, if_exists=True))
metadata.drop_all(pg_engine)
def test_auto_refresh_on_delete(self, pg_engine: Engine) -> None:
"""Test that materialized view refreshes after ORM delete."""
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "test_items_ar_del"
id: Mapped[int] = mapped_column(primary_key=True)
metadata = Base.metadata
item_count = MaterializedView(
"test_item_count_ar",
select(func.count(Item.id).label("count")),
metadata=metadata,
)
class TestSession(Session):
pass
item_count.auto_refresh_on(TestSession, Item.__table__)
try:
metadata.create_all(pg_engine)
# Add initial data
with TestSession(pg_engine) as session:
session.add(Item(id=1))
session.add(Item(id=2))
session.add(Item(id=3))
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(item_count.as_table())).fetchone()
assert result.count == 3
# Delete via ORM
with TestSession(pg_engine) as session:
item = session.get(Item, 2)
session.delete(item)
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(item_count.as_table())).fetchone()
assert result.count == 2
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(item_count, if_exists=True))
metadata.drop_all(pg_engine)
def test_no_refresh_on_unrelated_table(self, pg_engine: Engine) -> None:
"""Test that unrelated table changes don't trigger refresh."""
class Base(DeclarativeBase):
pass
class TableA(Base):
__tablename__ = "test_table_a_ar"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer)
class TableB(Base):
__tablename__ = "test_table_b_ar"
id: Mapped[int] = mapped_column(primary_key=True)
metadata = Base.metadata
# MV only watches TableA
table_a_sum = MaterializedView(
"test_table_a_sum_ar",
select(func.sum(TableA.value).label("total")),
metadata=metadata,
)
class TestSession(Session):
pass
# Only watch TableA
table_a_sum.auto_refresh_on(TestSession, TableA.__table__)
try:
metadata.create_all(pg_engine)
# Add to TableA
with TestSession(pg_engine) as session:
session.add(TableA(id=1, value=100))
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(table_a_sum.as_table())).fetchone()
assert result.total == 100
# Add to TableB (should not trigger refresh)
# First, manually make the view stale
with pg_engine.begin() as conn:
conn.execute(text("INSERT INTO test_table_a_ar (id, value) VALUES (2, 200)"))
with TestSession(pg_engine) as session:
session.add(TableB(id=1))
session.commit()
# View should still show stale data (100, not 300)
# because TableB changes don't trigger refresh
with pg_engine.connect() as conn:
result = conn.execute(select(table_a_sum.as_table())).fetchone()
assert result.total == 100 # Still stale
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(table_a_sum, if_exists=True))
metadata.drop_all(pg_engine)

182
tests/test_ddl.py Normal file
View File

@@ -0,0 +1,182 @@
"""Tests for DDL operations."""
from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy_pgview import (
CreateMaterializedView,
CreateView,
DropMaterializedView,
DropView,
MaterializedView,
RefreshMaterializedView,
View,
)
class TestCreateView:
"""Tests for CreateView DDL."""
def test_create_view_sql(self, pg_engine: Engine, sample_tables: tuple) -> None:
"""Test CREATE VIEW SQL generation."""
users, _ = sample_tables
view = View(
"active_users",
select(users.c.id, users.c.name),
)
stmt = CreateView(view)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert "CREATE VIEW active_users AS" in sql
assert "users.id" in sql
assert "users.name" in sql
def test_create_or_replace_view_sql(
self, pg_engine: Engine, sample_tables: tuple
) -> None:
"""Test CREATE OR REPLACE VIEW SQL generation."""
users, _ = sample_tables
view = View(
"active_users",
select(users.c.id),
)
stmt = CreateView(view, or_replace=True)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert "CREATE OR REPLACE VIEW" in sql
def test_create_view_with_schema(
self, pg_engine: Engine, sample_tables: tuple
) -> None:
"""Test CREATE VIEW with schema."""
users, _ = sample_tables
view = View(
"active_users",
select(users.c.id),
schema="analytics",
)
stmt = CreateView(view)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert "analytics.active_users" in sql
class TestDropView:
"""Tests for DropView DDL."""
def test_drop_view_sql(self, pg_engine: Engine) -> None:
"""Test DROP VIEW SQL generation."""
stmt = DropView("test_view")
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert sql == "DROP VIEW test_view"
def test_drop_view_if_exists(self, pg_engine: Engine) -> None:
"""Test DROP VIEW IF EXISTS."""
stmt = DropView("test_view", if_exists=True)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert sql == "DROP VIEW IF EXISTS test_view"
def test_drop_view_cascade(self, pg_engine: Engine) -> None:
"""Test DROP VIEW CASCADE."""
stmt = DropView("test_view", cascade=True)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert sql == "DROP VIEW test_view CASCADE"
def test_drop_view_with_schema(self, pg_engine: Engine) -> None:
"""Test DROP VIEW with schema."""
stmt = DropView("test_view", schema="analytics", if_exists=True)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert sql == "DROP VIEW IF EXISTS analytics.test_view"
class TestCreateMaterializedView:
"""Tests for CreateMaterializedView DDL."""
def test_create_materialized_view_sql(
self, pg_engine: Engine, sample_tables: tuple
) -> None:
"""Test CREATE MATERIALIZED VIEW SQL generation."""
users, _ = sample_tables
mview = MaterializedView(
"user_cache",
select(users.c.id, users.c.name),
with_data=True,
)
stmt = CreateMaterializedView(mview)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert "CREATE MATERIALIZED VIEW user_cache AS" in sql
assert "WITH DATA" in sql
def test_create_materialized_view_without_data(
self, pg_engine: Engine, sample_tables: tuple
) -> None:
"""Test CREATE MATERIALIZED VIEW WITH NO DATA."""
users, _ = sample_tables
mview = MaterializedView(
"user_cache",
select(users.c.id),
with_data=False,
)
stmt = CreateMaterializedView(mview)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert "WITH NO DATA" in sql
class TestDropMaterializedView:
"""Tests for DropMaterializedView DDL."""
def test_drop_materialized_view_sql(self, pg_engine: Engine) -> None:
"""Test DROP MATERIALIZED VIEW SQL generation."""
stmt = DropMaterializedView("test_mview", if_exists=True, cascade=True)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert sql == "DROP MATERIALIZED VIEW IF EXISTS test_mview CASCADE"
class TestRefreshMaterializedView:
"""Tests for RefreshMaterializedView DDL."""
def test_refresh_materialized_view_sql(self, pg_engine: Engine) -> None:
"""Test REFRESH MATERIALIZED VIEW SQL generation."""
stmt = RefreshMaterializedView("test_mview")
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert sql == "REFRESH MATERIALIZED VIEW test_mview WITH DATA"
def test_refresh_concurrently(self, pg_engine: Engine) -> None:
"""Test REFRESH MATERIALIZED VIEW CONCURRENTLY."""
stmt = RefreshMaterializedView("test_mview", concurrently=True)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert "CONCURRENTLY" in sql
def test_refresh_without_data(self, pg_engine: Engine) -> None:
"""Test REFRESH MATERIALIZED VIEW WITH NO DATA."""
stmt = RefreshMaterializedView("test_mview", with_data=False)
compiled = stmt.compile(dialect=pg_engine.dialect)
sql = str(compiled)
assert "WITH NO DATA" in sql

395
tests/test_declarative.py Normal file
View File

@@ -0,0 +1,395 @@
"""Tests for declarative view classes."""
from __future__ import annotations
import os
from decimal import Decimal
from typing import TYPE_CHECKING
import pytest
from sqlalchemy import (
Integer,
Numeric,
String,
func,
select,
)
from sqlalchemy.engine import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from sqlalchemy_pgview import (
MaterializedViewBase,
ViewBase,
)
from sqlalchemy_pgview.ddl import (
DropMaterializedView,
DropView,
)
if TYPE_CHECKING:
from sqlalchemy.engine import Engine
@pytest.fixture
def pg_engine() -> Engine:
"""Create a PostgreSQL engine for testing."""
url = os.environ.get("POSTGRES_URL", "postgresql://test:test@localhost:5432/testdb")
return create_engine(url)
class TestViewBase:
"""Tests for ViewBase declarative views."""
def test_basic_view_class(self, pg_engine: Engine) -> None:
"""Test basic ViewBase subclass creation and querying."""
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "test_users_vb"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
is_active: Mapped[int] = mapped_column(Integer)
class ActiveUsers(ViewBase, Base):
__tablename__ = "test_active_users_vb"
__select__ = select(User.id, User.name).where(User.is_active == 1)
try:
Base.metadata.create_all(pg_engine)
with Session(pg_engine) as session:
session.add(User(id=1, name="Alice", is_active=1))
session.add(User(id=2, name="Bob", is_active=0))
session.add(User(id=3, name="Charlie", is_active=1))
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
assert len(result) == 2
names = {row.name for row in result}
assert names == {"Alice", "Charlie"}
finally:
with pg_engine.begin() as conn:
conn.execute(DropView(ActiveUsers._view, if_exists=True))
Base.metadata.drop_all(pg_engine)
def test_view_class_columns_shorthand(self, pg_engine: Engine) -> None:
"""Test that .c shorthand works for columns."""
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "test_products_vb"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
class ProductView(ViewBase, Base):
__tablename__ = "test_product_view"
__select__ = select(Product.id, Product.name, Product.price)
try:
Base.metadata.create_all(pg_engine)
with Session(pg_engine) as session:
session.add(Product(id=1, name="Widget", price=Decimal("9.99")))
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(
select(ProductView.c.name, ProductView.c.price)
).fetchone()
assert result.name == "Widget"
assert result.price == Decimal("9.99")
finally:
with pg_engine.begin() as conn:
conn.execute(DropView(ProductView._view, if_exists=True))
Base.metadata.drop_all(pg_engine)
def test_view_class_missing_tablename_raises(self) -> None:
"""Test that missing __tablename__ raises TypeError."""
class Base(DeclarativeBase):
pass
with pytest.raises(TypeError, match="must define __tablename__"):
class BadView(ViewBase, Base):
__select__ = select()
def test_view_class_missing_select_raises(self) -> None:
"""Test that missing __select__ raises TypeError."""
class Base(DeclarativeBase):
pass
with pytest.raises(TypeError, match="must define __select__"):
class BadView(ViewBase, Base):
__tablename__ = "bad_view"
class TestMaterializedViewBase:
"""Tests for MaterializedViewBase declarative views."""
def test_basic_materialized_view_class(self, pg_engine: Engine) -> None:
"""Test basic MaterializedViewBase subclass."""
class Base(DeclarativeBase):
pass
class Order(Base):
__tablename__ = "test_orders_mvb"
id: Mapped[int] = mapped_column(primary_key=True)
total: Mapped[Decimal] = mapped_column(Numeric(10, 2))
class OrderStats(MaterializedViewBase, Base):
__tablename__ = "test_order_stats_mvb"
__select__ = select(
func.count(Order.id).label("order_count"),
func.sum(Order.total).label("total_revenue"),
)
try:
Base.metadata.create_all(pg_engine)
# Initial state - empty
with pg_engine.connect() as conn:
result = conn.execute(select(OrderStats.as_table())).fetchone()
assert result.order_count == 0
with Session(pg_engine) as session:
session.add(Order(id=1, total=Decimal("100.00")))
session.add(Order(id=2, total=Decimal("200.00")))
session.commit()
# MV is stale until refresh
with pg_engine.connect() as conn:
result = conn.execute(select(OrderStats.as_table())).fetchone()
assert result.order_count == 0
# Refresh using class method
with pg_engine.begin() as conn:
OrderStats.refresh(conn)
with pg_engine.connect() as conn:
result = conn.execute(select(OrderStats.as_table())).fetchone()
assert result.order_count == 2
assert result.total_revenue == Decimal("300.00")
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(OrderStats._view, if_exists=True))
Base.metadata.drop_all(pg_engine)
def test_materialized_view_with_data_false(self, pg_engine: Engine) -> None:
"""Test MaterializedViewBase with __with_data__ = False."""
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = "test_items_mvb_nodata"
id: Mapped[int] = mapped_column(primary_key=True)
class ItemCount(MaterializedViewBase, Base):
__tablename__ = "test_item_count_mvb"
__select__ = select(func.count(Item.id).label("count"))
__with_data__ = False
try:
Base.metadata.create_all(pg_engine)
with Session(pg_engine) as session:
session.add(Item(id=1))
session.commit()
with pg_engine.begin() as conn:
ItemCount.refresh(conn)
with pg_engine.connect() as conn:
result = conn.execute(select(ItemCount.as_table())).fetchone()
assert result.count == 1
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(ItemCount._view, if_exists=True))
Base.metadata.drop_all(pg_engine)
def test_materialized_view_auto_refresh(self, pg_engine: Engine) -> None:
"""Test MaterializedViewBase auto-refresh functionality."""
class Base(DeclarativeBase):
pass
class Counter(Base):
__tablename__ = "test_counter_mvb_ar"
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer)
class CounterSum(MaterializedViewBase, Base):
__tablename__ = "test_counter_sum_mvb"
__select__ = select(func.sum(Counter.value).label("total"))
class TestSession(Session):
pass
CounterSum.auto_refresh_on(TestSession, Counter.__table__)
try:
Base.metadata.create_all(pg_engine)
with TestSession(pg_engine) as session:
session.add(Counter(id=1, value=10))
session.add(Counter(id=2, value=20))
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(CounterSum.as_table())).fetchone()
assert result.total == 30
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(CounterSum._view, if_exists=True))
Base.metadata.drop_all(pg_engine)
class TestMultipleInheritance:
"""Tests for multiple inheritance with DeclarativeBase."""
def test_view_inherits_metadata(self, pg_engine: Engine) -> None:
"""Test that ViewBase inherits metadata from DeclarativeBase."""
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "test_product_mi"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
class ExpensiveProducts(ViewBase, Base):
__tablename__ = "test_expensive_products_mi"
__select__ = select(Product.id, Product.name, Product.price).where(
Product.price > 50
)
try:
Base.metadata.create_all(pg_engine)
with Session(pg_engine) as session:
session.add(Product(id=1, name="Cheap", price=Decimal("10.00")))
session.add(Product(id=2, name="Expensive", price=Decimal("100.00")))
session.add(Product(id=3, name="Premium", price=Decimal("200.00")))
session.commit()
with pg_engine.connect() as conn:
result = conn.execute(select(ExpensiveProducts.as_table())).fetchall()
assert len(result) == 2
names = {row.name for row in result}
assert names == {"Expensive", "Premium"}
finally:
with pg_engine.begin() as conn:
conn.execute(DropView(ExpensiveProducts._view, if_exists=True))
Base.metadata.drop_all(pg_engine)
def test_materialized_view_inherits_metadata(self, pg_engine: Engine) -> None:
"""Test that MaterializedViewBase inherits metadata from DeclarativeBase."""
class Base(DeclarativeBase):
pass
class Sale(Base):
__tablename__ = "test_sale_mi"
id: Mapped[int] = mapped_column(primary_key=True)
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2))
class SaleStats(MaterializedViewBase, Base):
__tablename__ = "test_sale_stats_mi"
__select__ = select(
func.count(Sale.id).label("sale_count"),
func.sum(Sale.amount).label("total_amount"),
)
try:
Base.metadata.create_all(pg_engine)
with Session(pg_engine) as session:
session.add(Sale(id=1, amount=Decimal("100.00")))
session.add(Sale(id=2, amount=Decimal("200.00")))
session.commit()
with pg_engine.begin() as conn:
SaleStats.refresh(conn)
with pg_engine.connect() as conn:
result = conn.execute(select(SaleStats.as_table())).fetchone()
assert result.sale_count == 2
assert result.total_amount == Decimal("300.00")
finally:
with pg_engine.begin() as conn:
conn.execute(DropMaterializedView(SaleStats._view, if_exists=True))
Base.metadata.drop_all(pg_engine)
def test_multiple_views_same_base(self, pg_engine: Engine) -> None:
"""Test multiple views sharing the same DeclarativeBase."""
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "test_user_multi"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
role: Mapped[str] = mapped_column(String(50))
class AdminUsers(ViewBase, Base):
__tablename__ = "test_admin_users"
__select__ = select(User.id, User.name).where(User.role == "admin")
class RegularUsers(ViewBase, Base):
__tablename__ = "test_regular_users"
__select__ = select(User.id, User.name).where(User.role == "user")
class UserStats(MaterializedViewBase, Base):
__tablename__ = "test_user_stats_multi"
__select__ = select(func.count(User.id).label("count"))
try:
Base.metadata.create_all(pg_engine)
with Session(pg_engine) as session:
session.add(User(id=1, name="Alice", role="admin"))
session.add(User(id=2, name="Bob", role="user"))
session.add(User(id=3, name="Charlie", role="user"))
session.commit()
with pg_engine.connect() as conn:
admins = conn.execute(select(AdminUsers.as_table())).fetchall()
assert len(admins) == 1
assert admins[0].name == "Alice"
regulars = conn.execute(select(RegularUsers.as_table())).fetchall()
assert len(regulars) == 2
with pg_engine.begin() as conn:
UserStats.refresh(conn)
with pg_engine.connect() as conn:
stats = conn.execute(select(UserStats.as_table())).fetchone()
assert stats.count == 3
finally:
with pg_engine.begin() as conn:
conn.execute(DropView(AdminUsers._view, if_exists=True))
conn.execute(DropView(RegularUsers._view, if_exists=True))
conn.execute(DropMaterializedView(UserStats._view, if_exists=True))
Base.metadata.drop_all(pg_engine)

327
tests/test_dependencies.py Normal file
View File

@@ -0,0 +1,327 @@
"""Tests for view dependency tracking."""
from sqlalchemy import Table, func, select
from sqlalchemy.engine import Engine
from sqlalchemy_pgview import (
CreateMaterializedView,
CreateView,
DropMaterializedView,
DropView,
MaterializedView,
View,
get_all_views,
get_view_definition,
)
from sqlalchemy_pgview.dependencies import (
ViewDependency,
ViewInfo,
get_dependency_order,
get_reverse_dependencies,
get_view_dependencies,
)
class TestViewInfo:
"""Tests for ViewInfo dataclass."""
def test_view_info_fullname_without_schema(self) -> None:
"""Test ViewInfo.fullname without schema."""
info = ViewInfo(
name="my_view",
schema=None,
definition="SELECT 1",
is_materialized=False,
)
assert info.fullname == "my_view"
def test_view_info_fullname_with_schema(self) -> None:
"""Test ViewInfo.fullname with schema."""
info = ViewInfo(
name="my_view",
schema="analytics",
definition="SELECT 1",
is_materialized=False,
)
assert info.fullname == "analytics.my_view"
class TestViewDependency:
"""Tests for ViewDependency dataclass."""
def test_view_dependency_fullnames(self) -> None:
"""Test ViewDependency fullname properties."""
dep = ViewDependency(
dependent_view="child_view",
dependent_schema="public",
referenced_view="parent_view",
referenced_schema="analytics",
)
assert dep.dependent_fullname == "public.child_view"
assert dep.referenced_fullname == "analytics.parent_view"
class TestGetAllViews:
"""Tests for get_all_views function."""
def test_get_all_views(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting all views from database."""
authors = pg_one_to_many_tables["authors"]
books = pg_one_to_many_tables["books"]
view1 = View(
"test_view_deps_1",
select(authors.c.id, authors.c.name),
)
mview1 = MaterializedView(
"test_mview_deps_1",
select(func.count(books.c.id).label("count")),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateView(view1, or_replace=True))
conn.execute(CreateMaterializedView(mview1, if_not_exists=True))
views = get_all_views(conn)
view_names = [v.name for v in views]
assert "test_view_deps_1" in view_names
assert "test_mview_deps_1" in view_names
# Check materialized flag
mview = next(v for v in views if v.name == "test_mview_deps_1")
assert mview.is_materialized is True
regular = next(v for v in views if v.name == "test_view_deps_1")
assert regular.is_materialized is False
conn.execute(DropView(view1, if_exists=True))
conn.execute(DropMaterializedView(mview1, if_exists=True))
def test_get_all_views_with_schema_filter(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting views filtered by schema."""
authors = pg_one_to_many_tables["authors"]
view = View(
"test_view_schema_filter",
select(authors.c.id),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(view, or_replace=True))
# Filter by public schema
views = get_all_views(conn, schema="public")
view_names = [v.name for v in views]
assert "test_view_schema_filter" in view_names
# Filter by non-existent schema
views = get_all_views(conn, schema="nonexistent")
assert len(views) == 0
conn.execute(DropView(view, if_exists=True))
def test_get_all_views_exclude_materialized(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting views excluding materialized views."""
authors = pg_one_to_many_tables["authors"]
view = View("test_view_excl", select(authors.c.id))
mview = MaterializedView(
"test_mview_excl",
select(func.count(authors.c.id).label("cnt")),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateView(view, or_replace=True))
conn.execute(CreateMaterializedView(mview, if_not_exists=True))
# Include materialized
views = get_all_views(conn, include_materialized=True)
names = [v.name for v in views]
assert "test_view_excl" in names
assert "test_mview_excl" in names
# Exclude materialized
views = get_all_views(conn, include_materialized=False)
names = [v.name for v in views]
assert "test_view_excl" in names
assert "test_mview_excl" not in names
conn.execute(DropView(view, if_exists=True))
conn.execute(DropMaterializedView(mview, if_exists=True))
class TestGetViewDefinition:
"""Tests for get_view_definition function."""
def test_get_view_definition(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting view definition."""
authors = pg_one_to_many_tables["authors"]
view = View(
"test_view_def",
select(authors.c.id, authors.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(view, or_replace=True))
definition = get_view_definition(conn, "test_view_def")
assert definition is not None
assert "authors" in definition.lower()
conn.execute(DropView(view, if_exists=True))
def test_get_view_definition_not_found(self, pg_engine: Engine) -> None:
"""Test getting definition for non-existent view."""
with pg_engine.connect() as conn:
definition = get_view_definition(conn, "nonexistent_view_xyz")
assert definition is None
class TestGetViewDependencies:
"""Tests for get_view_dependencies function."""
def test_get_view_dependencies(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting view dependencies."""
authors = pg_one_to_many_tables["authors"]
# Create base view
base_view = View(
"test_base_view_deps",
select(authors.c.id, authors.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(base_view, or_replace=True))
# Create dependent view that references base view
dependent_view = View(
"test_dependent_view_deps",
select(base_view.as_table().c.id),
)
conn.execute(CreateView(dependent_view, or_replace=True))
# Get dependencies
deps = get_view_dependencies(conn)
# Find our dependency
our_deps = [
d for d in deps if d.dependent_view == "test_dependent_view_deps"
]
assert len(our_deps) > 0
# Should reference the base view
ref_names = [d.referenced_view for d in our_deps]
assert "test_base_view_deps" in ref_names
conn.execute(DropView(dependent_view, if_exists=True, cascade=True))
conn.execute(DropView(base_view, if_exists=True, cascade=True))
class TestGetDependencyOrder:
"""Tests for get_dependency_order function."""
def test_get_dependency_order(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting views in dependency order."""
authors = pg_one_to_many_tables["authors"]
# Create views with dependencies
view_a = View(
"test_order_a",
select(authors.c.id, authors.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(view_a, or_replace=True))
view_b = View(
"test_order_b",
select(view_a.as_table().c.id),
)
conn.execute(CreateView(view_b, or_replace=True))
# Get dependency order
ordered = get_dependency_order(conn, schema="public")
names = [v.name for v in ordered]
# view_a should come before view_b (dependencies first)
if "test_order_a" in names and "test_order_b" in names:
idx_a = names.index("test_order_a")
idx_b = names.index("test_order_b")
assert idx_a < idx_b
conn.execute(DropView(view_b, if_exists=True, cascade=True))
conn.execute(DropView(view_a, if_exists=True, cascade=True))
class TestGetReverseDependencies:
"""Tests for get_reverse_dependencies function."""
def test_get_reverse_dependencies(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting reverse dependencies (views that depend on a view)."""
authors = pg_one_to_many_tables["authors"]
# Create base view
base_view = View(
"test_reverse_base",
select(authors.c.id, authors.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(base_view, or_replace=True))
# Create dependent view
dep_view = View(
"test_reverse_dep",
select(base_view.as_table().c.id),
)
conn.execute(CreateView(dep_view, or_replace=True))
# Get reverse dependencies of base view
dependents = get_reverse_dependencies(conn, "test_reverse_base")
dep_names = [v.name for v in dependents]
assert "test_reverse_dep" in dep_names
conn.execute(DropView(dep_view, if_exists=True, cascade=True))
conn.execute(DropView(base_view, if_exists=True, cascade=True))
def test_get_reverse_dependencies_none(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test getting reverse dependencies when there are none."""
authors = pg_one_to_many_tables["authors"]
# Create standalone view with no dependents
standalone = View(
"test_standalone_view",
select(authors.c.id),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(standalone, or_replace=True))
dependents = get_reverse_dependencies(conn, "test_standalone_view")
assert len(dependents) == 0
conn.execute(DropView(standalone, if_exists=True))

View File

@@ -0,0 +1,290 @@
"""Tests for metadata integration and auto-registration."""
from decimal import Decimal
from sqlalchemy import (
Column,
ForeignKey,
Integer,
MetaData,
Numeric,
String,
Table,
func,
insert,
select,
)
from sqlalchemy.engine import Engine
from sqlalchemy_pgview import (
MaterializedView,
View,
get_materialized_views,
get_views,
)
class TestAutoRegistration:
"""Tests for auto-registration of views with metadata."""
def test_view_auto_registers_with_metadata(self) -> None:
"""Test that View is auto-registered when metadata is provided."""
metadata = MetaData()
# Dummy table for selectable
users = Table("users", metadata, Column("id", Integer, primary_key=True))
# View should be auto-registered
user_view = View(
"user_view",
select(users.c.id),
metadata=metadata,
)
registered = get_views(metadata)
assert "user_view" in registered
assert registered["user_view"] is user_view
def test_materialized_view_auto_registers_with_metadata(self) -> None:
"""Test that MaterializedView is auto-registered when metadata is provided."""
metadata = MetaData()
users = Table("users", metadata, Column("id", Integer, primary_key=True))
mv = MaterializedView(
"user_mv",
select(users.c.id),
metadata=metadata,
with_data=True,
)
registered = get_materialized_views(metadata)
assert "user_mv" in registered
assert registered["user_mv"] is mv
def test_view_with_schema_registers_correctly(self) -> None:
"""Test that views with schema are registered with correct key."""
metadata = MetaData()
users = Table("users", metadata, Column("id", Integer, primary_key=True))
view = View(
"stats_view",
select(users.c.id),
schema="analytics",
metadata=metadata,
)
registered = get_views(metadata)
assert "analytics.stats_view" in registered
assert registered["analytics.stats_view"] is view
def test_view_without_metadata_not_registered(self) -> None:
"""Test that views without metadata are not auto-registered."""
metadata = MetaData()
users = Table("users", metadata, Column("id", Integer, primary_key=True))
# No metadata provided - should not be registered
View("orphan_view", select(users.c.id))
registered = get_views(metadata)
assert "orphan_view" not in registered
def test_multiple_views_registered(self) -> None:
"""Test that multiple views can be registered."""
metadata = MetaData()
users = Table("users", metadata, Column("id", Integer, primary_key=True))
View("view1", select(users.c.id), metadata=metadata)
View("view2", select(users.c.id), metadata=metadata)
MaterializedView("mv1", select(users.c.id), metadata=metadata)
views = get_views(metadata)
mviews = get_materialized_views(metadata)
assert len(views) == 2
assert "view1" in views
assert "view2" in views
assert len(mviews) == 1
assert "mv1" in mviews
class TestMetadataCreateAll:
"""Tests for metadata.create_all() with views."""
def test_create_all_creates_views(self, pg_engine: Engine) -> None:
"""Test that metadata.create_all() creates registered views."""
metadata = MetaData()
users = Table(
"test_users_ca",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100)),
)
orders = Table(
"test_orders_ca",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", Integer, ForeignKey("test_users_ca.id")),
Column("total", Numeric(10, 2)),
)
# Define view - auto-registered
user_stats = View(
"test_user_stats_ca",
select(
users.c.id,
users.c.name,
func.count(orders.c.id).label("order_count"),
)
.select_from(users.outerjoin(orders, users.c.id == orders.c.user_id))
.group_by(users.c.id, users.c.name),
metadata=metadata,
)
try:
# Create all - should create tables AND views
metadata.create_all(pg_engine)
# Insert test data
with pg_engine.begin() as conn:
conn.execute(insert(users).values(id=1, name="Alice"))
conn.execute(insert(users).values(id=2, name="Bob"))
conn.execute(insert(orders).values(id=1, user_id=1, total=100))
conn.execute(insert(orders).values(id=2, user_id=1, total=200))
# Query the view
with pg_engine.connect() as conn:
result = conn.execute(
select(user_stats.as_table()).order_by(user_stats.as_table().c.name)
).fetchall()
assert len(result) == 2
assert result[0].name == "Alice"
assert result[0].order_count == 2
assert result[1].name == "Bob"
assert result[1].order_count == 0
finally:
metadata.drop_all(pg_engine)
def test_create_all_creates_materialized_views(self, pg_engine: Engine) -> None:
"""Test that metadata.create_all() creates materialized views."""
metadata = MetaData()
orders = Table(
"test_orders_mv",
metadata,
Column("id", Integer, primary_key=True),
Column("total", Numeric(10, 2)),
)
# Define materialized view - auto-registered
order_summary = MaterializedView(
"test_order_summary_mv",
select(
func.count(orders.c.id).label("order_count"),
func.sum(orders.c.total).label("total_revenue"),
),
metadata=metadata,
with_data=True,
)
try:
metadata.create_all(pg_engine)
# Insert test data
with pg_engine.begin() as conn:
conn.execute(insert(orders).values(id=1, total=100))
conn.execute(insert(orders).values(id=2, total=200))
# Query the materialized view (shows data at creation time = 0)
with pg_engine.connect() as conn:
result = conn.execute(select(order_summary.as_table())).fetchone()
# MV was created before data, so shows 0
assert result.order_count == 0
# Refresh to see actual data
order_summary.refresh(conn)
result = conn.execute(select(order_summary.as_table())).fetchone()
assert result.order_count == 2
assert result.total_revenue == Decimal("300")
finally:
metadata.drop_all(pg_engine)
def test_drop_all_drops_views(self, pg_engine: Engine) -> None:
"""Test that metadata.drop_all() drops registered views."""
from sqlalchemy import text
metadata = MetaData()
users = Table(
"test_users_drop",
metadata,
Column("id", Integer, primary_key=True),
)
View(
"test_view_drop",
select(users.c.id),
metadata=metadata,
)
MaterializedView(
"test_mv_drop",
select(users.c.id),
metadata=metadata,
)
# Create all
metadata.create_all(pg_engine)
# Verify views exist
with pg_engine.connect() as conn:
result = conn.execute(
text(
"SELECT COUNT(*) FROM pg_class WHERE relname IN ('test_view_drop', 'test_mv_drop')"
)
).scalar()
assert result == 2
# Drop all
metadata.drop_all(pg_engine)
# Verify views are gone
with pg_engine.connect() as conn:
result = conn.execute(
text(
"SELECT COUNT(*) FROM pg_class WHERE relname IN ('test_view_drop', 'test_mv_drop')"
)
).scalar()
assert result == 0
def test_views_created_after_tables(self, pg_engine: Engine) -> None:
"""Test that views are created after tables (dependencies work)."""
metadata = MetaData()
# Table
users = Table(
"test_users_order",
metadata,
Column("id", Integer, primary_key=True),
Column("name", String(100)),
)
# View that depends on table
View(
"test_users_view_order",
select(users.c.id, users.c.name),
metadata=metadata,
)
# This should not raise - tables created before views
metadata.create_all(pg_engine)
metadata.drop_all(pg_engine)

448
tests/test_relationships.py Normal file
View File

@@ -0,0 +1,448 @@
"""Tests for views with one-to-many and many-to-many relationships."""
from decimal import Decimal
from sqlalchemy import Table, func, select
from sqlalchemy.engine import Engine
from sqlalchemy_pgview import (
CreateMaterializedView,
CreateView,
DropMaterializedView,
DropView,
MaterializedView,
View,
)
class TestOneToManyViews:
"""Tests for views based on one-to-many relationships."""
def test_author_book_count_view(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test view that counts books per author (1:N aggregation)."""
authors = pg_one_to_many_tables["authors"]
books = pg_one_to_many_tables["books"]
# Create view: author with book count
author_stats = View(
"author_book_stats",
select(
authors.c.id,
authors.c.name,
authors.c.country,
func.count(books.c.id).label("book_count"),
func.coalesce(func.sum(books.c.price), 0).label("total_price"),
)
.select_from(authors.outerjoin(books, authors.c.id == books.c.author_id))
.group_by(authors.c.id, authors.c.name, authors.c.country),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(author_stats, or_replace=True))
# Query the view
result = conn.execute(
select(author_stats.as_table()).order_by(author_stats.as_table().c.name)
).fetchall()
assert len(result) == 3
# Gabriel Garcia Marquez - 1 book
assert result[0].name == "Gabriel Garcia Marquez"
assert result[0].book_count == 1
# George Orwell - 2 books
assert result[1].name == "George Orwell"
assert result[1].book_count == 2
# Haruki Murakami - 2 books
assert result[2].name == "Haruki Murakami"
assert result[2].book_count == 2
conn.execute(DropView(author_stats, if_exists=True))
def test_book_review_stats_view(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test view with nested 1:N (books -> reviews) aggregation."""
books = pg_one_to_many_tables["books"]
reviews = pg_one_to_many_tables["reviews"]
# Create view: book with review stats
book_review_stats = View(
"book_review_stats",
select(
books.c.id,
books.c.title,
books.c.price,
func.count(reviews.c.id).label("review_count"),
func.coalesce(func.avg(reviews.c.rating), 0).label("avg_rating"),
)
.select_from(books.outerjoin(reviews, books.c.id == reviews.c.book_id))
.group_by(books.c.id, books.c.title, books.c.price),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(book_review_stats, or_replace=True))
result = conn.execute(
select(book_review_stats.as_table()).order_by(
book_review_stats.as_table().c.review_count.desc()
)
).fetchall()
assert len(result) == 5
# 1984 has 3 reviews
assert result[0].title == "1984"
assert result[0].review_count == 3
assert float(result[0].avg_rating) > 4.0
conn.execute(DropView(book_review_stats, if_exists=True))
def test_chained_one_to_many_view(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test view spanning multiple 1:N relationships (author -> books -> reviews)."""
authors = pg_one_to_many_tables["authors"]
books = pg_one_to_many_tables["books"]
reviews = pg_one_to_many_tables["reviews"]
# Create view: author with aggregated review stats across all their books
author_review_summary = View(
"author_review_summary",
select(
authors.c.id,
authors.c.name,
func.count(reviews.c.id).label("total_reviews"),
func.coalesce(func.avg(reviews.c.rating), 0).label("avg_rating"),
)
.select_from(
authors.outerjoin(books, authors.c.id == books.c.author_id).outerjoin(
reviews, books.c.id == reviews.c.book_id
)
)
.group_by(authors.c.id, authors.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(author_review_summary, or_replace=True))
result = conn.execute(
select(author_review_summary.as_table()).order_by(
author_review_summary.as_table().c.total_reviews.desc()
)
).fetchall()
assert len(result) == 3
# George Orwell has most reviews (1984: 3 + Animal Farm: 1 = 4)
assert result[0].name == "George Orwell"
assert result[0].total_reviews == 4
conn.execute(DropView(author_review_summary, if_exists=True))
def test_one_to_many_materialized_view(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test materialized view with 1:N relationship."""
authors = pg_one_to_many_tables["authors"]
books = pg_one_to_many_tables["books"]
# Create materialized view for author statistics
author_stats_mv = MaterializedView(
"author_stats_mv",
select(
authors.c.id,
authors.c.name,
func.count(books.c.id).label("book_count"),
)
.select_from(authors.outerjoin(books, authors.c.id == books.c.author_id))
.group_by(authors.c.id, authors.c.name),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateMaterializedView(author_stats_mv))
result = conn.execute(
select(author_stats_mv.as_table()).order_by(
author_stats_mv.as_table().c.book_count.desc()
)
).fetchall()
assert len(result) == 3
assert result[0].book_count == 2 # Orwell or Murakami
conn.execute(DropMaterializedView(author_stats_mv, if_exists=True))
class TestManyToManyViews:
"""Tests for views based on many-to-many relationships."""
def test_student_course_count_view(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test view counting courses per student (M:N aggregation)."""
students = pg_many_to_many_tables["students"]
student_courses = pg_many_to_many_tables["student_courses"]
# Create view: student with course count and GPA
student_stats = View(
"student_stats",
select(
students.c.id,
students.c.name,
students.c.email,
func.count(student_courses.c.course_id).label("course_count"),
func.coalesce(func.avg(student_courses.c.grade), 0).label("gpa"),
)
.select_from(
students.outerjoin(
student_courses, students.c.id == student_courses.c.student_id
)
)
.group_by(students.c.id, students.c.name, students.c.email),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(student_stats, or_replace=True))
result = conn.execute(
select(student_stats.as_table()).order_by(
student_stats.as_table().c.course_count.desc()
)
).fetchall()
assert len(result) == 3
# Alice is enrolled in 3 courses
assert result[0].name == "Alice"
assert result[0].course_count == 3
# Bob is enrolled in 2 courses
assert result[1].name == "Bob"
assert result[1].course_count == 2
# Charlie is enrolled in 1 course
assert result[2].name == "Charlie"
assert result[2].course_count == 1
conn.execute(DropView(student_stats, if_exists=True))
def test_course_enrollment_view(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test view counting students per course (reverse M:N)."""
courses = pg_many_to_many_tables["courses"]
student_courses = pg_many_to_many_tables["student_courses"]
# Create view: course with enrollment count
course_enrollment = View(
"course_enrollment",
select(
courses.c.id,
courses.c.name,
courses.c.credits,
func.count(student_courses.c.student_id).label("student_count"),
func.coalesce(func.avg(student_courses.c.grade), 0).label("avg_grade"),
)
.select_from(
courses.outerjoin(
student_courses, courses.c.id == student_courses.c.course_id
)
)
.group_by(courses.c.id, courses.c.name, courses.c.credits),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(course_enrollment, or_replace=True))
result = conn.execute(
select(course_enrollment.as_table()).order_by(
course_enrollment.as_table().c.name
)
).fetchall()
assert len(result) == 3
# Database Systems - 2 students (Alice, Bob)
assert result[0].name == "Database Systems"
assert result[0].student_count == 2
# Machine Learning - 2 students (Alice, Bob)
assert result[1].name == "Machine Learning"
assert result[1].student_count == 2
# Web Development - 2 students (Alice, Charlie)
assert result[2].name == "Web Development"
assert result[2].student_count == 2
conn.execute(DropView(course_enrollment, if_exists=True))
def test_course_tags_view(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test view joining through M:N to aggregate tags."""
courses = pg_many_to_many_tables["courses"]
course_tags = pg_many_to_many_tables["course_tags"]
tags = pg_many_to_many_tables["tags"]
# Create view: course with concatenated tag names
course_with_tags = View(
"course_with_tags",
select(
courses.c.id,
courses.c.name,
func.count(tags.c.id).label("tag_count"),
func.string_agg(tags.c.name, ", ").label("tag_names"),
)
.select_from(
courses.outerjoin(course_tags, courses.c.id == course_tags.c.course_id).outerjoin(
tags, course_tags.c.tag_id == tags.c.id
)
)
.group_by(courses.c.id, courses.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(course_with_tags, or_replace=True))
result = conn.execute(
select(course_with_tags.as_table()).order_by(
course_with_tags.as_table().c.tag_count.desc()
)
).fetchall()
assert len(result) == 3
# Machine Learning has 2 tags (data, ai)
assert result[0].name == "Machine Learning"
assert result[0].tag_count == 2
conn.execute(DropView(course_with_tags, if_exists=True))
def test_student_courses_detailed_view(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test flattened view of M:N relationship (no aggregation)."""
students = pg_many_to_many_tables["students"]
courses = pg_many_to_many_tables["courses"]
student_courses = pg_many_to_many_tables["student_courses"]
# Create view: detailed enrollment records
enrollment_details = View(
"enrollment_details",
select(
students.c.name.label("student_name"),
students.c.email,
courses.c.name.label("course_name"),
courses.c.credits,
student_courses.c.grade,
).select_from(
student_courses.join(students, student_courses.c.student_id == students.c.id).join(
courses, student_courses.c.course_id == courses.c.id
)
),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(enrollment_details, or_replace=True))
result = conn.execute(select(enrollment_details.as_table())).fetchall()
# Total enrollments: 6
assert len(result) == 6
# Check Alice's Web Development grade
alice_web = [
r for r in result if r.student_name == "Alice" and r.course_name == "Web Development"
]
assert len(alice_web) == 1
assert alice_web[0].grade == Decimal("4.00")
conn.execute(DropView(enrollment_details, if_exists=True))
def test_many_to_many_materialized_view(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test materialized view with M:N relationship."""
students = pg_many_to_many_tables["students"]
student_courses = pg_many_to_many_tables["student_courses"]
# Create materialized view for student GPA
student_gpa_mv = MaterializedView(
"student_gpa_mv",
select(
students.c.id,
students.c.name,
func.round(func.avg(student_courses.c.grade), 2).label("gpa"),
)
.select_from(
students.join(student_courses, students.c.id == student_courses.c.student_id)
)
.group_by(students.c.id, students.c.name),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateMaterializedView(student_gpa_mv))
result = conn.execute(
select(student_gpa_mv.as_table()).order_by(
student_gpa_mv.as_table().c.gpa.desc()
)
).fetchall()
assert len(result) == 3
# Alice has highest GPA (3.8 + 4.0 + 3.5) / 3 = 3.77
assert result[0].name == "Alice"
conn.execute(DropMaterializedView(student_gpa_mv, if_exists=True))
def test_double_many_to_many_view(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test view joining two M:N relationships."""
students = pg_many_to_many_tables["students"]
courses = pg_many_to_many_tables["courses"]
student_courses = pg_many_to_many_tables["student_courses"]
course_tags = pg_many_to_many_tables["course_tags"]
tags = pg_many_to_many_tables["tags"]
# Create view: students with their course tags
student_tags = View(
"student_tags",
select(
students.c.id.label("student_id"),
students.c.name.label("student_name"),
func.count(func.distinct(tags.c.id)).label("unique_tags"),
)
.select_from(
students.join(student_courses, students.c.id == student_courses.c.student_id)
.join(courses, student_courses.c.course_id == courses.c.id)
.join(course_tags, courses.c.id == course_tags.c.course_id)
.join(tags, course_tags.c.tag_id == tags.c.id)
)
.group_by(students.c.id, students.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(student_tags, or_replace=True))
result = conn.execute(
select(student_tags.as_table()).order_by(
student_tags.as_table().c.unique_tags.desc()
)
).fetchall()
assert len(result) == 3
# Alice takes all 3 courses, which have tags: data, programming, ai
assert result[0].student_name == "Alice"
assert result[0].unique_tags == 3
conn.execute(DropView(student_tags, if_exists=True))

113
tests/test_view.py Normal file
View File

@@ -0,0 +1,113 @@
"""Tests for View and MaterializedView classes."""
from sqlalchemy import func, select
from sqlalchemy_pgview import MaterializedView, View
class TestView:
"""Tests for the View class."""
def test_view_creation(self, sample_tables: tuple) -> None:
"""Test basic view creation."""
users, orders = sample_tables
view = View(
"user_order_count",
select(users.c.id, func.count(orders.c.id).label("order_count"))
.select_from(users.join(orders, users.c.id == orders.c.user_id))
.group_by(users.c.id),
)
assert view.name == "user_order_count"
assert view.schema is None
assert view.fullname == "user_order_count"
def test_view_with_schema(self, sample_tables: tuple) -> None:
"""Test view creation with schema."""
users, _ = sample_tables
view = View(
"active_users",
select(users.c.id, users.c.name),
schema="analytics",
)
assert view.name == "active_users"
assert view.schema == "analytics"
assert view.fullname == "analytics.active_users"
def test_view_columns(self, sample_tables: tuple) -> None:
"""Test view column derivation."""
users, _ = sample_tables
view = View(
"user_names",
select(users.c.id, users.c.name.label("user_name")),
)
columns = view.columns
assert len(columns) == 2
assert columns[0].name == "id"
assert columns[1].name == "user_name"
def test_view_as_table(self, sample_tables: tuple) -> None:
"""Test converting view to table for querying."""
users, _ = sample_tables
view = View(
"user_names",
select(users.c.id, users.c.name),
)
table = view.as_table()
assert table.name == "user_names"
assert len(table.columns) == 2
def test_view_repr(self, sample_tables: tuple) -> None:
"""Test view string representation."""
users, _ = sample_tables
view = View(
"test_view",
select(users.c.id),
schema="public",
)
assert repr(view) == "View('test_view', schema='public')"
class TestMaterializedView:
"""Tests for the MaterializedView class."""
def test_materialized_view_creation(self, sample_tables: tuple) -> None:
"""Test basic materialized view creation."""
users, orders = sample_tables
mview = MaterializedView(
"monthly_totals",
select(users.c.id, func.sum(orders.c.total).label("total"))
.select_from(users.join(orders, users.c.id == orders.c.user_id))
.group_by(users.c.id),
with_data=True,
)
assert mview.name == "monthly_totals"
assert mview.with_data is True
def test_materialized_view_without_data(self, sample_tables: tuple) -> None:
"""Test materialized view creation without data."""
users, _ = sample_tables
mview = MaterializedView(
"empty_view",
select(users.c.id),
with_data=False,
)
assert mview.with_data is False
def test_materialized_view_repr(self, sample_tables: tuple) -> None:
"""Test materialized view string representation."""
users, _ = sample_tables
mview = MaterializedView(
"test_mview",
select(users.c.id),
schema="public",
with_data=True,
)
assert repr(mview) == "MaterializedView('test_mview', schema='public', with_data=True)"

341
tests/test_view_updates.py Normal file
View File

@@ -0,0 +1,341 @@
"""Tests for view behavior after INSERT/UPDATE/DELETE on underlying tables."""
from decimal import Decimal
from sqlalchemy import Table, delete, func, insert, select, update
from sqlalchemy.engine import Engine
from sqlalchemy_pgview import (
CreateMaterializedView,
CreateView,
DropMaterializedView,
DropView,
MaterializedView,
RefreshMaterializedView,
View,
)
class TestRegularViewUpdates:
"""Tests that regular views reflect changes immediately."""
def test_view_reflects_insert(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test that view shows new rows after INSERT."""
authors = pg_one_to_many_tables["authors"]
books = pg_one_to_many_tables["books"]
author_book_count = View(
"author_book_count",
select(
authors.c.id,
authors.c.name,
func.count(books.c.id).label("book_count"),
)
.select_from(authors.outerjoin(books, authors.c.id == books.c.author_id))
.group_by(authors.c.id, authors.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(author_book_count, or_replace=True))
# Check initial count for Orwell (has 2 books)
result = conn.execute(
select(author_book_count.as_table()).where(
author_book_count.as_table().c.name == "George Orwell"
)
).fetchone()
assert result.book_count == 2
# INSERT a new book for Orwell
conn.execute(
insert(books).values(id=100, title="Homage to Catalonia", author_id=1, price=13.99)
)
# View should immediately show 3 books
result = conn.execute(
select(author_book_count.as_table()).where(
author_book_count.as_table().c.name == "George Orwell"
)
).fetchone()
assert result.book_count == 3
conn.execute(DropView(author_book_count, if_exists=True))
def test_view_reflects_update(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test that view shows updated values after UPDATE."""
students = pg_many_to_many_tables["students"]
student_courses = pg_many_to_many_tables["student_courses"]
student_gpa = View(
"student_gpa",
select(
students.c.id,
students.c.name,
func.round(func.avg(student_courses.c.grade), 2).label("gpa"),
)
.select_from(
students.join(student_courses, students.c.id == student_courses.c.student_id)
)
.group_by(students.c.id, students.c.name),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(student_gpa, or_replace=True))
# Check Alice's initial GPA
result = conn.execute(
select(student_gpa.as_table()).where(student_gpa.as_table().c.name == "Alice")
).fetchone()
initial_gpa = result.gpa
# UPDATE Alice's grade in Database Systems from 3.8 to 4.0
conn.execute(
update(student_courses)
.where(student_courses.c.student_id == 1)
.where(student_courses.c.course_id == 1)
.values(grade=Decimal("4.0"))
)
# View should show updated GPA
result = conn.execute(
select(student_gpa.as_table()).where(student_gpa.as_table().c.name == "Alice")
).fetchone()
assert result.gpa > initial_gpa
conn.execute(DropView(student_gpa, if_exists=True))
def test_view_reflects_delete(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test that view excludes deleted rows after DELETE."""
books = pg_one_to_many_tables["books"]
reviews = pg_one_to_many_tables["reviews"]
book_review_count = View(
"book_review_count",
select(
books.c.id,
books.c.title,
func.count(reviews.c.id).label("review_count"),
)
.select_from(books.outerjoin(reviews, books.c.id == reviews.c.book_id))
.group_by(books.c.id, books.c.title),
)
with pg_engine.begin() as conn:
conn.execute(CreateView(book_review_count, or_replace=True))
# Check initial review count for "1984" (has 3 reviews)
result = conn.execute(
select(book_review_count.as_table()).where(
book_review_count.as_table().c.title == "1984"
)
).fetchone()
assert result.review_count == 3
# DELETE one review for "1984"
conn.execute(delete(reviews).where(reviews.c.id == 1))
# View should immediately show 2 reviews
result = conn.execute(
select(book_review_count.as_table()).where(
book_review_count.as_table().c.title == "1984"
)
).fetchone()
assert result.review_count == 2
conn.execute(DropView(book_review_count, if_exists=True))
class TestMaterializedViewUpdates:
"""Tests that materialized views require explicit refresh."""
def test_materialized_view_stale_after_insert(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test that materialized view shows stale data after INSERT until refreshed."""
authors = pg_one_to_many_tables["authors"]
books = pg_one_to_many_tables["books"]
author_book_count_mv = MaterializedView(
"author_book_count_mv",
select(
authors.c.id,
authors.c.name,
func.count(books.c.id).label("book_count"),
)
.select_from(authors.outerjoin(books, authors.c.id == books.c.author_id))
.group_by(authors.c.id, authors.c.name),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateMaterializedView(author_book_count_mv))
# Check initial count for Orwell (has 2 books)
result = conn.execute(
select(author_book_count_mv.as_table()).where(
author_book_count_mv.as_table().c.name == "George Orwell"
)
).fetchone()
assert result.book_count == 2
# INSERT a new book for Orwell
conn.execute(
insert(books).values(id=101, title="Down and Out in Paris", author_id=1, price=11.99)
)
# Materialized view still shows OLD count (stale data)
result = conn.execute(
select(author_book_count_mv.as_table()).where(
author_book_count_mv.as_table().c.name == "George Orwell"
)
).fetchone()
assert result.book_count == 2 # Still 2, not 3!
# REFRESH the materialized view
conn.execute(RefreshMaterializedView(author_book_count_mv))
# Now it shows the updated count
result = conn.execute(
select(author_book_count_mv.as_table()).where(
author_book_count_mv.as_table().c.name == "George Orwell"
)
).fetchone()
assert result.book_count == 3 # Now shows 3
conn.execute(DropMaterializedView(author_book_count_mv, if_exists=True))
def test_materialized_view_stale_after_update(
self, pg_engine: Engine, pg_many_to_many_tables: dict[str, Table]
) -> None:
"""Test that materialized view shows stale data after UPDATE until refreshed."""
students = pg_many_to_many_tables["students"]
student_courses = pg_many_to_many_tables["student_courses"]
student_gpa_mv = MaterializedView(
"student_gpa_mv_test",
select(
students.c.id,
students.c.name,
func.round(func.avg(student_courses.c.grade), 2).label("gpa"),
)
.select_from(
students.join(student_courses, students.c.id == student_courses.c.student_id)
)
.group_by(students.c.id, students.c.name),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateMaterializedView(student_gpa_mv))
# Get Bob's initial GPA
result = conn.execute(
select(student_gpa_mv.as_table()).where(student_gpa_mv.as_table().c.name == "Bob")
).fetchone()
initial_gpa = result.gpa
# UPDATE Bob's grades to all 4.0
conn.execute(
update(student_courses)
.where(student_courses.c.student_id == 2)
.values(grade=Decimal("4.0"))
)
# Materialized view still shows old GPA
result = conn.execute(
select(student_gpa_mv.as_table()).where(student_gpa_mv.as_table().c.name == "Bob")
).fetchone()
assert result.gpa == initial_gpa # Unchanged
# REFRESH
conn.execute(RefreshMaterializedView(student_gpa_mv))
# Now shows 4.0
result = conn.execute(
select(student_gpa_mv.as_table()).where(student_gpa_mv.as_table().c.name == "Bob")
).fetchone()
assert result.gpa == Decimal("4.00")
conn.execute(DropMaterializedView(student_gpa_mv, if_exists=True))
def test_materialized_view_stale_after_delete(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test that materialized view shows stale data after DELETE until refreshed."""
authors = pg_one_to_many_tables["authors"]
books = pg_one_to_many_tables["books"]
reviews = pg_one_to_many_tables["reviews"]
author_count_mv = MaterializedView(
"author_count_mv",
select(func.count(authors.c.id).label("total_authors")),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateMaterializedView(author_count_mv))
# Initial count
result = conn.execute(select(author_count_mv.as_table())).fetchone()
assert result.total_authors == 3
# DELETE an author (need to delete reviews -> books -> author due to FK)
# Author 2 has book 3 which has review 5
conn.execute(delete(reviews).where(reviews.c.book_id == 3))
conn.execute(delete(books).where(books.c.author_id == 2))
conn.execute(delete(authors).where(authors.c.id == 2))
# Materialized view still shows 3
result = conn.execute(select(author_count_mv.as_table())).fetchone()
assert result.total_authors == 3 # Stale!
# REFRESH
conn.execute(RefreshMaterializedView(author_count_mv))
# Now shows 2
result = conn.execute(select(author_count_mv.as_table())).fetchone()
assert result.total_authors == 2
conn.execute(DropMaterializedView(author_count_mv, if_exists=True))
def test_materialized_view_refresh_via_method(
self, pg_engine: Engine, pg_one_to_many_tables: dict[str, Table]
) -> None:
"""Test refreshing materialized view using the .refresh() method."""
books = pg_one_to_many_tables["books"]
book_count_mv = MaterializedView(
"book_count_mv",
select(func.count(books.c.id).label("total_books")),
with_data=True,
)
with pg_engine.begin() as conn:
conn.execute(CreateMaterializedView(book_count_mv))
result = conn.execute(select(book_count_mv.as_table())).fetchone()
assert result.total_books == 5
# Add a new book
conn.execute(
insert(books).values(id=102, title="New Book", author_id=1, price=9.99)
)
# Still shows 5
result = conn.execute(select(book_count_mv.as_table())).fetchone()
assert result.total_books == 5
# Use the .refresh() method
book_count_mv.refresh(conn)
# Now shows 6
result = conn.execute(select(book_count_mv.as_table())).fetchone()
assert result.total_books == 6
conn.execute(DropMaterializedView(book_count_mv, if_exists=True))

1275
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff