diff --git a/django/pyproject.toml b/django/pyproject.toml index dc6a579..0a20580 100644 --- a/django/pyproject.toml +++ b/django/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.10" dependencies = [ "django>=5.0", "django-ninja>=1.0", + "django-readers>=2.0", "pydantic>=2.0", "PyJWT>=2.0", ] diff --git a/django/src/djarea/__init__.py b/django/src/djarea/__init__.py index dbbe155..ebf0901 100644 --- a/django/src/djarea/__init__.py +++ b/django/src/djarea/__init__.py @@ -89,10 +89,8 @@ from . import setup from .channels import ReactChannel from .channels import register as register_channel from .client import ComposedContext, ServerFunction, client, compose -try: - from .shapes import Shape -except ImportError: - pass # django-readers not installed + # Shape is lazy-loaded via __getattr__ because django_readers + # imports contenttypes, which can't happen during apps.populate() from .setup import ( djarea_clients, djarea_module, @@ -104,11 +102,15 @@ from .setup import ( def __getattr__(name): - """Lazy loading for urls to avoid circular imports.""" + """Lazy loading for modules that can't be imported at app load time.""" if name == "urls": from .urls import urlpatterns as djarea_patterns return djarea_patterns + if name == "Shape": + from .shapes import Shape + + return Shape raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/django/src/djarea/tests/test_shapes.py b/django/src/djarea/tests/test_shapes.py new file mode 100644 index 0000000..7e5a9a1 --- /dev/null +++ b/django/src/djarea/tests/test_shapes.py @@ -0,0 +1,285 @@ +""" +Tests for djarea.shapes — Pydantic API surface for Django models. + +Uses real Django models (Author, Book, Tag) and real database queries. +""" + +from django.test import TestCase + +from djarea.shapes import Shape, Diff, NestedDiff + +from tests.models import Author, Book, Tag + + +# ============================================================================= +# Shape definitions +# ============================================================================= + + +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 FlatAuthorShape(Shape[Author]): + """Author without nested books.""" + id: int | None = None + name: str + + +# ============================================================================= +# Shape class construction +# ============================================================================= + + +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) + + def test_flat_shape_has_no_nested(self): + self.assertEqual(FlatAuthorShape._nested, {}) + + def test_spec_built(self): + self.assertIsInstance(BookShape._spec, list) + self.assertIn("title", BookShape._spec) + self.assertIn("pages", BookShape._spec) + + def test_pair_built(self): + self.assertIsNotNone(BookShape._pair) + self.assertEqual(len(BookShape._pair), 2) # (prepare, project) + + +# ============================================================================= +# Querying +# ============================================================================= + + +class ShapeQueryTests(TestCase): + """Shape.query() runs real ORM queries and returns typed results.""" + + 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_query_returns_list_of_shapes(self): + results = BookShape.query() + self.assertEqual(len(results), 2) + self.assertIsInstance(results[0], BookShape) + + def test_query_field_values(self): + results = BookShape.query() + titles = {b.title for b in results} + self.assertEqual(titles, {"Book One", "Book Two"}) + + 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) + + def test_query_returns_ids(self): + results = BookShape.query() + for book in results: + self.assertIsNotNone(book.id) + self.assertIsInstance(book.id, int) + + def test_flat_author_query(self): + results = FlatAuthorShape.query() + self.assertEqual(len(results), 1) + self.assertEqual(results[0].name, "Alice") + + def test_nested_query(self): + results = AuthorShape.query() + self.assertEqual(len(results), 1) + + author = results[0] + self.assertEqual(author.name, "Alice") + self.assertEqual(len(author.books), 2) + self.assertIsInstance(author.books[0], BookShape) + + titles = {b.title for b in author.books} + self.assertEqual(titles, {"Book One", "Book Two"}) + + def test_empty_query(self): + results = BookShape.query(Book.objects.none()) + self.assertEqual(results, []) + + def test_query_returns_pydantic_models(self): + results = BookShape.query() + book = results[0] + # Can serialize to dict + d = book.model_dump() + self.assertIn("title", d) + self.assertIn("pages", d) + self.assertIn("id", d) + + +# ============================================================================= +# Diff +# ============================================================================= + + +class DiffNewTests(TestCase): + """Diff for new objects (no id).""" + + def test_new_object_is_new(self): + shape = BookShape(title="New Book", pages=50) + diff = shape.diff() + + self.assertTrue(diff.is_new) + self.assertEqual(diff.changed["title"], "New Book") + self.assertEqual(diff.changed["pages"], 50) + self.assertNotIn("id", diff.changed) + + +class DiffExistingTests(TestCase): + """Diff for existing objects detects changes.""" + + 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_no_changes(self): + shape = BookShape(id=self.book.id, title="Original", pages=100) + diff = shape.diff() + + 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) + with self.assertRaises(Book.DoesNotExist): + shape.diff() + + +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), + ], + ) + diff = shape.diff() + + 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 + ], + ) + diff = shape.diff() + + 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), + ], + ) + diff = shape.diff() + + 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 + ], + ) + diff = shape.diff() + + self.assertEqual(diff.books.deleted, [self.book2.id]) + + 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 + ], + ) + 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_returns_empty(self): + shape = BookShape(title="Simple", pages=10) + diff = shape.diff() + + # BookShape has no nested relations + empty = diff.nonexistent_relation + self.assertIsInstance(empty, NestedDiff) + self.assertEqual(empty.created, []) + self.assertEqual(empty.updated, []) + self.assertEqual(empty.deleted, []) diff --git a/django/tests/models.py b/django/tests/models.py index 6aaa8d2..4ac6e1f 100644 --- a/django/tests/models.py +++ b/django/tests/models.py @@ -38,3 +38,37 @@ class EmailUser(AbstractBaseUser, PermissionsMixin): class Meta: app_label = "tests" + + +# ─── Shape test models ────────────────────────────────────────────────────── + + +class Author(models.Model): + name = models.CharField(max_length=100) + bio = models.TextField(blank=True, 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 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) + + class Meta: + app_label = "tests"