""" Shapes behavior — the genuine capability behind the `shapes` probe. Proves the SQLAlchemy binding has the same Shape declaration surface and projection/diff semantics as the Django `django-readers` binding: - `Shape[Model]` resolves the mapped model + PK from the generic arg; - scalar annotations project columns, Shape-typed annotations project relations; - `.query(session, *stmt_fns, **relation_stmt)` flat / nested / scoped; - nested loads stay flat (selectinload, not N+1); - `.diff()` / `.diff_many()` detect field changes + nested created/updated/deleted. """ from __future__ import annotations import pytest from sqlalchemy import ForeignKey, create_engine, event from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, relationship from mizan_fastapi.shapes import Diff, NestedDiff, Shape # ─── Mapped models ──────────────────────────────────────────────────────────── class Base(DeclarativeBase): pass class Publisher(Base): __tablename__ = "publisher" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] country: Mapped[str] authors: Mapped[list["Author"]] = relationship(back_populates="publisher") class Author(Base): __tablename__ = "author" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] bio: Mapped[str] = mapped_column(default="") publisher_id: Mapped[int] = mapped_column(ForeignKey("publisher.id")) publisher: Mapped[Publisher] = relationship(back_populates="authors") books: Mapped[list["Book"]] = relationship(back_populates="author") class Book(Base): __tablename__ = "book" id: Mapped[int] = mapped_column(primary_key=True) title: Mapped[str] is_published: Mapped[bool] = mapped_column(default=True) author_id: Mapped[int] = mapped_column(ForeignKey("author.id")) author: Mapped[Author] = relationship(back_populates="books") # ─── Shapes (declaration surface identical to the Django adapter) ────────────── class FlatAuthorShape(Shape[Author]): id: int | None = None name: str class FlatBookShape(Shape[Book]): id: int | None = None title: str is_published: bool class BookCardShape(Shape[Book]): id: int | None = None title: str is_published: bool author: FlatAuthorShape # single nested FK class AuthorCardShape(Shape[Author]): id: int | None = None name: str bio: str books: list[FlatBookShape] = [] # list nested reverse-FK class PublisherDetailShape(Shape[Publisher]): id: int | None = None name: str authors: list[AuthorCardShape] = [] # 3-level nesting # ─── Fixtures ────────────────────────────────────────────────────────────────── @pytest.fixture def session(): engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as s: pub = Publisher(name="Orbit", country="UK") ann = Author(name="Ann Leckie", bio="Imperial Radch", publisher=pub) devi = Author(name="Devi Pillai", bio="", publisher=pub) ann.books = [ Book(title="Ancillary Justice", is_published=True), Book(title="Provenance", is_published=False), ] s.add_all([pub, ann, devi]) s.commit() yield s # ─── Declaration ──────────────────────────────────────────────────────────────── def test_shape_resolves_model_and_pk(): assert FlatAuthorShape._model is Author assert FlatAuthorShape._pk_field == "id" def test_flat_shape_has_no_nested(): assert FlatAuthorShape._nested == {} assert FlatAuthorShape._field_names == ["id", "name"] def test_single_nested_detected(): assert BookCardShape._nested == {"author": FlatAuthorShape} def test_list_nested_detected(): assert AuthorCardShape._nested == {"books": FlatBookShape} # ─── Query ────────────────────────────────────────────────────────────────────── def test_flat_query_projects_fields(session): authors = FlatAuthorShape.query(session) assert len(authors) == 2 assert {a.name for a in authors} == {"Ann Leckie", "Devi Pillai"} def test_query_with_stmt_fn_filters(session): authors = FlatAuthorShape.query(session, lambda s: s.where(Author.name == "Ann Leckie")) assert [a.name for a in authors] == ["Ann Leckie"] def test_single_nested_fk_projected(session): books = BookCardShape.query(session, lambda s: s.where(Book.title == "Ancillary Justice")) assert len(books) == 1 assert books[0].author.name == "Ann Leckie" def test_list_nested_reverse_fk_projected(session): authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Ann Leckie")) assert len(authors) == 1 assert {b.title for b in authors[0].books} == {"Ancillary Justice", "Provenance"} def test_empty_nested_list(session): authors = AuthorCardShape.query(session, lambda s: s.where(Author.name == "Devi Pillai")) assert authors[0].books == [] def test_three_level_nesting(session): pubs = PublisherDetailShape.query(session) assert len(pubs) == 1 leckie = next(a for a in pubs[0].authors if a.name == "Ann Leckie") assert len(leckie.books) == 2 def test_relation_stmt_scopes_nested_load(session): authors = AuthorCardShape.query( session, lambda s: s.where(Author.name == "Ann Leckie"), books=lambda s: s.where(Book.is_published.is_(True)), ) assert [b.title for b in authors[0].books] == ["Ancillary Justice"] assert all(b.is_published for b in authors[0].books) def test_nested_query_stays_flat(session): """selectinload keeps the projection at O(depth) queries, not N+1.""" counter = {"n": 0} @event.listens_for(session.bind, "after_cursor_execute") def _count(*args): counter["n"] += 1 AuthorCardShape.query(session) # one query for authors + one selectin for books assert counter["n"] == 2 # ─── Diff ───────────────────────────────────────────────────────────────────── def test_diff_no_changes(session): book = session.query(Book).filter_by(title="Ancillary Justice").one() shape = FlatBookShape(id=book.id, title="Ancillary Justice", is_published=True) d = shape.diff(session) assert d.is_new is False assert d.changed == {} def test_diff_detects_field_change(session): book = session.query(Book).filter_by(title="Ancillary Justice").one() shape = FlatBookShape(id=book.id, title="Ancillary Justice (rev)", is_published=True) d = shape.diff(session) assert d.changed["title"] == "Ancillary Justice (rev)" def test_diff_new_item(session): shape = FlatBookShape(id=None, title="Elantris", is_published=True) d = shape.diff(session) assert d.is_new is True assert "title" in d.changed def test_diff_nonexistent_pk_raises(session): shape = FlatBookShape(id=999999, title="Ghost", is_published=False) with pytest.raises(LookupError): shape.diff(session) def test_nested_diff_created_updated_deleted(session): author = session.query(Author).filter_by(name="Ann Leckie").one() books = sorted(author.books, key=lambda b: b.title) # keep one (updated), drop one (deleted), add one (created) shape = AuthorCardShape( id=author.id, name="Ann Leckie", bio="Imperial Radch", books=[ FlatBookShape(id=books[0].id, title="Ancillary Justice REWRITTEN", is_published=True), FlatBookShape(id=None, title="Ancillary Sword", is_published=True), ], ) d = shape.diff(session) assert len(d.books.updated) == 1 assert len(d.books.created) == 1 assert len(d.books.deleted) == 1 def test_diff_strict_nested_access_raises_on_typo(session): author = session.query(Author).filter_by(name="Ann Leckie").one() shape = FlatAuthorShape(id=author.id, name="Ann Leckie") d = shape.diff(session) with pytest.raises(AttributeError): _ = d.bookz with pytest.raises(KeyError): d.nested("bookz") def test_diff_many_batches(session): books = session.query(Book).all() items = [FlatBookShape(id=b.id, title=b.title + "!", is_published=b.is_published) for b in books] results = FlatBookShape.diff_many(session, items) assert len(results) == len(books) assert all("title" in d.changed for _, d in results) def test_diff_many_mixed_new_and_existing(session): book = session.query(Book).first() items = [ FlatBookShape(id=book.id, title=book.title, is_published=book.is_published), FlatBookShape(id=None, title="Brand New", is_published=False), ] results = FlatBookShape.diff_many(session, items) assert sum(1 for _, d in results if d.is_new) == 1 assert sum(1 for _, d in results if not d.is_new) == 1