Skip to content

Commit 18a1b00

Browse files
benaduoclaude
andcommitted
Add _full_clean flag to baker.make(), prepare(), and bulk_create()
Fixes model-bakers#523. Adds a `_full_clean=False` opt-in parameter across all main creation functions. When True, calls Django's `full_clean()` on each instance before saving. For `_bulk_create=True`, validation runs at prepare() time and the bulk_create call is wrapped in transaction.atomic() to ensure atomic rollback if validation fails. Based on the approach in model-bakers#547 (thanks @berinhard), incorporating feedback from Rust's review of that PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b094155 commit 18a1b00

File tree

4 files changed

+104
-7
lines changed

4 files changed

+104
-7
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
99

1010
### Added
1111

12+
- Add `_full_clean` flag to `baker.make()`, `baker.prepare()`, and `baker.bulk_create()` to run Django model validation (`False` by default) ([#523](https://github.com/model-bakers/model_bakery/issues/523))
13+
1214
### Changed
1315

16+
- Bulk creation now uses transaction blocks to ensure atomic rollback when `_full_clean=True` validation fails
17+
1418
### Removed
1519

1620
## [1.23.4](https://pypi.org/project/model-bakery/1.23.4/)

docs/basic_usage.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,30 @@ user_iter = User.objects.all().iterator()
342342
baker.prepare(Profile, user=user_iter, _quantity=5, _bulk_create=True)
343343
```
344344

345+
## Running Model Validation
346+
347+
By default, Model Bakery skips Django's model validation when creating objects. To enable
348+
it, pass `_full_clean=True`:
349+
350+
```python
351+
from model_bakery import baker
352+
from django.core.exceptions import ValidationError
353+
import pytest
354+
355+
with pytest.raises(ValidationError):
356+
baker.make('myapp.Profile', email="not-valid", _full_clean=True)
357+
```
358+
359+
The flag is also supported by `baker.prepare()`:
360+
361+
```python
362+
with pytest.raises(ValidationError):
363+
baker.prepare('myapp.Profile', email="not-valid", _full_clean=True)
364+
```
365+
366+
When using `_full_clean=True` with `_bulk_create=True`, all objects are created within a
367+
transaction and will be rolled back if any validation errors occur.
368+
345369
## Multi-database support
346370

347371
Model Bakery supports django application with more than one database.

model_bakery/baker.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from django.apps import apps
1212
from django.conf import settings
1313
from django.core.exceptions import FieldDoesNotExist
14+
from django.db import transaction
1415
from django.db.models import (
1516
AutoField,
1617
BooleanField,
@@ -99,6 +100,7 @@ def make(
99100
_create_files: bool = False,
100101
_using: str = "",
101102
_bulk_create: bool = False,
103+
_full_clean: bool = False,
102104
**attrs: Any,
103105
) -> M: ...
104106

@@ -114,6 +116,7 @@ def make(
114116
_using: str = "",
115117
_bulk_create: bool = False,
116118
_fill_optional: list[str] | bool = False,
119+
_full_clean: bool = False,
117120
**attrs: Any,
118121
) -> list[M]: ...
119122

@@ -128,6 +131,7 @@ def make(
128131
_using: str = "",
129132
_bulk_create: bool = False,
130133
_fill_optional: list[str] | bool = False,
134+
_full_clean: bool = False,
131135
**attrs: Any,
132136
):
133137
"""Create a persisted instance from a given model its associated models.
@@ -144,20 +148,26 @@ def make(
144148
raise InvalidQuantityException
145149

146150
if _bulk_create:
147-
result = bulk_create(baker, _quantity or 1, _save_kwargs=_save_kwargs, **attrs)
151+
result = bulk_create(
152+
baker, _quantity or 1, _save_kwargs=_save_kwargs, _full_clean=_full_clean, **attrs
153+
)
148154
return result if _quantity else result[0]
149155
elif _quantity:
150156
return [
151157
baker.make(
152158
_save_kwargs=_save_kwargs,
153159
_refresh_after_create=_refresh_after_create,
160+
_full_clean=_full_clean,
154161
**attrs,
155162
)
156163
for _ in range(_quantity)
157164
]
158165

159166
return baker.make(
160-
_save_kwargs=_save_kwargs, _refresh_after_create=_refresh_after_create, **attrs
167+
_save_kwargs=_save_kwargs,
168+
_refresh_after_create=_refresh_after_create,
169+
_full_clean=_full_clean,
170+
**attrs,
161171
)
162172

163173

@@ -167,6 +177,7 @@ def prepare(
167177
_quantity: None = None,
168178
_save_related: bool = False,
169179
_using: str = "",
180+
_full_clean: bool = False,
170181
**attrs: Any,
171182
) -> M: ...
172183

@@ -178,6 +189,7 @@ def prepare(
178189
_save_related: bool = False,
179190
_using: str = "",
180191
_fill_optional: list[str] | bool = False,
192+
_full_clean: bool = False,
181193
**attrs: Any,
182194
) -> list[M]: ...
183195

@@ -188,6 +200,7 @@ def prepare(
188200
_save_related: bool = False,
189201
_using: str = "",
190202
_fill_optional: list[str] | bool = False,
203+
_full_clean: bool = False,
191204
**attrs: Any,
192205
):
193206
"""Create but do not persist an instance from a given model.
@@ -202,11 +215,11 @@ def prepare(
202215

203216
if _quantity:
204217
return [
205-
baker.prepare(_save_related=_save_related, **attrs)
218+
baker.prepare(_save_related=_save_related, _full_clean=_full_clean, **attrs)
206219
for i in range(_quantity)
207220
]
208221

209-
return baker.prepare(_save_related=_save_related, **attrs)
222+
return baker.prepare(_save_related=_save_related, _full_clean=_full_clean, **attrs)
210223

211224

212225
def _recipe(name: str) -> Any:
@@ -403,6 +416,7 @@ def make(
403416
_refresh_after_create: bool = False,
404417
_from_manager=None,
405418
_fill_optional: list[str] | bool = False,
419+
_full_clean: bool = False,
406420
**attrs: Any,
407421
):
408422
"""Create and persist an instance of the model associated with Baker instance."""
@@ -413,6 +427,7 @@ def make(
413427
"_refresh_after_create": _refresh_after_create,
414428
"_from_manager": _from_manager,
415429
"_fill_optional": _fill_optional,
430+
"_full_clean": _full_clean,
416431
}
417432
params.update(attrs)
418433
return self._make(**params)
@@ -421,13 +436,15 @@ def prepare(
421436
self,
422437
_save_related=False,
423438
_fill_optional: list[str] | bool = False,
439+
_full_clean: bool = False,
424440
**attrs: Any,
425441
) -> M:
426442
"""Create, but do not persist, an instance of the associated model."""
427443
params = {
428444
"commit": False,
429445
"commit_related": _save_related,
430446
"_fill_optional": _fill_optional,
447+
"_full_clean": _full_clean,
431448
}
432449
params.update(attrs)
433450
return self._make(**params)
@@ -444,6 +461,7 @@ def _make( # noqa: C901
444461
_save_kwargs=None,
445462
_refresh_after_create=False,
446463
_from_manager=None,
464+
_full_clean=False,
447465
**attrs: Any,
448466
) -> M:
449467
_save_kwargs = _save_kwargs or {}
@@ -496,6 +514,7 @@ def _make( # noqa: C901
496514
_commit=commit,
497515
_from_manager=_from_manager,
498516
_save_kwargs=_save_kwargs,
517+
_full_clean=_full_clean,
499518
)
500519
if commit:
501520
for related in self.model._meta.related_objects:
@@ -514,7 +533,7 @@ def m2m_value(self, field: ManyToManyField) -> list[Any]:
514533
return self.generate_value(field)
515534

516535
def instance(
517-
self, attrs: dict[str, Any], _commit, _save_kwargs, _from_manager
536+
self, attrs: dict[str, Any], _commit, _save_kwargs, _from_manager, _full_clean=False
518537
) -> M:
519538
one_to_many_keys = {}
520539
auto_now_keys = {}
@@ -546,6 +565,9 @@ def instance(
546565
instance, generic_foreign_keys, commit=_commit
547566
)
548567

568+
if _full_clean:
569+
instance.full_clean()
570+
549571
if _commit:
550572
instance.save(**_save_kwargs)
551573
self._handle_one_to_many(instance, one_to_many_keys)
@@ -925,7 +947,7 @@ def _save_related_objs(model, objects, _using=None) -> None:
925947
setattr(objects[i], fk.name, fk_obj)
926948

927949

928-
def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> list[M]: # noqa: C901
950+
def bulk_create(baker: Baker[M], quantity: int, _full_clean: bool = False, **kwargs) -> list[M]: # noqa: C901
929951
"""
930952
Bulk create entries and all related FKs as well.
931953
@@ -936,6 +958,7 @@ def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> list[M]: # noqa: C
936958
# quantity number of times, passing in the additional keyword arguments
937959
entries = [
938960
baker.prepare(
961+
_full_clean=_full_clean,
939962
**kwargs,
940963
)
941964
for _ in range(quantity)
@@ -949,7 +972,11 @@ def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> list[M]: # noqa: C
949972
else:
950973
manager = baker.model._base_manager
951974

952-
created_entries = manager.bulk_create(entries)
975+
if _full_clean:
976+
with transaction.atomic():
977+
created_entries = manager.bulk_create(entries)
978+
else:
979+
created_entries = manager.bulk_create(entries)
953980

954981
# set many-to-many relations from kwargs
955982
for entry in created_entries:

tests/test_baker.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from django.apps import apps
77
from django.conf import settings
8+
from django.core.exceptions import ValidationError
89
from django.db.models import Manager, ManyToOneRel
910
from django.db.models.signals import m2m_changed
1011
from django.test import TestCase, override_settings
@@ -1535,3 +1536,44 @@ def test_generate_value_crashes_on_many_to_one_rel(self):
15351536
baker_instance = baker.Baker(models.Person)
15361537
with pytest.raises(AttributeError, match="has_default"):
15371538
baker_instance.generate_value(reverse_rel)
1539+
1540+
1541+
class TestFullClean:
1542+
@pytest.mark.django_db
1543+
def test_make_with_full_clean_valid(self):
1544+
"""_full_clean=True does not interfere with valid objects."""
1545+
profile = baker.make(models.Profile, _full_clean=True)
1546+
assert profile.pk is not None
1547+
1548+
@pytest.mark.django_db
1549+
def test_make_with_full_clean_raises_on_invalid(self):
1550+
"""_full_clean=True raises ValidationError for invalid field values."""
1551+
with pytest.raises(ValidationError):
1552+
baker.make(models.Profile, email="not-an-email", _full_clean=True)
1553+
1554+
@pytest.mark.django_db
1555+
def test_make_with_full_clean_no_db_entry_on_error(self):
1556+
"""No DB row is created when _full_clean=True raises ValidationError."""
1557+
with pytest.raises(ValidationError):
1558+
baker.make(models.Profile, email="not-an-email", _full_clean=True)
1559+
assert models.Profile.objects.count() == 0
1560+
1561+
@pytest.mark.django_db
1562+
def test_prepare_with_full_clean_raises_on_invalid(self):
1563+
"""_full_clean=True on prepare() validates without hitting the DB."""
1564+
with pytest.raises(ValidationError):
1565+
baker.prepare(models.Profile, email="not-an-email", _full_clean=True)
1566+
assert models.Profile.objects.count() == 0
1567+
1568+
@pytest.mark.django_db
1569+
def test_bulk_create_with_full_clean_rolls_back_on_error(self):
1570+
"""_full_clean=True with _bulk_create=True rolls back all entries on error."""
1571+
with pytest.raises(ValidationError):
1572+
baker.make(
1573+
models.Profile,
1574+
email="not-an-email",
1575+
_quantity=5,
1576+
_bulk_create=True,
1577+
_full_clean=True,
1578+
)
1579+
assert models.Profile.objects.count() == 0

0 commit comments

Comments
 (0)