mirror of
https://github.com/d3vyce/sqlalchemy-pgview.git
synced 2026-03-01 19:50:46 +01:00
Initial commit
This commit is contained in:
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