Django Structured JSON Field — Agent Skill
1. Project Identity
| Key | Value |
|---|---|
| Package name | django-structured-json-field |
| Python module | structured |
| Version | Stored in structured/__init__.py (__version__) and setup.py |
| License | MIT |
| Python support | >= 3.10 (up to 3.14) |
| Django support | 4.2, 5.0, 5.1, 5.2 |
| Pydantic | >= 2.12 |
| DRF | >= 3.14, < 4.0 |
| Branch | master (release), next (beta), feature/* (development) |
| Semantic Release | Configured in pyproject.toml with version variables in setup.py and structured/__init__.py |
2. Architecture Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ Django Model Layer │
│ StructuredJSONField(JSONField) ←── schema=SomePydanticModel │
│ StructuredDescriptior (lazy validation on __get__) │
└────────────┬───────────────────────────────────────┬────────────────────┘
│ validates via │ formfield()
▼ ▼
┌────────────────────────────┐ ┌──────────────────────────────────┐
│ Pydantic Layer │ │ Widget Layer (Admin) │
│ BaseModel (BaseModelMeta)│ │ StructuredJSONFormWidget │
│ ForeignKey[T] │ │ StructuredJSONFormField │
│ QuerySet[T] │ │ template: widget.html │
│ FieldSerializer │ │ JSONEditor + Select2 │
└────────────┬───────────────┘ └──────────────────────────────────┘
│ @model_validator ▲ AJAX search
▼ │
┌────────────────────────────┐ ┌──────────────────────────────────┐
│ Cache Layer │ │ Views │
│ CacheEngine │ │ search_view (staff-only) │
│ Cache / ThreadSafeCache │ │ /structured_field/search_model │
│ ValueWithCache │ └──────────────────────────────────┘
│ RelInfo │
└────────────────────────────┘ ┌──────────────────────────────────┐
│ DRF Contrib │
│ StructuredModelSerializer │
│ StructuredJSONField (DRF) │
│ FieldsOverrideMixin │
└──────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ Frontend (reactive-forms/) │
│ main.js → JSONEditor init callbacks.js → Select2 AJAX │
│ patches.js → Select2 editor scss/ → Admin theme integration │
│ Rollup build → structured/static/js/ & css/ │
└─────────────────────────────────────────────────────────────────────────┘
3. Directory Map & File Roles
Python — structured/
| Path | Role |
|---|---|
structured/__init__.py |
Package init, __version__ |
structured/fields.py |
Core: StructuredJSONField, StructuredDescriptior (Django model field) |
structured/apps.py |
Django AppConfig |
structured/settings.py |
Settings singleton — reads STRUCTURED_FIELD from Django settings |
structured/urls.py |
URL patterns for admin AJAX search endpoint |
structured/views.py |
search_view — staff-only model search for Select2 dropdowns |
structured/pydantic/models.py |
BaseModel, BaseModelMeta — custom Pydantic model with metaclass |
structured/pydantic/__init__.py |
Re-exports BaseModel |
structured/pydantic/fields/foreignkey.py |
ForeignKey[T] — Pydantic type for single Django model FK |
structured/pydantic/fields/queryset.py |
QuerySet[T] — Pydantic type for Django QuerySet/M2M-like |
structured/pydantic/fields/serializer.py |
FieldSerializer — custom DRF serializer override for fields |
structured/cache/cache.py |
Cache, ThreadSafeCache — in-memory caches with Django signal listeners |
structured/cache/engine.py |
CacheEngine — N+1 prevention: batch FK/QS lookups |
structured/cache/rel_info.py |
RelInfo — data class for relationship metadata |
structured/cache/__init__.py |
CacheEnabledModel mixin, ValueWithCache wrapper |
structured/widget/widgets.py |
StructuredJSONFormWidget — JSON editor admin widget |
structured/widget/fields.py |
StructuredJSONFormField — Django form field with Pydantic validation |
structured/contrib/restframework.py |
DRF integration: serializer field, mixin, StructuredModelSerializer |
structured/migrations/structured_json_migration.py |
StructuredJSONMigrationGenerator — schema migration code generator |
structured/management/commands/generate_schema_migration.py |
Management command to analyze data + generate migration templates |
structured/utils/cast.py |
cast_to_python, cast_to_model — type coercion helpers |
structured/utils/context.py |
build_context, increase_context_depth — serialization context |
structured/utils/dict.py |
dict_merge — deep dict merge (used in PATCH) |
structured/utils/django.py |
import_abs_model — import abstract Django models by label |
structured/utils/errors.py |
map_pydantic_errors — Pydantic → Django error dict conversion |
structured/utils/getter.py |
pointed_getter — dot-path traversal reader |
structured/utils/setter.py |
pointed_setter — dot-path traversal writer |
structured/utils/namespace.py |
merge_cls_and_parent_ns — namespace merging for forward refs |
structured/utils/options.py |
build_relation_schema_options — Select2 JSON schema metadata |
structured/utils/pydantic.py |
patch_annotation, map_method_aliases — annotation rewriting engine |
structured/utils/replace.py |
find_and_replace_dict — recursive dict transformation |
structured/utils/serializer.py |
build_model_serializer, BaseModelSerializer — dynamic DRF serializer factory |
structured/utils/typing.py |
LazyType, get_type, find_model_type_from_args — type introspection |
Frontend — reactive-forms/
| Path | Role |
|---|---|
reactive-forms/main.js |
Entry point: initializes JSONEditor for .structured-field-editor elements |
reactive-forms/callbacks.js |
initCallbacks() — Select2 AJAX params (_q, page) and result mapping |
reactive-forms/patches.js |
patchSelect2Editor() — extends JSONEditor's Select2 editor for relation types |
reactive-forms/scss/main.scss |
SCSS entry — imports component stylesheets |
reactive-forms/scss/components/editor.scss |
.structured-field-editor styling (Django admin theme vars) |
reactive-forms/scss/components/select2.custom.scss |
Select2 dropdown theming (Django admin CSS vars) |
Build output → structured/static/
| Path | Content |
|---|---|
structured/static/js/structured-field-form.js |
Unminified IIFE bundle (dev) |
structured/static/js/structured-field-form.min.js |
Minified IIFE bundle (prod) |
structured/static/css/structured-field-form.min.css |
Compiled + compressed SCSS |
structured/static/libs/ |
Vendored libraries (JSONEditor, Select2, FontAwesome) |
Template
| Path | Content |
|---|---|
structured/templates/json-forms/widget.html |
Django template: renders <div> with data-schema, data-formdata, data-uischema attributes + hidden <textarea> for form submission |
Tests — tests/
| Path | Role |
|---|---|
tests/app/app/settings.py |
Django settings (DJANGO_SETTINGS_MODULE) |
tests/app/app/urls.py |
URL config: admin + structured.urls + DRF router |
tests/app/test_module/models.py |
All test models and schemas — TestModel, TestSchema, TestSchema2, UnionSchema, RecursiveOnModelSchema, SimpleRelationModel, abstract models |
tests/app/test_module/admin.py |
Admin registration for test models |
tests/app/test_module/serializers.py |
TestModelSerializer(StructuredModelSerializer) |
tests/app/test_module/views.py |
TestModelViewSet(ModelViewSet) |
tests/fixtures/__init__.py |
load_json_fixture(), load_asset() helpers |
tests/fixtures/assets/ |
Binary test assets (PNG) |
tests/utils/media.py |
load_asset_and_remove_media() — file test helper |
conftest.py |
Root fixtures: django_db_setup, cache_setting_fixture, recursion_depth_setting_fixture |
4. Key Concepts & Design Patterns
4.1 Lazy Descriptor Validation
StructuredDescriptior (in fields.py) extends DeferredAttribute. Data stays as a raw dict in instance.__dict__ until the attribute is accessed. On first access, schema.validate_python() converts it to a Pydantic model instance and caches in __dict__. This means zero validation cost if the field is never read.
4.2 Metaclass-Driven Annotation Rewriting
BaseModelMeta.__new__ runs patch_annotation() on every field annotation. This auto-converts:
- A bare Django
Modelclass →ForeignKey[Model] QuerySet[Model]→Annotated[QuerySet[Model], Field(default_factory=...)]- Forward references are resolved via module globals + parent frame namespace
This allows users to write author: User and get full FK behavior automatically.
4.3 Batched Cache Loading (N+1 Prevention)
CacheEngine.build_cache() flow:
get_all_fk_data(data)walks all nested fields recursively, collecting{Model: [pk_list]}._build_plainset()deduplicates PKs per model class._populate_cache()issues oneModel.objects.filter(pk__in=pks)per model._set_cache_values()replaces raw PKs in the data dict withValueWithCachewrappers.fetch_cache()calls.retrieve()on each wrapper after validation.
Relationship types tracked via RelInfo:
fk— single ForeignKeyqs— QuerySet (many)rel— nested BaseModel (recurse into its cache engine)rel_l— list of nested BaseModels
4.4 Signal-Based Cache Invalidation
Cache registers post_save and pre_delete Django signals to keep cached model instances up-to-date. ThreadSafeCache (CACHE.SHARED = True) is a singleton — may retain stale data across requests if signals aren't firing.
4.5 Dual Serialization: _raw Property
StructuredJSONField.contribute_to_class() adds a <field>_raw property to the Django model class. This returns the raw dict from from_db_value() without validation — useful for migrations and debugging.
4.6 Dynamic DRF Serializer Factory
build_model_serializer(model) creates a ModelSerializer subclass on-the-fly for any Django model. It maps StructuredJSONField to JSONFieldInnerSerializer and respects MAX_DEPTH to prevent infinite recursion.
4.7 PATCH Deep Merge
The DRF StructuredJSONField.to_internal_value() handles PATCH by deep-merging old + new data via dict_merge(), so partial updates don't wipe unmodified nested fields.
4.8 JSON Schema for Admin Widget
FK/QS fields add format: "select2" and type: "relation" to their JSON schema. The JSONEditor resolver detects this and uses the patched Select2 editor (patches.js), which makes AJAX calls to /structured_field/search_model/<model>/ for autocomplete.
4.9 Schema Migration Generator
The generate_schema_migration command:
- Scans all DB rows for validation errors against the current schema.
- Groups errors by type/location.
- Generates a complete Django migration file with
RunPython+ transform functions. - Supports
--analyze-onlyfor inspection without generating files.
4.10 Python 3.14 / PEP 649 Support
BaseModelMeta._get_raw_annotations() handles deferred annotations using annotationlib.get_annotations(FORMAT.FORWARDREF) on Python 3.14+.
4.11 Extra = 'ignore' by Default
BaseModel uses ConfigDict(extra='ignore'), meaning unknown fields in JSON data are silently dropped. This makes schema evolution forward-compatible — no need to migrate data when removing fields.
5. Coding Conventions
Python
- Import style: Standard library → third-party → Django → local. Absolute imports within the
structuredpackage. - No docstrings in most internal code: Code is self-documenting; inline comments used sparingly for non-obvious logic.
- Type annotations: Used extensively throughout. Pydantic models are fully typed. Internal utilities use basic type hints.
- Naming:
- Classes:
PascalCase - Functions/methods:
snake_case - Constants:
UPPER_SNAKE_CASE - Private methods:
_single_underscore_prefix - File names:
snake_case.py
- Classes:
- Note:
StructuredDescriptioris the established spelling (typo preserved for backward compatibility). Do NOT rename it. - Error mapping: Pydantic
ValidationErroris always converted to nested dicts viamap_pydantic_errors()before surfacing to Django/DRF. - Field registration in
__init__: Custom Pydantic fields define their core schema via__get_pydantic_core_schema__classmethod. - Metaclass usage:
BaseModelMetaextends Pydantic'sModelMetaclassand must be maintained carefully — annotation patching order matters. - Settings access: Always through the
Settingssingleton (structured.settings), never directly fromdjango.conf.settings.
Frontend (JavaScript / SCSS)
- Plain ES modules: No framework (React/Vue). Uses vanilla JS + JSONEditor library.
- IIFE output: Rollup bundles to IIFE format for Django admin (no module loader).
- Class extension:
patches.jsextendsJSONEditor.defaults.editors.select2using ES6 class syntax. - Django CSS variables: SCSS uses Django admin CSS custom properties (
--body-bg,--border-color,--error-fg, etc.) for theme integration. - Data flow: JSON schema + form data passed via HTML
data-*attributes on the widget<div>. Editor changes write back to a hidden<textarea>. - Build command:
pnpm run build(ornpx rollup -c) — outputs tostructured/static/.
Tests
- Framework: pytest + pytest-django.
- Markers: All DB tests use
@pytest.mark.django_db. - Cache parametrization: Most tests are decorated with
@pytest.mark.parametrize("cache_setting_fixture", ["cache_enabled", "cache_disabled", "shared_cache"], indirect=True)to run across all cache configurations. - Late imports: Models are imported inside test functions, not at module top level.
- Standalone functions: Tests are top-level pytest functions (not classes), except
TestRestFrameworkwhich uses a class withsetup_method. - Query counting:
django_assert_num_queriesfrom pytest-django for cache/performance assertions. - Admin tests: Use
admin_clientfixture, POST data as strings, check HTTP 302 redirects. - DRF tests: Use
rest_framework.test.APIClient. - Settings override:
conftest.pyprovides indirect parametrize fixtures that modifysettings.STRUCTURED_FIELDdict. - Test DB: SQLite at
test_db.sqlite3, managed by session-scopeddjango_db_setupfixture.
6. How to Add or Modify Features
Adding a New Pydantic Field Type
- Create a new file in
structured/pydantic/fields/(e.g.,mytype.py). - Define a class with
__class_getitem__(for generic syntax) and__get_pydantic_core_schema__. - Implement validators (from raw data) and serializer (to JSON).
- If it references Django models, add relationship handling in
structured/cache/engine.py— register a newRelInfotype. - If it needs admin widget support, add JSON schema metadata (e.g.,
format,typekeys) and handle it inreactive-forms/patches.js. - Export from
structured/pydantic/fields/__init__.py. - Add annotation patching support in
structured/utils/pydantic.py→patch_annotation()if auto-conversion is desired.
Adding a New Utility
- Create a file in
structured/utils/. - Use pure functions with type hints.
- Import into
structured/utils/__init__.pyif it should be part of the public API.
Modifying the Cache System
- Cache population:
structured/cache/engine.py→_populate_cache(). - Cache invalidation:
structured/cache/cache.py→ signal handlers. - Relationship discovery:
engine.py→_get_rel_info()andget_all_fk_data(). - Shared cache singleton:
cache.py→ThreadSafeCache. - Important: The cache wraps raw PK values with
ValueWithCachebefore Pydantic validation, then resolves them after. This two-phase design is critical.
Modifying the Admin Widget
- Python side:
structured/widget/widgets.pymanages which CSS/JS files are loaded, andstructured/widget/fields.pyhandles form validation. - Template:
structured/templates/json-forms/widget.html— thedata-schema,data-formdata,data-uischemaattributes. - JS side:
reactive-forms/main.jsinitializes JSONEditor,callbacks.jsconfigures AJAX,patches.jsextends Select2 for relations. - After JS/SCSS changes, rebuild:
pnpm run build(ornpx rollup -c rollup.config.mjs). - Built assets go to
structured/static/js/andstructured/static/css/.
Modifying DRF Integration
structured/contrib/restframework.pycontains the serializer field, mixin, andStructuredModelSerializer.to_representationusesmodel_dump()with context depth tracking.to_internal_valuehandles PATCH viadict_mergeand validates via Pydantic schema.- Error mapping uses
map_pydantic_errors().
Adding/Modifying Migrations Support
structured/migrations/structured_json_migration.py— the generator class.structured/management/commands/generate_schema_migration.py— the CLI entry point.- The generator analyzes live data via
analyze_validation_errors()and produces Python code strings.
7. How to Write Tests
Test Location & Naming
- Test files go in
tests/withtest_prefix. - Use standalone functions (not classes) unless shared setup is needed.
Required Patterns
import pytest
@pytest.mark.django_db
@pytest.mark.parametrize(
"cache_setting_fixture",
["cache_enabled", "cache_disabled", "shared_cache"],
indirect=True,
)
def test_my_feature(cache_setting_fixture):
# Import models inside the test function
from tests.app.test_module.models import TestModel, SimpleRelationModel
# Create test data
rel = SimpleRelationModel.objects.create(name="Test")
obj = TestModel.objects.create(
title="Test",
structured_data={"name": "test", "age": 25, "fk_field": rel.pk},
)
# Refresh and assert
obj.refresh_from_db()
assert obj.structured_data.name == "test"
assert obj.structured_data.fk_field == rel
Query Count Tests
@pytest.mark.django_db
def test_query_efficiency(django_assert_num_queries):
from tests.app.test_module.models import TestModel
obj = TestModel.objects.create(...)
with django_assert_num_queries(N):
obj.refresh_from_db()
_ = obj.structured_data.some_field
DRF Tests
from rest_framework.test import APIClient
class TestMyAPI:
def setup_method(self):
self.client = APIClient()
# Create test data
@pytest.mark.django_db
def test_get(self):
response = self.client.get("/api/testmodels/1/")
assert response.status_code == 200
Test Models
All test schemas and Django models live in tests/app/test_module/models.py. If a new model/schema is needed for testing, add it there and run python manage.py makemigrations test_module.
Running Tests
# All tests
pytest -v
# Specific test file
pytest tests/test_structured_field.py -v
# With coverage
pytest --cov=structured -s -vv --cov-report=xml --cov-report=term-missing
# Via Makefile
make test
8. Common Debugging Scenarios
"N+1 queries" / Performance Issues
- Check if
STRUCTURED_FIELD['CACHE']['ENABLED']isTrue. - Inspect
CacheEngine.get_all_fk_data()— verify all FK/QS fields are discovered. - Use
django_assert_num_queriesin tests to verify. - Look at
_get_rel_info()inengine.pyto confirm relationship type detection.
Pydantic Validation Errors
StructuredJSONField.validate()callsschema.validate_python(cast_to_python(value)).- Errors are mapped via
map_pydantic_errors()→ nested dict keyed by field path. - For DRF, errors surface as DRF
ValidationErrorwith the same nested structure. - Check
patch_annotation()inutils/pydantic.pyif annotation rewriting is suspected.
Admin Widget Not Rendering
- Ensure
structuredis inINSTALLED_APPS. - Ensure
path("", include("structured.urls"))is in URL config. - Check that
formfield()infields.pyreturnsStructuredJSONFormFieldwith correct schema. - Verify static files are built:
structured/static/js/structured-field-form.min.jsmust exist.
Select2 Autocomplete Not Working
- The AJAX endpoint is
/structured_field/search_model/<app_label>.<model_name>/. views.py→search()requires staff authentication.- Check
build_relation_schema_options()inutils/options.pyfor JSON schema metadata. - Frontend:
callbacks.jssends_q(search term) andpageparams. patches.jsresolver checksschema.type === "relation"andschema.format === "select2".
Forward Reference Resolution Issues
BaseModelMetabuilds a namespace from module globals + parent frame.utils/namespace.py→merge_cls_and_parent_ns().- Python 3.14: Uses
annotationlib.get_annotations(FORMAT.FORWARDREF). - Check
LazyTypeinutils/typing.pyfor dot-path type resolution.
Cache Invalidation Problems
Cacheusespost_save/pre_deletesignals.ThreadSafeCache(CACHE.SHARED = True) is a singleton — may retain stale data across requests if signals aren't firing.flush(filters=None)clears cache optionally per-model.- Inspect signal connections in
cache.py.
Schema Migration Issues
- Run
python manage.py generate_schema_migration <app> <model> --analyze-onlyfirst. - Generated migration is a template — humans must review and customize transform functions.
extra='ignore'means removed fields don't need migration (silently dropped).- New required fields without defaults DO need data migration.
9. Settings Reference
Configure via STRUCTURED_FIELD dict in Django settings:
STRUCTURED_FIELD = {
"CACHE": {
"ENABLED": True, # Enable FK/QS cache (default: True)
"SHARED": False, # Use global thread-safe cache (default: False, experimental)
},
"SERIALIZATION": {
"MAX_DEPTH": 2, # Max depth for nested model serialization (default: 2)
},
}
Access in code: from structured.settings import Settings; Settings().CACHE_ENABLED.
10. Build & Development Commands
# Python
pip install -e ".[dev]" # Install in dev mode
pytest -v # Run tests
make test # Lint (flake8) + tests with coverage
python manage.py makemigrations # After model changes
python manage.py migrate # Apply migrations
python manage.py generate_schema_migration <app> <model> # Schema migration
# Frontend
pnpm install # Install JS dependencies
pnpm run build # Build JS/CSS → structured/static/
npx rollup -c rollup.config.mjs # Manual build
# Rollup config: reactive-forms/main.js → structured/static/js/ + css/
# Versioning
# Managed by python-semantic-release via pyproject.toml
# Version stored in: setup.py (__version__) and structured/__init__.py (__version__)
11. Important Warnings
- Do NOT rename
StructuredDescriptior— it's a known typo preserved for backward compatibility. - Annotation patching order matters in
BaseModelMeta.__new__()—patch_annotation()must run beforesuper().__new__()calls Pydantic's metaclass. - Cache two-phase design:
ValueWithCachewrappers are injected before Pydantic validation and resolved after. Do not change this order. extra='ignore'is intentional onBaseModel— do not change to'forbid'without considering all downstream migration implications.- Models imported inside tests — this is intentional to avoid import-time side effects with Django app registry.
- Test DB (
test_db.sqlite3) is created by session-scoped fixture — do not commit it or rely on its state between test runs programmatically. - Frontend build output (
structured/static/) is committed to the repo — after JS/SCSS changes, rebuild before committing. - Abstract model FK resolution uses a
modelkey in the dict data to determine the concrete model class.QuerySetfields do NOT support abstract models.
Source: bnznamco/django-structured-field — distributed by TomeVault.