Initial commit

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

1
tests/__init__.py Normal file
View File

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

270
tests/conftest.py Normal file
View File

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

1061
tests/test_alembic.py Normal file

File diff suppressed because it is too large Load Diff

396
tests/test_async.py Normal file
View File

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

369
tests/test_auto_refresh.py Normal file
View File

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

182
tests/test_ddl.py Normal file
View File

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

395
tests/test_declarative.py Normal file
View File

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

327
tests/test_dependencies.py Normal file
View File

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

View File

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

448
tests/test_relationships.py Normal file
View File

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

113
tests/test_view.py Normal file
View File

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

341
tests/test_view_updates.py Normal file
View File

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