Add shapes tests and fix django-readers as core dependency
25 tests covering Shape, Diff, and NestedDiff: - Shape metaclass: model resolution, field extraction, nested detection, spec/pair building - Query: list, filter, nested relations, empty results, Pydantic serialization - Diff (new): detects all fields as changed - Diff (existing): no changes, single field, multiple fields, nonexistent ID - Diff (nested): created, updated, deleted, combined, nonexistent relation Fixes: - django-readers moved from optional to core dependency - Shape import lazy-loaded via __getattr__ (django_readers imports contenttypes which can't happen during apps.populate()) - Added Author, Book, Tag test models Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ requires-python = ">=3.10"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=5.0",
|
"django>=5.0",
|
||||||
"django-ninja>=1.0",
|
"django-ninja>=1.0",
|
||||||
|
"django-readers>=2.0",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"PyJWT>=2.0",
|
"PyJWT>=2.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -89,10 +89,8 @@ from . import setup
|
|||||||
from .channels import ReactChannel
|
from .channels import ReactChannel
|
||||||
from .channels import register as register_channel
|
from .channels import register as register_channel
|
||||||
from .client import ComposedContext, ServerFunction, client, compose
|
from .client import ComposedContext, ServerFunction, client, compose
|
||||||
try:
|
# Shape is lazy-loaded via __getattr__ because django_readers
|
||||||
from .shapes import Shape
|
# imports contenttypes, which can't happen during apps.populate()
|
||||||
except ImportError:
|
|
||||||
pass # django-readers not installed
|
|
||||||
from .setup import (
|
from .setup import (
|
||||||
djarea_clients,
|
djarea_clients,
|
||||||
djarea_module,
|
djarea_module,
|
||||||
@@ -104,11 +102,15 @@ from .setup import (
|
|||||||
|
|
||||||
|
|
||||||
def __getattr__(name):
|
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":
|
if name == "urls":
|
||||||
from .urls import urlpatterns as djarea_patterns
|
from .urls import urlpatterns as djarea_patterns
|
||||||
|
|
||||||
return 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}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
285
django/src/djarea/tests/test_shapes.py
Normal file
285
django/src/djarea/tests/test_shapes.py
Normal file
@@ -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, [])
|
||||||
@@ -38,3 +38,37 @@ class EmailUser(AbstractBaseUser, PermissionsMixin):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
app_label = "tests"
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user