Skip to content

Commit d3f15ee

Browse files
committed
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.
1 parent b094155 commit d3f15ee

File tree

4 files changed

+115
-7
lines changed

4 files changed

+115
-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: 45 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,30 @@ 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,
153+
_quantity or 1,
154+
_save_kwargs=_save_kwargs,
155+
_full_clean=_full_clean,
156+
**attrs,
157+
)
148158
return result if _quantity else result[0]
149159
elif _quantity:
150160
return [
151161
baker.make(
152162
_save_kwargs=_save_kwargs,
153163
_refresh_after_create=_refresh_after_create,
164+
_full_clean=_full_clean,
154165
**attrs,
155166
)
156167
for _ in range(_quantity)
157168
]
158169

159170
return baker.make(
160-
_save_kwargs=_save_kwargs, _refresh_after_create=_refresh_after_create, **attrs
171+
_save_kwargs=_save_kwargs,
172+
_refresh_after_create=_refresh_after_create,
173+
_full_clean=_full_clean,
174+
**attrs,
161175
)
162176

163177

@@ -167,6 +181,7 @@ def prepare(
167181
_quantity: None = None,
168182
_save_related: bool = False,
169183
_using: str = "",
184+
_full_clean: bool = False,
170185
**attrs: Any,
171186
) -> M: ...
172187

@@ -178,6 +193,7 @@ def prepare(
178193
_save_related: bool = False,
179194
_using: str = "",
180195
_fill_optional: list[str] | bool = False,
196+
_full_clean: bool = False,
181197
**attrs: Any,
182198
) -> list[M]: ...
183199

@@ -188,6 +204,7 @@ def prepare(
188204
_save_related: bool = False,
189205
_using: str = "",
190206
_fill_optional: list[str] | bool = False,
207+
_full_clean: bool = False,
191208
**attrs: Any,
192209
):
193210
"""Create but do not persist an instance from a given model.
@@ -202,11 +219,11 @@ def prepare(
202219

203220
if _quantity:
204221
return [
205-
baker.prepare(_save_related=_save_related, **attrs)
222+
baker.prepare(_save_related=_save_related, _full_clean=_full_clean, **attrs)
206223
for i in range(_quantity)
207224
]
208225

209-
return baker.prepare(_save_related=_save_related, **attrs)
226+
return baker.prepare(_save_related=_save_related, _full_clean=_full_clean, **attrs)
210227

211228

212229
def _recipe(name: str) -> Any:
@@ -403,6 +420,7 @@ def make(
403420
_refresh_after_create: bool = False,
404421
_from_manager=None,
405422
_fill_optional: list[str] | bool = False,
423+
_full_clean: bool = False,
406424
**attrs: Any,
407425
):
408426
"""Create and persist an instance of the model associated with Baker instance."""
@@ -413,6 +431,7 @@ def make(
413431
"_refresh_after_create": _refresh_after_create,
414432
"_from_manager": _from_manager,
415433
"_fill_optional": _fill_optional,
434+
"_full_clean": _full_clean,
416435
}
417436
params.update(attrs)
418437
return self._make(**params)
@@ -421,13 +440,15 @@ def prepare(
421440
self,
422441
_save_related=False,
423442
_fill_optional: list[str] | bool = False,
443+
_full_clean: bool = False,
424444
**attrs: Any,
425445
) -> M:
426446
"""Create, but do not persist, an instance of the associated model."""
427447
params = {
428448
"commit": False,
429449
"commit_related": _save_related,
430450
"_fill_optional": _fill_optional,
451+
"_full_clean": _full_clean,
431452
}
432453
params.update(attrs)
433454
return self._make(**params)
@@ -444,6 +465,7 @@ def _make( # noqa: C901
444465
_save_kwargs=None,
445466
_refresh_after_create=False,
446467
_from_manager=None,
468+
_full_clean=False,
447469
**attrs: Any,
448470
) -> M:
449471
_save_kwargs = _save_kwargs or {}
@@ -496,6 +518,7 @@ def _make( # noqa: C901
496518
_commit=commit,
497519
_from_manager=_from_manager,
498520
_save_kwargs=_save_kwargs,
521+
_full_clean=_full_clean,
499522
)
500523
if commit:
501524
for related in self.model._meta.related_objects:
@@ -514,7 +537,12 @@ def m2m_value(self, field: ManyToManyField) -> list[Any]:
514537
return self.generate_value(field)
515538

516539
def instance(
517-
self, attrs: dict[str, Any], _commit, _save_kwargs, _from_manager
540+
self,
541+
attrs: dict[str, Any],
542+
_commit,
543+
_save_kwargs,
544+
_from_manager,
545+
_full_clean=False,
518546
) -> M:
519547
one_to_many_keys = {}
520548
auto_now_keys = {}
@@ -546,6 +574,9 @@ def instance(
546574
instance, generic_foreign_keys, commit=_commit
547575
)
548576

577+
if _full_clean:
578+
instance.full_clean()
579+
549580
if _commit:
550581
instance.save(**_save_kwargs)
551582
self._handle_one_to_many(instance, one_to_many_keys)
@@ -925,7 +956,9 @@ def _save_related_objs(model, objects, _using=None) -> None:
925956
setattr(objects[i], fk.name, fk_obj)
926957

927958

928-
def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> list[M]: # noqa: C901
959+
def bulk_create(
960+
baker: Baker[M], quantity: int, _full_clean: bool = False, **kwargs
961+
) -> list[M]: # noqa: C901
929962
"""
930963
Bulk create entries and all related FKs as well.
931964
@@ -936,6 +969,7 @@ def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> list[M]: # noqa: C
936969
# quantity number of times, passing in the additional keyword arguments
937970
entries = [
938971
baker.prepare(
972+
_full_clean=_full_clean,
939973
**kwargs,
940974
)
941975
for _ in range(quantity)
@@ -949,7 +983,11 @@ def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> list[M]: # noqa: C
949983
else:
950984
manager = baker.model._base_manager
951985

952-
created_entries = manager.bulk_create(entries)
986+
if _full_clean:
987+
with transaction.atomic():
988+
created_entries = manager.bulk_create(entries)
989+
else:
990+
created_entries = manager.bulk_create(entries)
953991

954992
# set many-to-many relations from kwargs
955993
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)