Skip to content

Commit

Permalink
Isolate Recipe defaults to prevent modification via instances
Browse files Browse the repository at this point in the history
With this change, every baked instance holds an isolated copy of the
recipe default attributes of any Container class (e.g., dict or list).
This prevents from affecting the parent recipe, and other (existing or
future) instances, when such fields are modified on an instance.
  • Loading branch information
gildegoma committed Dec 10, 2024
1 parent 3818ad8 commit fb3a1eb
Show file tree
Hide file tree
Showing 2 changed files with 37 additions and 0 deletions.
3 changes: 3 additions & 0 deletions model_bakery/recipe.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import collections
import copy
import itertools
from typing import (
Any,
Expand Down Expand Up @@ -79,6 +80,8 @@ def _mapping( # noqa: C901
mapping[k] = v.recipe.prepare(_using=_using, **recipe_attrs)
elif isinstance(v, related):
mapping[k] = v.make
elif isinstance(v, collections.abc.Container):
mapping[k] = copy.deepcopy(v)

mapping.update(new_attrs)
mapping.update(rel_fields_attrs)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from random import choice # noqa
from unittest.mock import patch

from django.db import connection
from django.utils.timezone import now

import pytest
Expand Down Expand Up @@ -34,6 +35,7 @@
"blog": "https://joe.example.com",
"days_since_last_login": 4,
"birth_time": now(),
"data": {"one": 1},
}
person_recipe = Recipe(Person, **recipe_attrs)
user_recipe = Recipe(User)
Expand Down Expand Up @@ -68,6 +70,8 @@ def test_flat_model_make_recipe_with_the_correct_attributes(self):
assert person.appointment == recipe_attrs["appointment"]
assert person.blog == recipe_attrs["blog"]
assert person.days_since_last_login == recipe_attrs["days_since_last_login"]
assert person.data is not recipe_attrs["data"]
assert person.data == recipe_attrs["data"]
assert person.id is not None

def test_flat_model_prepare_recipe_with_the_correct_attributes(self):
Expand All @@ -80,6 +84,8 @@ def test_flat_model_prepare_recipe_with_the_correct_attributes(self):
assert person.appointment == recipe_attrs["appointment"]
assert person.blog == recipe_attrs["blog"]
assert person.days_since_last_login == recipe_attrs["days_since_last_login"]
assert person.data is not recipe_attrs["data"]
assert person.data == recipe_attrs["data"]
assert person.id is None

def test_accepts_callable(self):
Expand Down Expand Up @@ -171,6 +177,34 @@ def test_defining_recipes_str(self):
except AttributeError as e:
pytest.fail(f"{e}")

def test_recipe_dict_attribute_isolation(self):
person1 = person_recipe.make()
person2 = person_recipe.make()
person2.data["two"] = 2
person3 = person_recipe.make()

# Mutation on instances must have no side effect on their recipe definition,
# or on other instances of the same recipe.
assert person1.data == {"one": 1}
assert person2.data == {"one": 1, "two": 2}
assert person3.data == {"one": 1}

@pytest.mark.skipif(
connection.vendor != "postgresql", reason="PostgreSQL specific tests"
)
def test_recipe_list_attribute_isolation(self):
pg_person_recipe = person_recipe.extend(acquaintances=[1, 2, 3])
person1 = pg_person_recipe.make()
person2 = pg_person_recipe.make()
person2.acquaintances.append(4)
person3 = pg_person_recipe.make()

# Mutation on instances must have no side effect on their recipe definition,
# or on other instances of the same recipe.
assert person1.acquaintances == [1, 2, 3]
assert person2.acquaintances == [1, 2, 3, 4]
assert person3.acquaintances == [1, 2, 3]


@pytest.mark.django_db
class TestExecutingRecipes:
Expand Down

0 comments on commit fb3a1eb

Please sign in to comment.