mirror of
https://github.com/d3vyce/sqlalchemy-pgview.git
synced 2026-03-01 18:00:47 +01:00
Initial commit
This commit is contained in:
211
.gitignore
vendored
Normal file
211
.gitignore
vendored
Normal 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
24
.pre-commit-config.yaml
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
193
README.md
Normal file
193
README.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# SQLAlchemy-PGView
|
||||||
|
|
||||||
|
A SQLAlchemy 2.0+ extension that provides first-class support for PostgreSQL views and materialized views.
|
||||||
|
|
||||||
|
[](https://github.com/astral-sh/ty)
|
||||||
|
[](https://github.com/astral-sh/uv)
|
||||||
|
[](https://github.com/astral-sh/ruff)
|
||||||
|
[](https://www.python.org/downloads/)
|
||||||
|
[](https://www.sqlalchemy.org/)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Declarative Views** - Class-based view definitions using multiple inheritance with SQLAlchemy ORM
|
||||||
|
- **View & MaterializedView Classes** - Define PostgreSQL views as Python objects with full DDL support
|
||||||
|
- **Alembic Integration** - Database migration operations (`op.create_view()`, `op.drop_view()`, etc.)
|
||||||
|
- **Auto-Refresh** - Automatically refresh materialized views on data changes
|
||||||
|
- **Async Support** - Works with asyncpg and SQLAlchemy's async engines
|
||||||
|
- **Dependency Tracking** - Query PostgreSQL system catalogs for view dependencies
|
||||||
|
- **Type Safety** - Full type annotations for modern Python development
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- SQLAlchemy 2.0+
|
||||||
|
- PostgreSQL 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.
|
||||||
13
docs/api/alembic/create-materialized-view-op.md
Normal file
13
docs/api/alembic/create-materialized-view-op.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# `CreateMaterializedViewOp` class
|
||||||
|
|
||||||
|
Alembic operation to create a materialized view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview.alembic import CreateMaterializedViewOp
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.alembic.CreateMaterializedViewOp
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- create_materialized_view
|
||||||
|
- reverse
|
||||||
14
docs/api/alembic/create-view-op.md
Normal file
14
docs/api/alembic/create-view-op.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# `CreateViewOp` class
|
||||||
|
|
||||||
|
Alembic operation to create a view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sqlalchemy_pgview.alembic # Registers operations
|
||||||
|
from sqlalchemy_pgview.alembic import CreateViewOp
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.alembic.CreateViewOp
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- create_view
|
||||||
|
- reverse
|
||||||
12
docs/api/alembic/drop-materialized-view-op.md
Normal file
12
docs/api/alembic/drop-materialized-view-op.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# `DropMaterializedViewOp` class
|
||||||
|
|
||||||
|
Alembic operation to drop a materialized view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview.alembic import DropMaterializedViewOp
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.alembic.DropMaterializedViewOp
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- drop_materialized_view
|
||||||
12
docs/api/alembic/drop-view-op.md
Normal file
12
docs/api/alembic/drop-view-op.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# `DropViewOp` class
|
||||||
|
|
||||||
|
Alembic operation to drop a view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview.alembic import DropViewOp
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.alembic.DropViewOp
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- drop_view
|
||||||
12
docs/api/alembic/refresh-materialized-view-op.md
Normal file
12
docs/api/alembic/refresh-materialized-view-op.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# `RefreshMaterializedViewOp` class
|
||||||
|
|
||||||
|
Alembic operation to refresh a materialized view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview.alembic import RefreshMaterializedViewOp
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.alembic.RefreshMaterializedViewOp
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- refresh_materialized_view
|
||||||
9
docs/api/ddl/create-materialized-view.md
Normal file
9
docs/api/ddl/create-materialized-view.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# `CreateMaterializedView` class
|
||||||
|
|
||||||
|
DDL element to create a PostgreSQL materialized view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import CreateMaterializedView
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.CreateMaterializedView
|
||||||
9
docs/api/ddl/create-view.md
Normal file
9
docs/api/ddl/create-view.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# `CreateView` class
|
||||||
|
|
||||||
|
DDL element to create a PostgreSQL view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import CreateView
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.CreateView
|
||||||
12
docs/api/ddl/drop-materialized-view.md
Normal file
12
docs/api/ddl/drop-materialized-view.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# `DropMaterializedView` class
|
||||||
|
|
||||||
|
DDL element to drop a PostgreSQL materialized view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import DropMaterializedView
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.DropMaterializedView
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- fullname
|
||||||
12
docs/api/ddl/drop-view.md
Normal file
12
docs/api/ddl/drop-view.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# `DropView` class
|
||||||
|
|
||||||
|
DDL element to drop a PostgreSQL view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import DropView
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.DropView
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- fullname
|
||||||
12
docs/api/ddl/refresh-materialized-view.md
Normal file
12
docs/api/ddl/refresh-materialized-view.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# `RefreshMaterializedView` class
|
||||||
|
|
||||||
|
DDL element to refresh a PostgreSQL materialized view.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import RefreshMaterializedView
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.RefreshMaterializedView
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- fullname
|
||||||
7
docs/api/dependencies/get-all-views.md
Normal file
7
docs/api/dependencies/get-all-views.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `get_all_views`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_all_views
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.get_all_views
|
||||||
7
docs/api/dependencies/get-dependency-order.md
Normal file
7
docs/api/dependencies/get-dependency-order.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `get_dependency_order`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_dependency_order
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.get_dependency_order
|
||||||
7
docs/api/dependencies/get-reverse-dependencies.md
Normal file
7
docs/api/dependencies/get-reverse-dependencies.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `get_reverse_dependencies`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_reverse_dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.get_reverse_dependencies
|
||||||
7
docs/api/dependencies/get-view-definition.md
Normal file
7
docs/api/dependencies/get-view-definition.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `get_view_definition`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_view_definition
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.get_view_definition
|
||||||
7
docs/api/dependencies/get-view-dependencies.md
Normal file
7
docs/api/dependencies/get-view-dependencies.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `get_view_dependencies`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_view_dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.get_view_dependencies
|
||||||
17
docs/api/dependencies/view-dependency.md
Normal file
17
docs/api/dependencies/view-dependency.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# `ViewDependency` class
|
||||||
|
|
||||||
|
Represents a dependency between views.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import ViewDependency
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.ViewDependency
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- dependent_view
|
||||||
|
- dependent_schema
|
||||||
|
- referenced_view
|
||||||
|
- referenced_schema
|
||||||
|
- dependent_fullname
|
||||||
|
- referenced_fullname
|
||||||
16
docs/api/dependencies/view-info.md
Normal file
16
docs/api/dependencies/view-info.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# `ViewInfo` class
|
||||||
|
|
||||||
|
Information about a view from the database.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import ViewInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.ViewInfo
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- name
|
||||||
|
- schema
|
||||||
|
- definition
|
||||||
|
- is_materialized
|
||||||
|
- fullname
|
||||||
9
docs/api/views/auto-refresh-context.md
Normal file
9
docs/api/views/auto-refresh-context.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# `AutoRefreshContext` class
|
||||||
|
|
||||||
|
Context manager for auto-refreshing materialized views when using SQLAlchemy Core.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import AutoRefreshContext
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.AutoRefreshContext
|
||||||
7
docs/api/views/get-materialized-views.md
Normal file
7
docs/api/views/get-materialized-views.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `get_materialized_views`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_materialized_views
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.get_materialized_views
|
||||||
7
docs/api/views/get-views.md
Normal file
7
docs/api/views/get-views.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# `get_views`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_views
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.get_views
|
||||||
13
docs/api/views/materialized-view.md
Normal file
13
docs/api/views/materialized-view.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# `MaterializedView` class
|
||||||
|
|
||||||
|
Here's the reference for the `MaterializedView` class, which extends `View` with materialized view features.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import MaterializedView
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.MaterializedView
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- refresh
|
||||||
|
- auto_refresh_on
|
||||||
17
docs/api/views/view.md
Normal file
17
docs/api/views/view.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# `View` class
|
||||||
|
|
||||||
|
Here's the reference for the `View` class, with all its parameters, attributes and methods.
|
||||||
|
|
||||||
|
You can import it directly from `sqlalchemy_pgview`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import View
|
||||||
|
```
|
||||||
|
|
||||||
|
::: sqlalchemy_pgview.View
|
||||||
|
options:
|
||||||
|
members:
|
||||||
|
- fullname
|
||||||
|
- columns
|
||||||
|
- as_table
|
||||||
|
- as_from_clause
|
||||||
352
docs/guide/alembic.md
Normal file
352
docs/guide/alembic.md
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
# Alembic Migrations
|
||||||
|
|
||||||
|
SQLAlchemy-PGView provides Alembic operations for managing views in database migrations, including autogenerate support for automatic view detection.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Install with Alembic support:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install sqlalchemy-pgview[alembic]
|
||||||
|
```
|
||||||
|
|
||||||
|
In your `env.py`, import the alembic module to register comparators and renderers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# env.py
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
import sqlalchemy_pgview.alembic # Registers autogenerate support
|
||||||
|
|
||||||
|
# Import your models/views
|
||||||
|
from myapp.models import metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
## Autogenerate Support
|
||||||
|
|
||||||
|
When you run `alembic revision --autogenerate`, Alembic will:
|
||||||
|
|
||||||
|
1. **Detect new views**: Views in metadata but not in database → generates `create_view`/`create_materialized_view`
|
||||||
|
2. **Detect removed views**: Views in database but not in metadata → generates `drop_view`/`drop_materialized_view`
|
||||||
|
3. **Detect changed views**: Views with different definitions → generates drop + create
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migration with view changes
|
||||||
|
alembic revision --autogenerate -m "add user stats view"
|
||||||
|
```
|
||||||
|
|
||||||
|
Register views with metadata to enable detection:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import MetaData, Table, Column, Integer, String, select
|
||||||
|
from sqlalchemy_pgview import View, MaterializedView
|
||||||
|
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
users = Table("users", metadata, ...)
|
||||||
|
|
||||||
|
# These views will be detected by autogenerate
|
||||||
|
active_users = View(
|
||||||
|
"active_users",
|
||||||
|
select(users.c.id, users.c.name).where(users.c.is_active == True),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_stats = MaterializedView(
|
||||||
|
"user_stats",
|
||||||
|
select(users.c.id, users.c.name),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated migration example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""add user stats view
|
||||||
|
|
||||||
|
Revision ID: abc123
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy_pgview.alembic
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_view(
|
||||||
|
"active_users",
|
||||||
|
"SELECT users.id, users.name FROM users WHERE users.is_active = true"
|
||||||
|
)
|
||||||
|
op.create_materialized_view(
|
||||||
|
"user_stats",
|
||||||
|
"SELECT users.id, users.name FROM users",
|
||||||
|
with_data=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_materialized_view("user_stats")
|
||||||
|
op.drop_view("active_users")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
# Regular view
|
||||||
|
op.create_view(
|
||||||
|
"active_users",
|
||||||
|
"""
|
||||||
|
SELECT id, name, email
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = true
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
# With schema
|
||||||
|
op.create_view(
|
||||||
|
"user_metrics",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
COUNT(*) as order_count,
|
||||||
|
SUM(total) as total_spent
|
||||||
|
FROM orders
|
||||||
|
GROUP BY user_id
|
||||||
|
""",
|
||||||
|
schema="analytics",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CREATE OR REPLACE (won't fail if exists)
|
||||||
|
op.create_view(
|
||||||
|
"user_stats",
|
||||||
|
"SELECT id, name, created_at FROM users",
|
||||||
|
or_replace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_view("user_stats")
|
||||||
|
op.drop_view("user_metrics", schema="analytics")
|
||||||
|
op.drop_view("active_users")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Materialized Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
# With data populated immediately
|
||||||
|
op.create_materialized_view(
|
||||||
|
"monthly_revenue",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
date_trunc('month', created_at) as month,
|
||||||
|
SUM(total) as revenue
|
||||||
|
FROM orders
|
||||||
|
GROUP BY date_trunc('month', created_at)
|
||||||
|
""",
|
||||||
|
with_data=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without initial data (populate later)
|
||||||
|
op.create_materialized_view(
|
||||||
|
"large_summary",
|
||||||
|
"SELECT ...",
|
||||||
|
with_data=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_materialized_view("large_summary")
|
||||||
|
op.drop_materialized_view("monthly_revenue")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dropping Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
# Regular view
|
||||||
|
op.drop_view("old_view")
|
||||||
|
|
||||||
|
# With options
|
||||||
|
op.drop_view("another_view", if_exists=True, cascade=True)
|
||||||
|
|
||||||
|
# Materialized view
|
||||||
|
op.drop_materialized_view("old_mv", if_exists=True, cascade=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refreshing Materialized Views
|
||||||
|
|
||||||
|
Use in data migrations:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
# After data changes, refresh the view
|
||||||
|
op.refresh_materialized_view("monthly_revenue")
|
||||||
|
|
||||||
|
# Concurrent refresh (requires unique index)
|
||||||
|
op.refresh_materialized_view("monthly_revenue", concurrently=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modifying a View
|
||||||
|
|
||||||
|
Views can't be altered - drop and recreate:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
op.drop_view("user_stats")
|
||||||
|
op.create_view(
|
||||||
|
"user_stats",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.name,
|
||||||
|
u.email, -- Added column
|
||||||
|
COUNT(o.id) as order_count,
|
||||||
|
COALESCE(SUM(o.total), 0) as total_spent
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN orders o ON u.id = o.user_id
|
||||||
|
GROUP BY u.id, u.name, u.email
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_view("user_stats")
|
||||||
|
op.create_view(
|
||||||
|
"user_stats",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.name,
|
||||||
|
COUNT(o.id) as order_count,
|
||||||
|
COALESCE(SUM(o.total), 0) as total_spent
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN orders o ON u.id = o.user_id
|
||||||
|
GROUP BY u.id, u.name
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Use OR REPLACE When Possible"
|
||||||
|
If you're only changing the query (not column names/types), use `or_replace=True`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
op.create_view(
|
||||||
|
"user_stats",
|
||||||
|
"SELECT ... (new query)",
|
||||||
|
or_replace=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependent Views
|
||||||
|
|
||||||
|
Drop dependents first, recreate in order:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
# Drop in reverse dependency order
|
||||||
|
op.drop_view("derived_view", if_exists=True)
|
||||||
|
op.drop_view("base_view", if_exists=True)
|
||||||
|
|
||||||
|
# Recreate base view with changes
|
||||||
|
op.create_view("base_view", "SELECT ...")
|
||||||
|
|
||||||
|
# Recreate derived view
|
||||||
|
op.create_view("derived_view", "SELECT * FROM base_view WHERE ...")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_view("derived_view")
|
||||||
|
op.drop_view("base_view")
|
||||||
|
op.create_view("base_view", "SELECT ... (old query)")
|
||||||
|
op.create_view("derived_view", "SELECT ... (old query)")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Converting Table to Materialized View
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade():
|
||||||
|
# Create materialized view from table data
|
||||||
|
op.create_materialized_view(
|
||||||
|
"summary_mv",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
category,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(amount) as total
|
||||||
|
FROM transactions
|
||||||
|
GROUP BY category
|
||||||
|
""",
|
||||||
|
with_data=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optionally drop the old summary table
|
||||||
|
op.drop_table("summary_table")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Recreate table
|
||||||
|
op.create_table(
|
||||||
|
"summary_table",
|
||||||
|
sa.Column("category", sa.String(50)),
|
||||||
|
sa.Column("count", sa.Integer),
|
||||||
|
sa.Column("total", sa.Numeric(10, 2)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Populate from materialized view
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO summary_table
|
||||||
|
SELECT * FROM summary_mv
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.drop_materialized_view("summary_mv")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Add analytics views
|
||||||
|
|
||||||
|
Revision ID: abc123
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy_pgview.alembic
|
||||||
|
|
||||||
|
revision = 'abc123'
|
||||||
|
down_revision = 'xyz789'
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create schema
|
||||||
|
op.execute("CREATE SCHEMA IF NOT EXISTS analytics")
|
||||||
|
|
||||||
|
# Regular view for real-time queries
|
||||||
|
op.create_view(
|
||||||
|
"active_orders",
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM orders
|
||||||
|
WHERE status = 'pending'
|
||||||
|
""",
|
||||||
|
schema="analytics",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Materialized view for reports
|
||||||
|
op.create_materialized_view(
|
||||||
|
"daily_revenue",
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
date_trunc('day', created_at) as day,
|
||||||
|
COUNT(*) as orders,
|
||||||
|
SUM(total) as revenue
|
||||||
|
FROM orders
|
||||||
|
WHERE status = 'completed'
|
||||||
|
GROUP BY date_trunc('day', created_at)
|
||||||
|
""",
|
||||||
|
schema="analytics",
|
||||||
|
with_data=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create index for concurrent refresh
|
||||||
|
op.execute("""
|
||||||
|
CREATE UNIQUE INDEX idx_daily_revenue_day
|
||||||
|
ON analytics.daily_revenue (day)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_materialized_view("daily_revenue", schema="analytics")
|
||||||
|
op.drop_view("active_orders", schema="analytics")
|
||||||
|
op.execute("DROP SCHEMA IF EXISTS analytics")
|
||||||
|
```
|
||||||
219
docs/guide/async.md
Normal file
219
docs/guide/async.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Async Support
|
||||||
|
|
||||||
|
SQLAlchemy-PGView works with SQLAlchemy's async engine using `asyncpg`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Install asyncpg:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install asyncpg
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an async engine:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Views (Async)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy_pgview import View, CreateView, DropView
|
||||||
|
|
||||||
|
user_stats = View(
|
||||||
|
"user_stats",
|
||||||
|
select(
|
||||||
|
users.c.id,
|
||||||
|
users.c.name,
|
||||||
|
func.count(orders.c.id).label("order_count"),
|
||||||
|
)
|
||||||
|
.join(orders)
|
||||||
|
.group_by(users.c.id, users.c.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Create view
|
||||||
|
await conn.execute(CreateView(user_stats, or_replace=True))
|
||||||
|
|
||||||
|
# Query view
|
||||||
|
result = await conn.execute(select(user_stats.as_table()))
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# Drop view
|
||||||
|
await conn.execute(DropView(user_stats, if_exists=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Materialized Views (Async)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import (
|
||||||
|
MaterializedView,
|
||||||
|
CreateMaterializedView,
|
||||||
|
RefreshMaterializedView,
|
||||||
|
DropMaterializedView,
|
||||||
|
)
|
||||||
|
|
||||||
|
monthly_stats = MaterializedView(
|
||||||
|
"monthly_stats",
|
||||||
|
select(
|
||||||
|
func.date_trunc('month', orders.c.created_at).label("month"),
|
||||||
|
func.sum(orders.c.total).label("revenue"),
|
||||||
|
).group_by(func.date_trunc('month', orders.c.created_at)),
|
||||||
|
with_data=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Create
|
||||||
|
await conn.execute(CreateMaterializedView(monthly_stats))
|
||||||
|
|
||||||
|
# Query
|
||||||
|
result = await conn.execute(select(monthly_stats.as_table()))
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# Refresh
|
||||||
|
await conn.execute(RefreshMaterializedView(monthly_stats))
|
||||||
|
|
||||||
|
# Drop
|
||||||
|
await conn.execute(DropMaterializedView(monthly_stats, if_exists=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refreshing with .refresh() Method
|
||||||
|
|
||||||
|
The `.refresh()` method is synchronous. Use `run_sync` to call it from async code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Use run_sync for the synchronous refresh method
|
||||||
|
def refresh_sync(sync_conn):
|
||||||
|
monthly_stats.refresh(sync_conn)
|
||||||
|
|
||||||
|
await conn.run_sync(refresh_sync)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the DDL directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(RefreshMaterializedView(monthly_stats, concurrently=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Prefer DDL Classes for Async"
|
||||||
|
For async code, prefer using `RefreshMaterializedView` DDL class over the `.refresh()` method to avoid `run_sync` overhead.
|
||||||
|
|
||||||
|
## Dependency Functions (Async)
|
||||||
|
|
||||||
|
The dependency tracking functions are synchronous. Use `run_sync`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_all_views, get_view_definition
|
||||||
|
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
# Get all views
|
||||||
|
views = await conn.run_sync(lambda c: get_all_views(c))
|
||||||
|
|
||||||
|
for view in views:
|
||||||
|
print(f"{view.fullname}: {'MV' if view.is_materialized else 'V'}")
|
||||||
|
|
||||||
|
# Get view definition
|
||||||
|
definition = await conn.run_sync(
|
||||||
|
lambda c: get_view_definition(c, "user_stats")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sync vs Async Operations
|
||||||
|
|
||||||
|
| Operation | Sync | Async |
|
||||||
|
|-----------|------|-------|
|
||||||
|
| DDL (CREATE/DROP) | Direct | Direct |
|
||||||
|
| Queries | Direct | Direct |
|
||||||
|
| `.refresh()` method | Direct | Use `run_sync` |
|
||||||
|
| Dependency functions | Direct | Use `run_sync` |
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Numeric, ForeignKey, MetaData, Table, select, func
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy_pgview import (
|
||||||
|
View,
|
||||||
|
MaterializedView,
|
||||||
|
CreateView,
|
||||||
|
CreateMaterializedView,
|
||||||
|
RefreshMaterializedView,
|
||||||
|
DropView,
|
||||||
|
DropMaterializedView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
users = Table(
|
||||||
|
"users", metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("name", String(100)),
|
||||||
|
)
|
||||||
|
|
||||||
|
orders = Table(
|
||||||
|
"orders", metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("user_id", Integer, ForeignKey("users.id")),
|
||||||
|
Column("total", Numeric(10, 2)),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(metadata.create_all)
|
||||||
|
|
||||||
|
# Define views
|
||||||
|
user_summary = View(
|
||||||
|
"user_summary",
|
||||||
|
select(
|
||||||
|
users.c.id,
|
||||||
|
users.c.name,
|
||||||
|
func.count(orders.c.id).label("order_count"),
|
||||||
|
)
|
||||||
|
.select_from(users.outerjoin(orders))
|
||||||
|
.group_by(users.c.id, users.c.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
revenue_mv = MaterializedView(
|
||||||
|
"revenue_summary",
|
||||||
|
select(func.sum(orders.c.total).label("total_revenue")),
|
||||||
|
with_data=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
# Create views
|
||||||
|
await conn.execute(CreateView(user_summary, or_replace=True))
|
||||||
|
await conn.execute(CreateMaterializedView(revenue_mv))
|
||||||
|
|
||||||
|
# Query regular view (always current)
|
||||||
|
result = await conn.execute(select(user_summary.as_table()))
|
||||||
|
print("User Summary:", result.fetchall())
|
||||||
|
|
||||||
|
# Query materialized view
|
||||||
|
result = await conn.execute(select(revenue_mv.as_table()))
|
||||||
|
print("Revenue:", result.fetchone())
|
||||||
|
|
||||||
|
# Refresh materialized view
|
||||||
|
await conn.execute(RefreshMaterializedView(revenue_mv))
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await conn.execute(DropMaterializedView(revenue_mv, if_exists=True))
|
||||||
|
await conn.execute(DropView(user_summary, if_exists=True))
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
207
docs/guide/dependencies.md
Normal file
207
docs/guide/dependencies.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# Dependency Tracking
|
||||||
|
|
||||||
|
SQLAlchemy-PGView can query PostgreSQL system catalogs to discover view dependencies, helping you manage complex view hierarchies.
|
||||||
|
|
||||||
|
## Why Track Dependencies?
|
||||||
|
|
||||||
|
- **Safe migrations**: Know which views to drop/recreate when modifying a base view
|
||||||
|
- **Impact analysis**: Understand what breaks if you change a table or view
|
||||||
|
- **Correct ordering**: Create views in the right order during deployment
|
||||||
|
|
||||||
|
## Getting All Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_all_views
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
views = get_all_views(conn)
|
||||||
|
|
||||||
|
for view in views:
|
||||||
|
print(f"{view.schema}.{view.name}")
|
||||||
|
print(f" Materialized: {view.is_materialized}")
|
||||||
|
print(f" Definition: {view.definition[:50]}...")
|
||||||
|
|
||||||
|
# Only views in 'analytics' schema
|
||||||
|
views = get_all_views(conn, schema="analytics")
|
||||||
|
|
||||||
|
# Exclude materialized views
|
||||||
|
views = get_all_views(conn, include_materialized=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting View Definition
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_view_definition
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
definition = get_view_definition(conn, "user_stats")
|
||||||
|
print(definition)
|
||||||
|
# SELECT u.id, u.name, count(o.id) AS order_count
|
||||||
|
# FROM users u LEFT JOIN orders o ON u.id = o.user_id
|
||||||
|
# GROUP BY u.id, u.name
|
||||||
|
```
|
||||||
|
|
||||||
|
## Direct Dependencies
|
||||||
|
|
||||||
|
Find what a view depends on:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_view_dependencies
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
deps = get_view_dependencies(conn, "user_order_summary")
|
||||||
|
|
||||||
|
for dep in deps:
|
||||||
|
print(f"{dep.dependent_fullname} depends on {dep.referenced_fullname}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse Dependencies
|
||||||
|
|
||||||
|
Find what depends on a view (impact analysis):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_reverse_dependencies
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# What views depend on 'users' table/view?
|
||||||
|
dependents = get_reverse_dependencies(conn, "users")
|
||||||
|
|
||||||
|
print("Views that depend on 'users':")
|
||||||
|
for view in dependents:
|
||||||
|
print(f" - {view.fullname}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Order
|
||||||
|
|
||||||
|
Get views sorted by dependencies (dependencies first):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_dependency_order
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
ordered_views = get_dependency_order(conn)
|
||||||
|
|
||||||
|
print("Views in creation order:")
|
||||||
|
for i, view in enumerate(ordered_views, 1):
|
||||||
|
print(f"{i}. {view.fullname}")
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for:
|
||||||
|
|
||||||
|
- **Creating views**: Process in order to avoid "relation does not exist" errors
|
||||||
|
- **Dropping views**: Process in reverse order to avoid dependency errors
|
||||||
|
|
||||||
|
## Safe View Modification
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import (
|
||||||
|
get_reverse_dependencies,
|
||||||
|
get_view_definition,
|
||||||
|
DropView,
|
||||||
|
CreateView,
|
||||||
|
)
|
||||||
|
|
||||||
|
def modify_view_safely(conn, view_name, new_definition):
|
||||||
|
"""Modify a view by dropping/recreating it and all dependents."""
|
||||||
|
|
||||||
|
# Get all views that depend on this one
|
||||||
|
dependents = get_reverse_dependencies(conn, view_name)
|
||||||
|
|
||||||
|
# Save their definitions
|
||||||
|
saved_definitions = {}
|
||||||
|
for dep in dependents:
|
||||||
|
saved_definitions[dep.fullname] = get_view_definition(
|
||||||
|
conn, dep.name, dep.schema
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop dependents in reverse order
|
||||||
|
for dep in reversed(dependents):
|
||||||
|
conn.execute(DropView(dep.name, schema=dep.schema, if_exists=True))
|
||||||
|
|
||||||
|
# Drop and recreate the target view
|
||||||
|
conn.execute(DropView(view_name, if_exists=True))
|
||||||
|
conn.execute(text(f"CREATE VIEW {view_name} AS {new_definition}"))
|
||||||
|
|
||||||
|
# Recreate dependents in order
|
||||||
|
for dep in dependents:
|
||||||
|
definition = saved_definitions[dep.fullname]
|
||||||
|
conn.execute(text(
|
||||||
|
f"CREATE VIEW {dep.fullname} AS {definition}"
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Helper
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_view_migration(conn, schema=None):
|
||||||
|
"""Generate migration code for all views in dependency order."""
|
||||||
|
|
||||||
|
views = get_dependency_order(conn, schema=schema)
|
||||||
|
|
||||||
|
print("def upgrade():")
|
||||||
|
for view in views:
|
||||||
|
view_type = "materialized_view" if view.is_materialized else "view"
|
||||||
|
definition = view.definition.replace("'", "\\'")
|
||||||
|
print(f" op.create_{view_type}(")
|
||||||
|
print(f" '{view.name}',")
|
||||||
|
print(f" '''{definition}''',")
|
||||||
|
if view.schema != "public":
|
||||||
|
print(f" schema='{view.schema}',")
|
||||||
|
print(f" )")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("def downgrade():")
|
||||||
|
for view in reversed(views):
|
||||||
|
view_type = "materialized_view" if view.is_materialized else "view"
|
||||||
|
print(f" op.drop_{view_type}('{view.name}'", end="")
|
||||||
|
if view.schema != "public":
|
||||||
|
print(f", schema='{view.schema}'", end="")
|
||||||
|
print(")")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Visualization
|
||||||
|
|
||||||
|
```python
|
||||||
|
def print_dependency_tree(conn, view_name, indent=0):
|
||||||
|
"""Print a tree of view dependencies."""
|
||||||
|
prefix = " " * indent
|
||||||
|
print(f"{prefix}- {view_name}")
|
||||||
|
|
||||||
|
deps = get_view_dependencies(conn, view_name)
|
||||||
|
for dep in deps:
|
||||||
|
print_dependency_tree(conn, dep.referenced_fullname, indent + 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
- sales_summary
|
||||||
|
- monthly_sales
|
||||||
|
- orders
|
||||||
|
- products
|
||||||
|
- customer_stats
|
||||||
|
- customers
|
||||||
|
- orders
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Classes
|
||||||
|
|
||||||
|
### ViewInfo
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `name` | `str` | View name |
|
||||||
|
| `schema` | `str` | Schema name |
|
||||||
|
| `definition` | `str` | SQL definition |
|
||||||
|
| `is_materialized` | `bool` | Whether the view is materialized |
|
||||||
|
| `fullname` | `str` | `schema.name` (property) |
|
||||||
|
|
||||||
|
### ViewDependency
|
||||||
|
|
||||||
|
| Attribute | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `dependent_view` | `str` | Name of the dependent view |
|
||||||
|
| `dependent_schema` | `str` | Schema of the dependent view |
|
||||||
|
| `referenced_view` | `str` | Name of the referenced object |
|
||||||
|
| `referenced_schema` | `str` | Schema of the referenced object |
|
||||||
|
| `dependent_fullname` | `str` | `schema.view` (property) |
|
||||||
|
| `referenced_fullname` | `str` | `schema.view` (property) |
|
||||||
307
docs/guide/materialized-views.md
Normal file
307
docs/guide/materialized-views.md
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
# Materialized Views
|
||||||
|
|
||||||
|
Materialized views store the query results physically, making them faster to query but requiring explicit refresh to update.
|
||||||
|
|
||||||
|
## When to Use Materialized Views
|
||||||
|
|
||||||
|
| Use Case | Regular View | Materialized View |
|
||||||
|
|----------|--------------|-------------------|
|
||||||
|
| Complex aggregations queried frequently | Slow | **Fast** |
|
||||||
|
| Data that changes frequently | **Current** | Stale |
|
||||||
|
| Reports and dashboards | Slow | **Fast** |
|
||||||
|
| Real-time data requirements | **Yes** | No |
|
||||||
|
| Large dataset summaries | Slow | **Fast** |
|
||||||
|
|
||||||
|
## Creating Materialized Views (Declarative)
|
||||||
|
|
||||||
|
The recommended way to create materialized views is using the declarative pattern with multiple inheritance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import select, func, String, Numeric, Integer, DateTime
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
from sqlalchemy_pgview import MaterializedViewBase
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
total: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||||
|
created_at: Mapped[DateTime] = mapped_column(DateTime)
|
||||||
|
|
||||||
|
# Define materialized view with data populated immediately
|
||||||
|
class MonthlySales(MaterializedViewBase, Base):
|
||||||
|
__tablename__ = "monthly_sales"
|
||||||
|
__select__ = select(
|
||||||
|
func.date_trunc('month', Order.created_at).label("month"),
|
||||||
|
func.count(Order.id).label("order_count"),
|
||||||
|
func.sum(Order.total).label("revenue"),
|
||||||
|
).group_by(func.date_trunc('month', Order.created_at))
|
||||||
|
__with_data__ = True # Default: populate on creation
|
||||||
|
|
||||||
|
# Create tables and views together
|
||||||
|
engine = create_engine("postgresql://user:pass@localhost/mydb")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
```
|
||||||
|
|
||||||
|
### MaterializedViewBase Attributes
|
||||||
|
|
||||||
|
| Attribute | Required | Description |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `__tablename__` | Yes | Name of the materialized view in the database |
|
||||||
|
| `__select__` | Yes | SELECT statement that defines the view |
|
||||||
|
| `__schema__` | No | Database schema (default: None/public) |
|
||||||
|
| `__with_data__` | No | Populate data on creation (default: True) |
|
||||||
|
|
||||||
|
## Querying Materialized Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Query all rows
|
||||||
|
result = conn.execute(select(MonthlySales.as_table())).fetchall()
|
||||||
|
|
||||||
|
# Query with filtering using .c accessor
|
||||||
|
result = conn.execute(
|
||||||
|
select(MonthlySales.as_table())
|
||||||
|
.where(MonthlySales.c.order_count > 10)
|
||||||
|
.order_by(MonthlySales.c.revenue.desc())
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in result:
|
||||||
|
print(f"{row.month}: {row.order_count} orders, ${row.revenue}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refreshing Materialized Views
|
||||||
|
|
||||||
|
Materialized views become stale when underlying data changes. Refresh to update:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# Using the class method (declarative)
|
||||||
|
MonthlySales.refresh(conn)
|
||||||
|
|
||||||
|
# Or using the view object
|
||||||
|
MonthlySales.as_view().refresh(conn)
|
||||||
|
|
||||||
|
# Concurrent refresh (requires a unique index, doesn't block reads)
|
||||||
|
MonthlySales.refresh(conn, concurrently=True)
|
||||||
|
|
||||||
|
# Refresh without data (empties the view)
|
||||||
|
MonthlySales.refresh(conn, with_data=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "Concurrent Refresh Requirements"
|
||||||
|
`REFRESH MATERIALIZED VIEW CONCURRENTLY` requires:
|
||||||
|
|
||||||
|
- A unique index on the materialized view
|
||||||
|
- The view must already contain data (can't be empty)
|
||||||
|
|
||||||
|
## Stale Data Behavior
|
||||||
|
|
||||||
|
!!! warning "Materialized Views Don't Auto-Update"
|
||||||
|
Unlike regular views, materialized views show **stale data** until refreshed.
|
||||||
|
|
||||||
|
```python
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# Insert new data into underlying table
|
||||||
|
conn.execute(insert(Order.__table__).values(...))
|
||||||
|
|
||||||
|
# Materialized view still shows old data!
|
||||||
|
# Refresh to see new data
|
||||||
|
MonthlySales.refresh(conn)
|
||||||
|
|
||||||
|
# Now the view reflects the changes
|
||||||
|
result = conn.execute(select(MonthlySales.as_table())).fetchall()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Refresh (ORM)
|
||||||
|
|
||||||
|
SQLAlchemy-PGView can automatically refresh materialized views when watched tables are modified via ORM:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Enable auto-refresh when Order table changes
|
||||||
|
MonthlySales.auto_refresh_on(Session, Order.__table__)
|
||||||
|
|
||||||
|
# Now ORM commits automatically refresh the view
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(Order(total=Decimal("99.99"), created_at=datetime.now()))
|
||||||
|
session.commit() # MonthlySales is refreshed automatically!
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! tip "Custom Session Class"
|
||||||
|
For isolated testing or specific workflows, create a custom Session subclass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AnalyticsSession(Session):
|
||||||
|
pass
|
||||||
|
|
||||||
|
MonthlySales.auto_refresh_on(AnalyticsSession, Order.__table__)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Refresh (Core)
|
||||||
|
|
||||||
|
For SQLAlchemy Core (without ORM), use `AutoRefreshContext`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import AutoRefreshContext
|
||||||
|
|
||||||
|
# Get the underlying view and table objects
|
||||||
|
mview = MonthlySales.as_view()
|
||||||
|
orders_table = Order.__table__
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
with AutoRefreshContext(conn, mview, orders_table):
|
||||||
|
conn.execute(insert(orders_table).values(total=100, created_at=datetime.now()))
|
||||||
|
conn.execute(insert(orders_table).values(total=200, created_at=datetime.now()))
|
||||||
|
# View is refreshed when exiting the context
|
||||||
|
|
||||||
|
# View now shows updated data
|
||||||
|
result = conn.execute(select(MonthlySales.as_table())).fetchone()
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "Auto-Refresh Considerations"
|
||||||
|
- **Performance**: Each commit triggers a refresh. For high-frequency writes, use scheduled refresh instead.
|
||||||
|
- **Exceptions**: `AutoRefreshContext` only refreshes on successful exit (no exception).
|
||||||
|
- **ORM-only**: `auto_refresh_on()` only catches changes made through the ORM Session, not raw SQL.
|
||||||
|
|
||||||
|
## Refresh Strategies
|
||||||
|
|
||||||
|
| Data Freshness Requirement | Strategy |
|
||||||
|
|---------------------------|----------|
|
||||||
|
| Real-time (careful!) | Auto-refresh on commit |
|
||||||
|
| Minutes | Frequent scheduled refresh |
|
||||||
|
| Hours | Hourly cron job |
|
||||||
|
| Daily | Nightly refresh |
|
||||||
|
| On-demand | Manual refresh before queries |
|
||||||
|
|
||||||
|
## Imperative API (Alternative)
|
||||||
|
|
||||||
|
For SQLAlchemy Core or when declarative style isn't suitable:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import MetaData, Table, Column, Integer, Numeric, DateTime, select, func
|
||||||
|
from sqlalchemy_pgview import MaterializedView, CreateMaterializedView, RefreshMaterializedView
|
||||||
|
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
orders = Table(
|
||||||
|
"orders", metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("total", Numeric(10, 2)),
|
||||||
|
Column("created_at", DateTime),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define materialized view
|
||||||
|
monthly_sales = MaterializedView(
|
||||||
|
"monthly_sales",
|
||||||
|
select(
|
||||||
|
func.date_trunc('month', orders.c.created_at).label("month"),
|
||||||
|
func.count(orders.c.id).label("order_count"),
|
||||||
|
func.sum(orders.c.total).label("revenue"),
|
||||||
|
).group_by(func.date_trunc('month', orders.c.created_at)),
|
||||||
|
metadata=metadata,
|
||||||
|
with_data=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tables and views
|
||||||
|
metadata.create_all(engine)
|
||||||
|
|
||||||
|
# Refresh
|
||||||
|
with engine.begin() as conn:
|
||||||
|
monthly_sales.refresh(conn)
|
||||||
|
# Or using DDL directly
|
||||||
|
conn.execute(RefreshMaterializedView(monthly_sales, concurrently=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dropping Materialized Views
|
||||||
|
|
||||||
|
Materialized views are automatically dropped when using `metadata.drop_all()`. For manual control:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import DropMaterializedView
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# Drop using view object
|
||||||
|
conn.execute(DropMaterializedView(MonthlySales.as_view()))
|
||||||
|
|
||||||
|
# Drop by name with options
|
||||||
|
conn.execute(DropMaterializedView("old_mv", if_exists=True))
|
||||||
|
|
||||||
|
# Drop with cascade (drops dependent objects)
|
||||||
|
conn.execute(DropMaterializedView("base_mv", cascade=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Registered Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_views
|
||||||
|
|
||||||
|
# Get all registered views from metadata (includes materialized views)
|
||||||
|
views = get_views(Base.metadata)
|
||||||
|
print(views) # {'monthly_sales': <MaterializedView 'monthly_sales'>}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import create_engine, select, func, String, Numeric, Integer, DateTime
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
|
||||||
|
from sqlalchemy_pgview import MaterializedViewBase
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
__tablename__ = "products"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
category: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
class Sale(Base):
|
||||||
|
__tablename__ = "sales"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||||
|
sold_at: Mapped[datetime] = mapped_column(DateTime)
|
||||||
|
|
||||||
|
class CategorySales(MaterializedViewBase, Base):
|
||||||
|
__tablename__ = "category_sales"
|
||||||
|
__select__ = select(
|
||||||
|
Product.category,
|
||||||
|
func.count(Sale.id).label("sale_count"),
|
||||||
|
func.sum(Sale.quantity).label("total_quantity"),
|
||||||
|
func.sum(Sale.amount).label("total_revenue"),
|
||||||
|
).select_from(
|
||||||
|
Sale.__table__.join(Product.__table__, Sale.product_id == Product.id)
|
||||||
|
).group_by(Product.category)
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
engine = create_engine("postgresql://...")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
# Enable auto-refresh
|
||||||
|
CategorySales.auto_refresh_on(Session, Sale.__table__)
|
||||||
|
|
||||||
|
# Add data - view refreshes automatically on commit
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(Sale(product_id=1, quantity=5, amount=Decimal("49.95"), sold_at=datetime.now()))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Query fresh data
|
||||||
|
with engine.connect() as conn:
|
||||||
|
results = conn.execute(
|
||||||
|
select(CategorySales.as_table()).order_by(CategorySales.c.total_revenue.desc())
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
print(f"{row.category}: {row.sale_count} sales, ${row.total_revenue}")
|
||||||
|
```
|
||||||
242
docs/guide/views.md
Normal file
242
docs/guide/views.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# Views
|
||||||
|
|
||||||
|
Views are virtual tables whose contents are defined by a query. Unlike regular tables, views don't store data - they compute results on each access.
|
||||||
|
|
||||||
|
## Creating Views (Declarative)
|
||||||
|
|
||||||
|
The recommended way to create views is using the declarative pattern with multiple inheritance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import select, String
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
from sqlalchemy_pgview import ViewBase
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
email: Mapped[str] = mapped_column(String(100))
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
|
|
||||||
|
# Define a view using ViewBase
|
||||||
|
class ActiveUsers(ViewBase, Base):
|
||||||
|
__tablename__ = "active_users"
|
||||||
|
__select__ = select(User.id, User.name, User.email).where(User.is_active == True)
|
||||||
|
|
||||||
|
# Create tables and views together
|
||||||
|
engine = create_engine("postgresql://user:pass@localhost/mydb")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ViewBase Attributes
|
||||||
|
|
||||||
|
| Attribute | Required | Description |
|
||||||
|
|-----------|----------|-------------|
|
||||||
|
| `__tablename__` | Yes | Name of the view in the database |
|
||||||
|
| `__select__` | Yes | SELECT statement that defines the view |
|
||||||
|
| `__schema__` | No | Database schema (default: None/public) |
|
||||||
|
|
||||||
|
## Querying Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Query all rows
|
||||||
|
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
|
||||||
|
|
||||||
|
# Query with filtering using .c accessor
|
||||||
|
result = conn.execute(
|
||||||
|
select(ActiveUsers.as_table())
|
||||||
|
.where(ActiveUsers.c.name.like('A%'))
|
||||||
|
.order_by(ActiveUsers.c.name)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in result:
|
||||||
|
print(f"{row.name} ({row.email})")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Views with Joins
|
||||||
|
|
||||||
|
Views can encapsulate complex joins:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
class Author(Base):
|
||||||
|
__tablename__ = "authors"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
|
||||||
|
class Book(Base):
|
||||||
|
__tablename__ = "books"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200))
|
||||||
|
author_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||||
|
|
||||||
|
# View with one-to-many join and aggregation
|
||||||
|
class AuthorStats(ViewBase, Base):
|
||||||
|
__tablename__ = "author_stats"
|
||||||
|
__select__ = select(
|
||||||
|
Author.id,
|
||||||
|
Author.name,
|
||||||
|
func.count(Book.id).label("book_count"),
|
||||||
|
func.avg(Book.price).label("avg_price"),
|
||||||
|
).select_from(
|
||||||
|
Author.__table__.outerjoin(Book.__table__, Author.id == Book.author_id)
|
||||||
|
).group_by(Author.id, Author.name)
|
||||||
|
```
|
||||||
|
|
||||||
|
## View Update Behavior
|
||||||
|
|
||||||
|
!!! info "Views Always Show Current Data"
|
||||||
|
Regular views are just stored queries. When you INSERT, UPDATE, or DELETE rows in the underlying tables, the view immediately reflects those changes.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
# Add new user
|
||||||
|
session.add(User(name="Alice", email="alice@example.com", is_active=True))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# View immediately shows Alice
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
|
||||||
|
# Alice is included!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Imperative API (Alternative)
|
||||||
|
|
||||||
|
For SQLAlchemy Core or when declarative style isn't suitable:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import MetaData, Table, Column, Integer, String, Boolean, select
|
||||||
|
from sqlalchemy_pgview import View
|
||||||
|
|
||||||
|
metadata = MetaData()
|
||||||
|
|
||||||
|
users = Table(
|
||||||
|
"users", metadata,
|
||||||
|
Column("id", Integer, primary_key=True),
|
||||||
|
Column("name", String(100)),
|
||||||
|
Column("email", String(100)),
|
||||||
|
Column("is_active", Boolean, default=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define the view
|
||||||
|
active_users = View(
|
||||||
|
name="active_users",
|
||||||
|
selectable=select(users.c.id, users.c.name, users.c.email)
|
||||||
|
.where(users.c.is_active == True),
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tables and views
|
||||||
|
metadata.create_all(engine)
|
||||||
|
|
||||||
|
# Query the view
|
||||||
|
with engine.connect() as conn:
|
||||||
|
result = conn.execute(select(active_users.as_table())).fetchall()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dropping Views
|
||||||
|
|
||||||
|
Views are automatically dropped when using `metadata.drop_all()`. For manual control:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import DropView
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# Drop using view object
|
||||||
|
conn.execute(DropView(ActiveUsers.as_view()))
|
||||||
|
|
||||||
|
# Drop by name with options
|
||||||
|
conn.execute(DropView("old_view", if_exists=True))
|
||||||
|
|
||||||
|
# Drop with cascade (drops dependent objects)
|
||||||
|
conn.execute(DropView("base_view", cascade=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Registered Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy_pgview import get_views
|
||||||
|
|
||||||
|
# Get all registered views from metadata
|
||||||
|
views = get_views(Base.metadata)
|
||||||
|
print(views) # {'active_users': <View 'active_users'>}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import create_engine, select, func, String, Numeric, Integer
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
|
||||||
|
from sqlalchemy_pgview import ViewBase
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Product(Base):
|
||||||
|
__tablename__ = "products"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
category: Mapped[str] = mapped_column(String(50))
|
||||||
|
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||||
|
in_stock: Mapped[bool] = mapped_column(default=True)
|
||||||
|
|
||||||
|
# View for available products only
|
||||||
|
class AvailableProducts(ViewBase, Base):
|
||||||
|
__tablename__ = "available_products"
|
||||||
|
__select__ = select(
|
||||||
|
Product.id,
|
||||||
|
Product.name,
|
||||||
|
Product.category,
|
||||||
|
Product.price,
|
||||||
|
).where(Product.in_stock == True)
|
||||||
|
|
||||||
|
# View with aggregation by category
|
||||||
|
class CategoryPricing(ViewBase, Base):
|
||||||
|
__tablename__ = "category_pricing"
|
||||||
|
__select__ = select(
|
||||||
|
Product.category,
|
||||||
|
func.count(Product.id).label("product_count"),
|
||||||
|
func.min(Product.price).label("min_price"),
|
||||||
|
func.max(Product.price).label("max_price"),
|
||||||
|
func.avg(Product.price).label("avg_price"),
|
||||||
|
).where(Product.in_stock == True).group_by(Product.category)
|
||||||
|
|
||||||
|
# Setup
|
||||||
|
engine = create_engine("postgresql://...")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
# Add products
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add_all([
|
||||||
|
Product(name="Widget", category="Tools", price=Decimal("9.99")),
|
||||||
|
Product(name="Gadget", category="Electronics", price=Decimal("49.99")),
|
||||||
|
Product(name="Doohickey", category="Tools", price=Decimal("19.99")),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# Query views
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Get available products
|
||||||
|
available = conn.execute(select(AvailableProducts.as_table())).fetchall()
|
||||||
|
print(f"Available products: {len(available)}")
|
||||||
|
|
||||||
|
# Get category pricing
|
||||||
|
pricing = conn.execute(
|
||||||
|
select(CategoryPricing.as_table())
|
||||||
|
.order_by(CategoryPricing.c.avg_price.desc())
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for row in pricing:
|
||||||
|
print(f"{row.category}: {row.product_count} products, avg ${row.avg_price:.2f}")
|
||||||
|
```
|
||||||
172
docs/index.md
Normal file
172
docs/index.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# SQLAlchemy-PGView
|
||||||
|
|
||||||
|
**A SQLAlchemy 2.0+ extension that provides first-class support for PostgreSQL views and materialized views.**
|
||||||
|
|
||||||
|
[](https://github.com/astral-sh/ty)
|
||||||
|
[](https://github.com/astral-sh/uv)
|
||||||
|
[](https://github.com/astral-sh/ruff)
|
||||||
|
[](https://www.python.org/downloads/)
|
||||||
|
[](https://www.sqlalchemy.org/)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Declarative Views** - Class-based view definitions using multiple inheritance with SQLAlchemy ORM
|
||||||
|
- **View & MaterializedView Classes** - Define PostgreSQL views as Python objects with full DDL support
|
||||||
|
- **Alembic Integration** - Database migration operations (`op.create_view()`, `op.drop_view()`, etc.)
|
||||||
|
- **Auto-Refresh** - Automatically refresh materialized views on data changes
|
||||||
|
- **Async Support** - Works with asyncpg and SQLAlchemy's async engines
|
||||||
|
- **Dependency Tracking** - Query PostgreSQL system catalogs for view dependencies
|
||||||
|
- **Type Safety** - Full type annotations for modern Python development
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- SQLAlchemy 2.0+
|
||||||
|
- PostgreSQL 12+
|
||||||
|
- Alembic 1.10+ (optional, for migrations)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
=== "Base package"
|
||||||
|
```bash
|
||||||
|
uv pip install "sqlalchemy-pgview"
|
||||||
|
```
|
||||||
|
=== "With alembic support"
|
||||||
|
```bash
|
||||||
|
uv pip install "sqlalchemy-pgview[alembic]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
The recommended way to define views is using the **declarative pattern** with multiple inheritance. This integrates seamlessly with SQLAlchemy ORM models.
|
||||||
|
|
||||||
|
### Define Your Models and Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import create_engine, select, func, String, Numeric, Integer
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session
|
||||||
|
from sqlalchemy_pgview import ViewBase, MaterializedViewBase
|
||||||
|
|
||||||
|
# Define your base and models
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
is_active: Mapped[bool] = mapped_column(default=True)
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer)
|
||||||
|
total: Mapped[Decimal] = mapped_column(Numeric(10, 2))
|
||||||
|
|
||||||
|
# Define a regular view (computed on every query)
|
||||||
|
class ActiveUsers(ViewBase, Base):
|
||||||
|
__tablename__ = "active_users"
|
||||||
|
__select__ = select(User.id, User.name).where(User.is_active == True)
|
||||||
|
|
||||||
|
# Define a materialized view (cached results, needs refresh)
|
||||||
|
class UserStats(MaterializedViewBase, Base):
|
||||||
|
__tablename__ = "user_stats"
|
||||||
|
__select__ = select(
|
||||||
|
User.id.label("user_id"),
|
||||||
|
User.name,
|
||||||
|
func.count(Order.id).label("order_count"),
|
||||||
|
func.coalesce(func.sum(Order.total), 0).label("total_spent"),
|
||||||
|
).select_from(User.__table__.outerjoin(Order.__table__, User.id == Order.user_id)
|
||||||
|
).group_by(User.id, User.name)
|
||||||
|
|
||||||
|
# Create everything (tables + views)
|
||||||
|
engine = create_engine("postgresql://user:pass@localhost/mydb")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Query regular view (always shows current data)
|
||||||
|
result = conn.execute(select(ActiveUsers.as_table())).fetchall()
|
||||||
|
for row in result:
|
||||||
|
print(f"{row.name}")
|
||||||
|
|
||||||
|
# Query materialized view (shows cached data)
|
||||||
|
stats = conn.execute(select(UserStats.as_table())).fetchall()
|
||||||
|
for stat in stats:
|
||||||
|
print(f"{stat.name}: {stat.order_count} orders, ${stat.total_spent}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh Materialized Views
|
||||||
|
|
||||||
|
Materialized views store cached results - refresh them when data changes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with engine.begin() as conn:
|
||||||
|
UserStats.refresh(conn)
|
||||||
|
|
||||||
|
# Concurrent refresh (allows reads during refresh, requires unique index)
|
||||||
|
UserStats.refresh(conn, concurrently=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Refresh on Data Changes
|
||||||
|
|
||||||
|
Automatically refresh materialized views when underlying data changes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Enable auto-refresh when Order table changes
|
||||||
|
UserStats.auto_refresh_on(Session, Order.__table__)
|
||||||
|
|
||||||
|
# Now commits automatically refresh the materialized view
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add(Order(user_id=1, total=Decimal("100.00")))
|
||||||
|
session.commit() # UserStats is automatically refreshed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
<div class="grid cards" markdown>
|
||||||
|
|
||||||
|
- :material-book-open-variant:{ .lg .middle } **Views**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Learn about regular views in depth
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Views](guide/views.md)
|
||||||
|
|
||||||
|
- :material-database-refresh:{ .lg .middle } **Materialized Views**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Caching, refreshing, and auto-refresh
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Materialized views](guide/materialized-views.md)
|
||||||
|
|
||||||
|
- :material-source-branch:{ .lg .middle } **Alembic Migrations**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Database migrations with autogenerate
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: Alembic guide](guide/alembic.md)
|
||||||
|
|
||||||
|
- :material-api:{ .lg .middle } **API Reference**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Complete API documentation
|
||||||
|
|
||||||
|
[:octicons-arrow-right-24: API reference](api/views/view.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
122
mkdocs.yml
Normal file
122
mkdocs.yml
Normal 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
79
pyproject.toml
Normal 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",
|
||||||
|
]
|
||||||
87
src/sqlalchemy_pgview/__init__.py
Normal file
87
src/sqlalchemy_pgview/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
42
src/sqlalchemy_pgview/alembic/__init__.py
Normal file
42
src/sqlalchemy_pgview/alembic/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
215
src/sqlalchemy_pgview/alembic/autogenerate.py
Normal file
215
src/sqlalchemy_pgview/alembic/autogenerate.py
Normal 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)})"
|
||||||
349
src/sqlalchemy_pgview/alembic/ops.py
Normal file
349
src/sqlalchemy_pgview/alembic/ops.py
Normal 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
|
||||||
305
src/sqlalchemy_pgview/ddl.py
Normal file
305
src/sqlalchemy_pgview/ddl.py
Normal 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
|
||||||
298
src/sqlalchemy_pgview/declarative.py
Normal file
298
src/sqlalchemy_pgview/declarative.py
Normal 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)
|
||||||
274
src/sqlalchemy_pgview/dependencies.py
Normal file
274
src/sqlalchemy_pgview/dependencies.py
Normal 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()
|
||||||
|
]
|
||||||
0
src/sqlalchemy_pgview/py.typed
Normal file
0
src/sqlalchemy_pgview/py.typed
Normal file
8
src/sqlalchemy_pgview/util.py
Normal file
8
src/sqlalchemy_pgview/util.py
Normal 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
|
||||||
407
src/sqlalchemy_pgview/view.py
Normal file
407
src/sqlalchemy_pgview/view.py
Normal 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
59
test.py
Normal 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for sqlalchemy-pgview."""
|
||||||
270
tests/conftest.py
Normal file
270
tests/conftest.py
Normal 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
1061
tests/test_alembic.py
Normal file
File diff suppressed because it is too large
Load Diff
396
tests/test_async.py
Normal file
396
tests/test_async.py
Normal 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
369
tests/test_auto_refresh.py
Normal 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
182
tests/test_ddl.py
Normal 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
395
tests/test_declarative.py
Normal 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
327
tests/test_dependencies.py
Normal 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))
|
||||||
290
tests/test_metadata_integration.py
Normal file
290
tests/test_metadata_integration.py
Normal 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
448
tests/test_relationships.py
Normal 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
113
tests/test_view.py
Normal 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
341
tests/test_view_updates.py
Normal 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))
|
||||||
Reference in New Issue
Block a user