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:
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