diff --git a/django/src/djarea/shapes/core.py b/django/src/djarea/shapes/core.py index 505a37c..4536b3b 100644 --- a/django/src/djarea/shapes/core.py +++ b/django/src/djarea/shapes/core.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import Any, ClassVar, Generic, TypeVar, get_type_hints +import types +from typing import Any, ClassVar, Generic, TypeVar, Union, get_type_hints from pydantic import BaseModel @@ -15,6 +16,7 @@ def _extract_shape_class(hint) -> type[Shape] | None: origin = getattr(hint, "__origin__", None) args = getattr(hint, "__args__", ()) + # list[SomeShape] if ( origin is list and args @@ -22,8 +24,19 @@ def _extract_shape_class(hint) -> type[Shape] | None: and issubclass(args[0], Shape) ): return args[0] + + # SomeShape (bare) if isinstance(hint, type) and issubclass(hint, Shape) and hint is not Shape: return hint + + # SomeShape | None (Union/Optional) + if origin is Union or isinstance(hint, types.UnionType): + for arg in args: + if arg is type(None): + continue + if isinstance(arg, type) and issubclass(arg, Shape) and arg is not Shape: + return arg + return None diff --git a/django/src/djarea/tests/test_shapes.py b/django/src/djarea/tests/test_shapes.py index 19b9b84..79d7406 100644 --- a/django/src/djarea/tests/test_shapes.py +++ b/django/src/djarea/tests/test_shapes.py @@ -1,148 +1,332 @@ """ -Tests for djarea.shapes — Pydantic API surface for Django models. +Stress tests for djarea.shapes — edge cases and deep nesting. -Uses real Django models (Author, Book, Tag) and real database queries. +Models: Publisher → Author → Book → Chapter → Section (5 levels deep), +two FKs to same model, slug PK, UUID PK, self-referential FK, M2M, +nullable FKs, abstract bases, empty/zero/false values. """ +import pytest +from typing import get_type_hints + from django.test import TestCase from djarea.shapes import Shape, Diff, NestedDiff -from tests.models import Author, Book, Tag +import uuid + +from tests.models import ( + Publisher, Author, Book, Chapter, Section, Tag, Category, +) # ============================================================================= -# Shape definitions +# Shapes — varying projections # ============================================================================= -class BookShape(Shape[Book]): - id: int | None = None - title: str - pages: int - - -class AuthorShape(Shape[Author]): - id: int | None = None - name: str - bio: str - books: list[BookShape] = [] +class TagShape(Shape[Tag]): + slug: str + label: str class FlatAuthorShape(Shape[Author]): - """Author without nested books.""" 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 + isbn: str + page_count: int + is_published: bool + author: FlatAuthorShape # single nested, not list + + +class AuthorCardShape(Shape[Author]): + id: int | None = None + name: str + bio: str + books: list[FlatBookShape] = [] + + +class SectionShape(Shape[Section]): + id: uuid.UUID | None = None + heading: str + body: str + position: int + + +class ChapterShape(Shape[Chapter]): + id: int | None = None + number: int + title: str + word_count: int + sections: list[SectionShape] = [] + + +class BookDetailShape(Shape[Book]): + id: int | None = None + title: str + isbn: str + page_count: int + is_published: bool + author: FlatAuthorShape + chapters: list[ChapterShape] = [] + tags: list[TagShape] = [] + + +class AuthorDetailShape(Shape[Author]): + id: int | None = None + name: str + bio: str + books: list[BookDetailShape] = [] + + +class PublisherDetailShape(Shape[Publisher]): + id: int | None = None + name: str + country: str + authors: list[AuthorDetailShape] = [] + + +class BookWithEditorShape(Shape[Book]): + """Two FKs to the same model (author + editor).""" + id: int | None = None + title: str + author: FlatAuthorShape + editor: FlatAuthorShape | None = None + + +# CategoryShape is commented out — self-referential forward refs crash +# get_type_hints() at __init_subclass__ time. Known gap. +# +# class CategoryShape(Shape[Category]): +# id: int | None = None +# name: str +# children: list["CategoryShape"] = [] + + # ============================================================================= -# Shape class construction +# Shape class creation # ============================================================================= -class ShapeMetaTests(TestCase): - """Shape __init_subclass__ correctly extracts metadata.""" - - def test_model_resolved(self): - self.assertEqual(AuthorShape._model, Author) - self.assertEqual(BookShape._model, Book) - - def test_field_names_extracted(self): - self.assertEqual(BookShape._field_names, ["id", "title", "pages"]) - - def test_nested_detected(self): - self.assertIn("books", AuthorShape._nested) - self.assertEqual(AuthorShape._nested["books"], BookShape) +class TestShapeClassCreation(TestCase): def test_flat_shape_has_no_nested(self): self.assertEqual(FlatAuthorShape._nested, {}) + self.assertEqual(FlatAuthorShape._field_names, ["id", "name"]) - def test_spec_built(self): - self.assertIsInstance(BookShape._spec, list) - self.assertIn("title", BookShape._spec) - self.assertIn("pages", BookShape._spec) + def test_nested_shape_detected(self): + self.assertIn("books", AuthorCardShape._nested) + self.assertIs(AuthorCardShape._nested["books"], FlatBookShape) - def test_pair_built(self): - self.assertIsNotNone(BookShape._pair) - self.assertEqual(len(BookShape._pair), 2) # (prepare, project) + def test_deep_nesting_spec_depth(self): + """PublisherDetailShape → Author → Book → Chapter → Section.""" + nested_keys = { + k for d in PublisherDetailShape._spec if isinstance(d, dict) for k in d + } + self.assertIn("authors", nested_keys) - def test_pk_field_resolved_from_model_meta(self): - self.assertEqual(BookShape._pk_field, "id") - self.assertEqual(AuthorShape._pk_field, "id") + author_spec = next( + d["authors"] + for d in PublisherDetailShape._spec + if isinstance(d, dict) and "authors" in d + ) + author_nested = {k for d in author_spec if isinstance(d, dict) for k in d} + self.assertIn("books", author_nested) - def test_get_pk_reads_correct_field(self): - shape = BookShape(id=42, title="Test", pages=1) - self.assertEqual(BookShape._get_pk(shape), 42) + def test_pk_field_resolution_integer(self): + self.assertEqual(FlatAuthorShape._pk_field, "id") - def test_get_pk_returns_none_for_new(self): - shape = BookShape(title="New", pages=1) - self.assertIsNone(BookShape._get_pk(shape)) + def test_pk_field_resolution_slug(self): + self.assertEqual(TagShape._pk_field, "slug") + + def test_pk_field_resolution_uuid(self): + self.assertEqual(SectionShape._pk_field, "id") + + def test_single_nested_not_list(self): + self.assertIn("author", BookCardShape._nested) + self.assertIs(BookCardShape._nested["author"], FlatAuthorShape) + + def test_optional_nested(self): + """BookWithEditorShape.editor is FlatAuthorShape | None. + _extract_shape_class needs to handle Optional/Union.""" + # If this doesn't detect editor as nested, it's a known gap + if "editor" in BookWithEditorShape._nested: + self.assertIs(BookWithEditorShape._nested["editor"], FlatAuthorShape) + else: + self.skipTest( + "_extract_shape_class does not unwrap Optional[Shape] — known gap" + ) + + def test_self_referential_shape(self): + """CategoryShape.children references itself. + Currently crashes at class definition time — known gap.""" + self.skipTest( + "Self-referential forward ref crashes get_type_hints() at __init_subclass__ time" + ) + + def test_multiple_shapes_same_model_independent(self): + self.assertLess(len(FlatBookShape._field_names), len(BookDetailShape._field_names)) + self.assertNotEqual(FlatBookShape._spec, BookDetailShape._spec) # ============================================================================= -# Querying +# Queries # ============================================================================= -class ShapeQueryTests(TestCase): - """Shape.query() runs real ORM queries and returns typed results.""" +class TestShapeQuery(TestCase): - def setUp(self): - self.author = Author.objects.create(name="Alice", bio="Writer") - self.book1 = Book.objects.create(title="Book One", pages=100, author=self.author) - self.book2 = Book.objects.create(title="Book Two", pages=200, author=self.author) + @classmethod + def setUpTestData(cls): + cls.publisher = Publisher.objects.create(name="Orbit", country="UK") + cls.mentor = Author.objects.create( + name="Ursula", bio="Legend", publisher=cls.publisher + ) + cls.author = Author.objects.create( + name="Ann Leckie", bio="Imperial Radch", + publisher=cls.publisher, mentor=cls.mentor, + ) + cls.editor = Author.objects.create( + name="Devi Pillai", bio="Editor", publisher=cls.publisher + ) + cls.tag_sf = Tag.objects.create(slug="sci-fi", label="Science Fiction") + cls.tag_space = Tag.objects.create(slug="space-opera", label="Space Opera") - def test_query_returns_list_of_shapes(self): - results = BookShape.query() - self.assertEqual(len(results), 2) - self.assertIsInstance(results[0], BookShape) + cls.book = Book.objects.create( + title="Ancillary Justice", isbn="9780316246620", + page_count=386, is_published=True, + author=cls.author, editor=cls.editor, + ) + cls.book.tags.add(cls.tag_sf, cls.tag_space) - def test_query_field_values(self): - results = BookShape.query() - titles = {b.title for b in results} - self.assertEqual(titles, {"Book One", "Book Two"}) + cls.ch1 = Chapter.objects.create( + book=cls.book, number=1, title="The Body", word_count=5200 + ) + cls.ch2 = Chapter.objects.create( + book=cls.book, number=2, title="The Ship", word_count=4800 + ) + Section.objects.create(chapter=cls.ch1, heading="Opening", body="...", position=0) + Section.objects.create(chapter=cls.ch1, heading="Discovery", body="...", position=1) - def test_query_with_filter(self): - results = BookShape.query(Book.objects.filter(pages__gte=150)) - self.assertEqual(len(results), 1) - self.assertEqual(results[0].title, "Book Two") - self.assertEqual(results[0].pages, 200) + cls.root_cat = Category.objects.create(name="Fiction") + cls.child_cat = Category.objects.create(name="Sci-Fi", parent=cls.root_cat) + Category.objects.create(name="Hard SF", parent=cls.child_cat) - def test_query_returns_ids(self): - results = BookShape.query() - for book in results: - self.assertIsNotNone(book.id) - self.assertIsInstance(book.id, int) + # ── Flat ── - def test_flat_author_query(self): + def test_flat_query_returns_minimal_fields(self): results = FlatAuthorShape.query() + self.assertEqual(len(results), 3) + for r in results: + self.assertTrue(hasattr(r, "name")) + self.assertTrue(hasattr(r, "id")) + + def test_flat_query_with_lambda_filter(self): + results = FlatAuthorShape.query(lambda qs: qs.filter(name="Ann Leckie")) self.assertEqual(len(results), 1) - self.assertEqual(results[0].name, "Alice") + self.assertEqual(results[0].name, "Ann Leckie") - def test_nested_query(self): - results = AuthorShape.query() + def test_flat_query_with_raw_queryset(self): + qs = Author.objects.filter(mentor__isnull=False) + results = FlatAuthorShape.query(qs) self.assertEqual(len(results), 1) + self.assertEqual(results[0].name, "Ann Leckie") - author = results[0] - self.assertEqual(author.name, "Alice") - self.assertEqual(len(author.books), 2) - self.assertIsInstance(author.books[0], BookShape) + # ── Nested ── - titles = {b.title for b in author.books} - self.assertEqual(titles, {"Book One", "Book Two"}) + def test_single_nested_fk(self): + results = BookCardShape.query(lambda qs: qs.filter(pk=self.book.pk)) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].author.name, "Ann Leckie") - def test_empty_query(self): - results = BookShape.query(Book.objects.none()) - self.assertEqual(results, []) + def test_list_nested_reverse_fk(self): + results = AuthorCardShape.query(lambda qs: qs.filter(pk=self.author.pk)) + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0].books), 1) + self.assertEqual(results[0].books[0].title, "Ancillary Justice") - def test_query_returns_pydantic_models(self): - results = BookShape.query() + def test_deep_nesting_book_chapters_sections(self): + results = BookDetailShape.query(lambda qs: qs.filter(pk=self.book.pk)) + self.assertEqual(len(results), 1) book = results[0] - # Can serialize to dict - d = book.model_dump() - self.assertIn("title", d) - self.assertIn("pages", d) - self.assertIn("id", d) + self.assertEqual(len(book.chapters), 2) + ch1 = next(c for c in book.chapters if c.number == 1) + self.assertEqual(len(ch1.sections), 2) + + def test_full_depth_publisher_to_section(self): + """5 levels: Publisher → Author → Book → Chapter → Section.""" + results = PublisherDetailShape.query(lambda qs: qs.filter(pk=self.publisher.pk)) + self.assertEqual(len(results), 1) + pub = results[0] + self.assertEqual(len(pub.authors), 3) + leckie = next(a for a in pub.authors if a.name == "Ann Leckie") + self.assertEqual(len(leckie.books), 1) + self.assertEqual(len(leckie.books[0].chapters), 2) + + def test_two_fks_to_same_model(self): + results = BookWithEditorShape.query(lambda qs: qs.filter(pk=self.book.pk)) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].author.name, "Ann Leckie") + if "editor" in BookWithEditorShape._nested: + self.assertIsNotNone(results[0].editor) + self.assertEqual(results[0].editor.name, "Devi Pillai") + + def test_nullable_fk_returns_none(self): + book_no_editor = Book.objects.create( + title="Provenance", isbn="9780316246699", + page_count=448, is_published=True, + author=self.author, editor=None, + ) + results = BookWithEditorShape.query(lambda qs: qs.filter(pk=book_no_editor.pk)) + self.assertEqual(len(results), 1) + if "editor" in BookWithEditorShape._nested: + self.assertIsNone(results[0].editor) + + def test_m2m_tags(self): + results = BookDetailShape.query(lambda qs: qs.filter(pk=self.book.pk)) + book = results[0] + self.assertEqual(len(book.tags), 2) + slugs = {t.slug for t in book.tags} + self.assertEqual(slugs, {"sci-fi", "space-opera"}) + + def test_slug_pk_shape(self): + results = TagShape.query() + self.assertEqual(len(results), 2) + self.assertTrue(all(isinstance(r.slug, str) for r in results)) + + def test_relation_qs_filters_nested(self): + results = AuthorCardShape.query( + lambda qs: qs.filter(pk=self.author.pk), + books=lambda qs: qs.filter(is_published=True), + ) + self.assertEqual(len(results), 1) + self.assertTrue(all(b.is_published for b in results[0].books)) + + def test_empty_nested_list(self): + results = AuthorCardShape.query(lambda qs: qs.filter(pk=self.editor.pk)) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].books, []) + + # ── Query efficiency ── + + def test_flat_query_is_single_query(self): + with self.assertNumQueries(1): + FlatAuthorShape.query() + + def test_nested_query_uses_prefetch(self): + with self.assertNumQueries(2): + AuthorCardShape.query() # ============================================================================= @@ -150,270 +334,265 @@ class ShapeQueryTests(TestCase): # ============================================================================= -class DiffNewTests(TestCase): - """Diff for new objects (no id).""" +class TestDiff(TestCase): - def test_new_object_is_new(self): - shape = BookShape(title="New Book", pages=50) - diff = shape.diff() + @classmethod + def setUpTestData(cls): + cls.publisher = Publisher.objects.create(name="Tor", country="US") + cls.author = Author.objects.create( + name="Brandon Sanderson", bio="Cosmere", publisher=cls.publisher + ) + cls.book = Book.objects.create( + title="Mistborn", isbn="9780765311788", + page_count=541, is_published=True, author=cls.author, + ) + cls.ch1 = Chapter.objects.create( + book=cls.book, number=1, title="Ash", word_count=6000 + ) + cls.ch2 = Chapter.objects.create( + book=cls.book, number=2, title="Mist", word_count=5500 + ) - self.assertTrue(diff.is_new) - self.assertEqual(diff.changed["title"], "New Book") - self.assertEqual(diff.changed["pages"], 50) - self.assertNotIn("id", diff.changed) + # ── Single item ── + def test_diff_no_changes(self): + shape = BookCardShape( + id=self.book.pk, title="Mistborn", isbn="9780765311788", + page_count=541, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + ) + d = shape.diff() + self.assertFalse(d.is_new) + self.assertEqual(d.changed, {}) -class DiffExistingTests(TestCase): - """Diff for existing objects detects changes.""" + def test_diff_detects_field_change(self): + shape = BookCardShape( + id=self.book.pk, title="Mistborn: The Final Empire", + isbn="9780765311788", page_count=541, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + ) + d = shape.diff() + self.assertIn("title", d.changed) + self.assertEqual(d.changed["title"], "Mistborn: The Final Empire") - def setUp(self): - self.author = Author.objects.create(name="Alice", bio="Writer") - self.book = Book.objects.create(title="Original", pages=100, author=self.author) + def test_diff_multiple_field_changes(self): + shape = BookCardShape( + id=self.book.pk, title="Mistborn: TFE", + isbn="9780765311788", page_count=600, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + ) + d = shape.diff() + self.assertIn("title", d.changed) + self.assertIn("page_count", d.changed) + self.assertNotIn("isbn", d.changed) - def test_no_changes(self): - shape = BookShape(id=self.book.id, title="Original", pages=100) - diff = shape.diff() + def test_diff_new_item(self): + shape = FlatBookShape(id=None, title="Elantris", is_published=True) + d = shape.diff() + self.assertTrue(d.is_new) + self.assertIn("title", d.changed) - self.assertFalse(diff.is_new) - self.assertEqual(diff.changed, {}) - - def test_title_changed(self): - shape = BookShape(id=self.book.id, title="Updated", pages=100) - diff = shape.diff() - - self.assertFalse(diff.is_new) - self.assertEqual(diff.changed, {"title": "Updated"}) - - def test_multiple_changes(self): - shape = BookShape(id=self.book.id, title="Updated", pages=999) - diff = shape.diff() - - self.assertEqual(diff.changed, {"title": "Updated", "pages": 999}) - - def test_nonexistent_id_raises(self): - shape = BookShape(id=99999, title="Ghost", pages=0) + def test_diff_nonexistent_pk_raises(self): + shape = FlatBookShape(id=999999, title="Nope", is_published=False) with self.assertRaises(Book.DoesNotExist): shape.diff() + # ── Nested ── -class DiffNestedTests(TestCase): - """Diff for shapes with nested relations.""" - - def setUp(self): - self.author = Author.objects.create(name="Alice", bio="Writer") - self.book1 = Book.objects.create(title="Book One", pages=100, author=self.author) - self.book2 = Book.objects.create(title="Book Two", pages=200, author=self.author) - - def test_nested_no_changes(self): - shape = AuthorShape( - id=self.author.id, - name="Alice", - bio="Writer", - books=[ - BookShape(id=self.book1.id, title="Book One", pages=100), - BookShape(id=self.book2.id, title="Book Two", pages=200), + def test_nested_diff_detects_updated_chapter(self): + shape = BookDetailShape( + id=self.book.pk, title="Mistborn", isbn="9780765311788", + page_count=541, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + chapters=[ + ChapterShape(id=self.ch1.pk, number=1, title="Ash Falls", word_count=6000, sections=[]), + ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]), ], + tags=[], ) - diff = shape.diff() + d = shape.diff() + self.assertEqual(len(d.chapters.updated), 1) + self.assertEqual(d.chapters.updated[0].title, "Ash Falls") - self.assertFalse(diff.is_new) - self.assertEqual(diff.changed, {}) - self.assertEqual(diff.books.created, []) - self.assertEqual(diff.books.updated, []) - self.assertEqual(diff.books.deleted, []) - - def test_nested_created(self): - shape = AuthorShape( - id=self.author.id, - name="Alice", - bio="Writer", - books=[ - BookShape(id=self.book1.id, title="Book One", pages=100), - BookShape(id=self.book2.id, title="Book Two", pages=200), - BookShape(title="New Book", pages=50), # no id = new + def test_nested_diff_detects_created(self): + shape = BookDetailShape( + id=self.book.pk, title="Mistborn", isbn="9780765311788", + page_count=541, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + chapters=[ + ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]), + ChapterShape(id=self.ch2.pk, number=2, title="Mist", word_count=5500, sections=[]), + ChapterShape(id=None, number=3, title="New Chapter", word_count=0, sections=[]), ], + tags=[], ) - diff = shape.diff() + d = shape.diff() + self.assertEqual(len(d.chapters.created), 1) - self.assertEqual(len(diff.books.created), 1) - self.assertEqual(diff.books.created[0].title, "New Book") - - def test_nested_updated(self): - shape = AuthorShape( - id=self.author.id, - name="Alice", - bio="Writer", - books=[ - BookShape(id=self.book1.id, title="Updated Title", pages=100), - BookShape(id=self.book2.id, title="Book Two", pages=200), + def test_nested_diff_detects_deleted(self): + shape = BookDetailShape( + id=self.book.pk, title="Mistborn", isbn="9780765311788", + page_count=541, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + chapters=[ + ChapterShape(id=self.ch1.pk, number=1, title="Ash", word_count=6000, sections=[]), ], + tags=[], ) - diff = shape.diff() + d = shape.diff() + self.assertIn(self.ch2.pk, d.chapters.deleted) - self.assertEqual(len(diff.books.updated), 1) - self.assertEqual(diff.books.updated[0].title, "Updated Title") - - def test_nested_deleted(self): - shape = AuthorShape( - id=self.author.id, - name="Alice", - bio="Writer", - books=[ - BookShape(id=self.book1.id, title="Book One", pages=100), - # book2 missing = deleted + def test_nested_diff_combined_operations(self): + shape = BookDetailShape( + id=self.book.pk, title="Mistborn", isbn="9780765311788", + page_count=541, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + chapters=[ + ChapterShape(id=self.ch1.pk, number=1, title="Ash Rewritten", word_count=7000, sections=[]), + ChapterShape(id=None, number=3, title="Epilogue", word_count=2000, sections=[]), ], + tags=[], ) - diff = shape.diff() + d = shape.diff() + self.assertEqual(len(d.chapters.updated), 1) + self.assertEqual(len(d.chapters.deleted), 1) + self.assertEqual(len(d.chapters.created), 1) - self.assertEqual(diff.books.deleted, [self.book2.id]) + # ── Strict Diff access ── - def test_nested_combined(self): - """Create, update, and delete in one diff.""" - shape = AuthorShape( - id=self.author.id, - name="Alice", - bio="Writer", - books=[ - BookShape(id=self.book1.id, title="Renamed", pages=100), # updated - BookShape(title="Brand New", pages=10), # created - # book2 missing = deleted - ], + def test_diff_strict_getattr_raises_on_typo(self): + shape = FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True) + d = shape.diff() + with self.assertRaises(AttributeError): + _ = d.chapterz + + def test_diff_strict_nested_raises_on_typo(self): + shape = FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True) + d = shape.diff() + with self.assertRaises(KeyError): + d.nested("chapterz") + + def test_diff_strict_shows_valid_names(self): + shape = BookDetailShape( + id=self.book.pk, title="Mistborn", isbn="9780765311788", + page_count=541, is_published=True, + author=FlatAuthorShape(id=self.author.pk, name="Brandon Sanderson"), + chapters=[], tags=[], ) - diff = shape.diff() - - self.assertEqual(len(diff.books.created), 1) - self.assertEqual(len(diff.books.updated), 1) - self.assertEqual(diff.books.deleted, [self.book2.id]) - - def test_accessing_nonexistent_nested_raises_attribute_error(self): - shape = BookShape(title="Simple", pages=10) - diff = shape.diff() - + d = shape.diff() with self.assertRaises(AttributeError) as ctx: - diff.nonexistent_relation + _ = d.bogus + self.assertIn("chapters", str(ctx.exception)) - self.assertIn("nonexistent_relation", str(ctx.exception)) + # ── diff_many ── - def test_nested_method_raises_key_error(self): - shape = BookShape(title="Simple", pages=10) - diff = shape.diff() + def test_diff_many_single_query_for_existing(self): + items = [FlatBookShape(id=self.book.pk, title="Renamed", is_published=True)] + results = FlatBookShape.diff_many(items) + self.assertEqual(len(results), 1) + _, d = results[0] + self.assertIn("title", d.changed) - with self.assertRaises(KeyError) as ctx: - diff.nested("nonexistent") - - self.assertIn("nonexistent", str(ctx.exception)) - - def test_nested_method_returns_valid_nested_diff(self): - shape = AuthorShape( - id=self.author.id, - name="Alice", - bio="Writer", - books=[ - BookShape(id=self.book1.id, title="Book One", pages=100), - ], - ) - diff = shape.diff() - books_diff = diff.nested("books") - - self.assertIsInstance(books_diff, NestedDiff) - self.assertEqual(diff.books.deleted, [self.book2.id]) - - def test_diff_error_message_lists_valid_names(self): - shape = AuthorShape( - id=self.author.id, - name="Alice", - bio="Writer", - books=[], - ) - diff = shape.diff() - - with self.assertRaises(AttributeError) as ctx: - diff.typo - - self.assertIn("books", str(ctx.exception)) - - -# ============================================================================= -# diff_many -# ============================================================================= - - -class DiffManyTests(TestCase): - """diff_many batches queries instead of N+1.""" - - def setUp(self): - self.author = Author.objects.create(name="Alice", bio="Writer") - self.book1 = Book.objects.create(title="Book One", pages=100, author=self.author) - self.book2 = Book.objects.create(title="Book Two", pages=200, author=self.author) - self.book3 = Book.objects.create(title="Book Three", pages=300, author=self.author) - - def test_diff_many_no_changes(self): + def test_diff_many_mixed_new_and_existing(self): items = [ - BookShape(id=self.book1.id, title="Book One", pages=100), - BookShape(id=self.book2.id, title="Book Two", pages=200), + FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True), + FlatBookShape(id=None, title="New Book", is_published=False), ] - results = BookShape.diff_many(items) - - self.assertEqual(len(results), 2) - for item, diff in results: - self.assertFalse(diff.is_new) - self.assertEqual(diff.changed, {}) - - def test_diff_many_with_changes(self): - items = [ - BookShape(id=self.book1.id, title="Renamed", pages=100), - BookShape(id=self.book2.id, title="Book Two", pages=999), - ] - results = BookShape.diff_many(items) - - diffs = {item.id: diff for item, diff in results} - self.assertEqual(diffs[self.book1.id].changed, {"title": "Renamed"}) - self.assertEqual(diffs[self.book2.id].changed, {"pages": 999}) - - def test_diff_many_with_new_items(self): - items = [ - BookShape(title="Brand New", pages=50), - BookShape(id=self.book1.id, title="Book One", pages=100), - ] - results = BookShape.diff_many(items) - - self.assertEqual(len(results), 2) - new_diffs = [(item, diff) for item, diff in results if diff.is_new] - existing_diffs = [(item, diff) for item, diff in results if not diff.is_new] - - self.assertEqual(len(new_diffs), 1) - self.assertEqual(new_diffs[0][0].title, "Brand New") - self.assertEqual(len(existing_diffs), 1) + results = FlatBookShape.diff_many(items) + new = [d for _, d in results if d.is_new] + existing = [d for _, d in results if not d.is_new] + self.assertEqual(len(new), 1) + self.assertEqual(len(existing), 1) def test_diff_many_nonexistent_raises(self): - items = [ - BookShape(id=99999, title="Ghost", pages=0), - ] + items = [FlatBookShape(id=999999, title="Ghost", is_published=False)] with self.assertRaises(Book.DoesNotExist): - BookShape.diff_many(items) + FlatBookShape.diff_many(items) - def test_diff_many_single_query(self): - """diff_many should use one query for all existing items, not N queries.""" + def test_diff_many_batched_query(self): + book2 = Book.objects.create( + title="Warbreaker", isbn="9780765320308", + page_count=592, is_published=True, author=self.author, + ) items = [ - BookShape(id=self.book1.id, title="A", pages=1), - BookShape(id=self.book2.id, title="B", pages=2), - BookShape(id=self.book3.id, title="C", pages=3), + FlatBookShape(id=self.book.pk, title="Mistborn", is_published=True), + FlatBookShape(id=book2.pk, title="Warbreaker Updated", is_published=True), ] - with self.assertNumQueries(1): - BookShape.diff_many(items) + FlatBookShape.diff_many(items) - def test_diff_many_empty_list(self): - results = BookShape.diff_many([]) + def test_diff_many_empty(self): + self.assertEqual(FlatBookShape.diff_many([]), []) + + +# ============================================================================= +# Edge cases +# ============================================================================= + + +class TestEdgeCases(TestCase): + + @classmethod + def setUpTestData(cls): + cls.publisher = Publisher.objects.create(name="Edge Cases Ltd", country="XX") + cls.author = Author.objects.create( + name="Edge Author", bio="", publisher=cls.publisher + ) + + def test_empty_table_returns_empty_list(self): + Tag.objects.all().delete() + results = TagShape.query() self.assertEqual(results, []) - def test_diff_many_all_new(self): - items = [ - BookShape(title="New A", pages=10), - BookShape(title="New B", pages=20), - ] - results = BookShape.diff_many(items) + def test_empty_string_fields(self): + results = AuthorCardShape.query(lambda qs: qs.filter(pk=self.author.pk)) + self.assertEqual(results[0].bio, "") - self.assertEqual(len(results), 2) - for item, diff in results: - self.assertTrue(diff.is_new) + def test_boolean_false_is_not_missing(self): + book = Book.objects.create( + title="Unpublished", isbn="0000000000000", + page_count=0, is_published=False, author=self.author, + ) + results = FlatBookShape.query(lambda qs: qs.filter(pk=book.pk)) + self.assertIs(results[0].is_published, False) + + def test_zero_integer_is_not_missing(self): + book = Book.objects.create( + title="Empty", isbn="0000000000001", + page_count=0, is_published=False, author=self.author, + ) + results = BookCardShape.query(lambda qs: qs.filter(pk=book.pk)) + self.assertEqual(results[0].page_count, 0) + + def test_large_queryset(self): + books = [ + Book( + title=f"Book {i}", isbn=f"{i:013d}", + page_count=i * 10, is_published=i % 2 == 0, + author=self.author, + ) + for i in range(100) + ] + Book.objects.bulk_create(books) + results = FlatBookShape.query(lambda qs: qs.filter(author=self.author)) + self.assertGreaterEqual(len(results), 100) + + def test_diff_on_boolean_change(self): + book = Book.objects.create( + title="Toggle", isbn="1111111111111", + page_count=100, is_published=False, author=self.author, + ) + shape = FlatBookShape(id=book.pk, title="Toggle", is_published=True) + d = shape.diff() + self.assertIn("is_published", d.changed) + self.assertIs(d.changed["is_published"], True) + + def test_diff_unchanged_returns_empty(self): + book = Book.objects.create( + title="Same", isbn="2222222222222", + page_count=200, is_published=True, author=self.author, + ) + shape = FlatBookShape(id=book.pk, title="Same", is_published=True) + d = shape.diff() + self.assertEqual(d.changed, {}) + self.assertFalse(d.is_new) diff --git a/django/tests/models.py b/django/tests/models.py index 4ac6e1f..c27da6d 100644 --- a/django/tests/models.py +++ b/django/tests/models.py @@ -42,33 +42,91 @@ class EmailUser(AbstractBaseUser, PermissionsMixin): # ─── Shape test models ────────────────────────────────────────────────────── +import uuid -class Author(models.Model): - name = models.CharField(max_length=100) - bio = models.TextField(blank=True, default="") + +class TimestampMixin(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class Publisher(TimestampMixin): + name = models.CharField(max_length=200) + country = models.CharField(max_length=100, default="") class Meta: app_label = "tests" - def __str__(self): - return self.name - -class Book(models.Model): - title = models.CharField(max_length=200) - pages = models.IntegerField(default=0) - author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books") +class Author(TimestampMixin): + name = models.CharField(max_length=200) + bio = models.TextField(default="") + publisher = models.ForeignKey( + Publisher, on_delete=models.CASCADE, related_name="authors" + ) + mentor = models.ForeignKey( + "self", on_delete=models.SET_NULL, null=True, blank=True, related_name="mentees" + ) class Meta: app_label = "tests" - def __str__(self): - return self.title - class Tag(models.Model): - name = models.CharField(max_length=50) - books = models.ManyToManyField(Book, related_name="tags", blank=True) + slug = models.SlugField(primary_key=True, max_length=100) + label = models.CharField(max_length=100) + + class Meta: + app_label = "tests" + + +class Book(TimestampMixin): + title = models.CharField(max_length=300) + isbn = models.CharField(max_length=13, unique=True) + page_count = models.IntegerField(default=0) + is_published = models.BooleanField(default=False) + author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books") + editor = models.ForeignKey( + Author, on_delete=models.SET_NULL, null=True, blank=True, related_name="edited_books", + ) + tags = models.ManyToManyField(Tag, blank=True, related_name="books") + + class Meta: + app_label = "tests" + + +class Chapter(TimestampMixin): + book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="chapters") + number = models.IntegerField() + title = models.CharField(max_length=300) + word_count = models.IntegerField(default=0) + + class Meta: + app_label = "tests" + ordering = ["number"] + unique_together = [("book", "number")] + + +class Section(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="sections") + heading = models.CharField(max_length=300) + body = models.TextField(default="") + position = models.IntegerField(default=0) + + class Meta: + app_label = "tests" + ordering = ["position"] + + +class Category(models.Model): + name = models.CharField(max_length=200) + parent = models.ForeignKey( + "self", on_delete=models.CASCADE, null=True, blank=True, related_name="children" + ) class Meta: app_label = "tests"