Files
sqlalchemy-pgview/tests/test_view_updates.py
2026-02-08 10:09:48 +01:00

342 lines
12 KiB
Python

"""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))