Repository: model-bakers/model_bakery

Issue:
_bulk_create=True silently and undocumentedly does not create M2M-entries
Probably related to #202, as it is the same issue applied to ManyToManyField.

When using `baker.make` with `_bulk_create=True`, it will silently ignore arguments that should be used to fill many-to-many fields.

## Expected behavior
with
```python3
class House(models.Model):
     pass

class HouseDetail(models.Model):
    houses = models.ManyToManyField(House, blank=True)
```
I'd expect
```python3
house = baker.make(House)
details = baker.make(HouseDetail, houses=[house], _quantity=20, _bulk_create=True)
assert details[0].houses.count() == 1
```
to work.

If it can't, for some technical reason, I'd expect it to be documented that this is not supported, and raise an exception.

## Actual behavior

Currently, this code only raises at the assertion, and I couldn't find any remarks [in the documentation](https://model-bakery.readthedocs.io/en/latest/basic_usage.html#more-than-one-instance).

If the `_bulk_create=True` argument is removed, everything behaves as expected.

### Versions

Python: 3.7.5
Django: 3.2
Model Bakery: 1.4.0

Discussion:
amureki: @model-bakers/core would love to get your opinion here. I solved the original issue, but it adds extra queries to resolve M2M relations (which I expected). However, due to [QuerySet.bulk_create](https://docs.djangoproject.com/en/4.1/ref/models/querysets/#django.db.models.query.QuerySet.bulk_create) not returning pks in old SQLite versions (and Django < 4.0), there is an extra step that adds even more queries (however, I limited it to not affect Django 4.0+ users).

The other option would be to explicitly say that we are not supporting M2M properly in QuerySet.bulk_create, which is also not desirable...

Patch:
diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml
index 6a06232..dfc3c77 100644
--- a/.github/workflows/changelog.yml
+++ b/.github/workflows/changelog.yml
@@ -4,7 +4,7 @@ on: pull_request
 
 jobs:
   remind:
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
     if: |
       !contains(github.event.pull_request.body, '[skip changelog]') &&
       (github.actor != 'dependabot[bot]')
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
index 06d2296..59e8164 100644
--- a/.github/workflows/linter.yml
+++ b/.github/workflows/linter.yml
@@ -9,7 +9,7 @@ on:
 jobs:
   tests:
     name: Python ${{ matrix.python-version }}
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     steps:
       - uses: actions/checkout@v3
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5bc80f9..6ce473a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,7 +5,7 @@ on: [release]
 jobs:
   package:
     name: Build & verify package
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     steps:
       - uses: actions/checkout@v3
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fdd2f2a..ca76f0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 ### Changed
 - Fixed a bug with `seq` being passed a tz-aware start value [PR #353](https://github.com/model-bakers/model_bakery/pull/353)
 - [dev] Use official postgis docker image in CI [PR #355](https://github.com/model-bakers/model_bakery/pull/355)
+- Create m2m when using `_bulk_create=True` [PR #354](https://github.com/model-bakers/model_bakery/pull/354)
 
 ### Removed
 
diff --git a/model_bakery/baker.py b/model_bakery/baker.py
index 930f50b..86dd2a8 100644
--- a/model_bakery/baker.py
+++ b/model_bakery/baker.py
@@ -13,6 +13,7 @@ from typing import (
     overload,
 )
 
+from django import VERSION as DJANGO_VERSION
 from django.apps import apps
 from django.conf import settings
 from django.contrib import contenttypes
@@ -787,4 +788,27 @@ def bulk_create(baker: Baker[M], quantity: int, **kwargs) -> List[M]:
     else:
         manager = baker.model._base_manager
 
-    return manager.bulk_create(entries)
+    existing_entries = list(manager.values_list("pk", flat=True))
+    created_entries = manager.bulk_create(entries)
+    # bulk_create in Django < 4.0 does not return ids of created objects.
+    #  drop this after 01 Apr 2024 (Django 3.2 LTS end of life)
+    if DJANGO_VERSION < (4, 0):
+        created_entries = manager.exclude(pk__in=existing_entries)
+
+    # set many-to-many relations from kwargs
+    for entry in created_entries:
+        for field in baker.model._meta.many_to_many:
+            if field.name in kwargs:
+                through_model = getattr(entry, field.name).through
+                through_model.objects.bulk_create(
+                    [
+                        through_model(
+                            **{
+                                field.remote_field.name: entry,
+                                field.related_model._meta.model_name: obj,
+                            }
+                        )
+                        for obj in kwargs[field.name]
+                    ]
+                )
+    return created_entries


Test patch:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 4d2294e..bb40687 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -9,7 +9,7 @@ on:
 jobs:
   tests:
     name: Python ${{ matrix.python-version }}
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-22.04
 
     services:
       postgis:
diff --git a/tests/test_baker.py b/tests/test_baker.py
index e955f9d..d777d76 100644
--- a/tests/test_baker.py
+++ b/tests/test_baker.py
@@ -4,6 +4,7 @@ from decimal import Decimal
 from unittest.mock import patch
 
 import pytest
+from django import VERSION as DJANGO_VERSION
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Manager
@@ -151,17 +152,18 @@ class TestsBakerRepeatedCreatesSimpleModel(TestCase):
             assert all(p.name == "George Washington" for p in people)
 
     def test_make_quantity_respecting_bulk_create_parameter(self):
-        with self.assertNumQueries(1):
+        query_count = 2 if DJANGO_VERSION >= (4, 0) else 3
+        with self.assertNumQueries(query_count):
             baker.make(models.Person, _quantity=5, _bulk_create=True)
         assert models.Person.objects.count() == 5
 
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(query_count):
             people = baker.make(
                 models.Person, name="George Washington", _quantity=5, _bulk_create=True
             )
             assert all(p.name == "George Washington" for p in people)
 
-        with self.assertNumQueries(1):
+        with self.assertNumQueries(query_count):
             baker.make(models.NonStandardManager, _quantity=3, _bulk_create=True)
             assert getattr(models.NonStandardManager, "objects", None) is None
             assert (
@@ -362,16 +364,17 @@ class TestBakerCreatesAssociatedModels(TestCase):
         assert models.Person.objects.all().count() == 5
 
     def test_bulk_create_multiple_one_to_one(self):
-        with self.assertNumQueries(6):
+        query_count = 7 if DJANGO_VERSION >= (4, 0) else 8
+        with self.assertNumQueries(query_count):
             baker.make(models.LonelyPerson, _quantity=5, _bulk_create=True)
 
         assert models.LonelyPerson.objects.all().count() == 5
         assert models.Person.objects.all().count() == 5
 
     def test_chaining_bulk_create_reduces_query_count(self):
-        qtd = 5
-        with self.assertNumQueries(3):
-            baker.make(models.Person, _quantity=qtd, _bulk_create=True)
+        query_count = 5 if DJANGO_VERSION >= (4, 0) else 7
+        with self.assertNumQueries(query_count):
+            baker.make(models.Person, _quantity=5, _bulk_create=True)
             person_iter = models.Person.objects.all().iterator()
             baker.make(
                 models.LonelyPerson,
@@ -385,7 +388,8 @@ class TestBakerCreatesAssociatedModels(TestCase):
         assert models.Person.objects.all().count() == 5
 
     def test_bulk_create_multiple_fk(self):
-        with self.assertNumQueries(6):
+        query_count = 7 if DJANGO_VERSION >= (4, 0) else 8
+        with self.assertNumQueries(query_count):
             baker.make(models.PaymentBill, _quantity=5, _bulk_create=True)
 
         assert models.PaymentBill.objects.all().count() == 5
@@ -396,7 +400,7 @@ class TestBakerCreatesAssociatedModels(TestCase):
         assert store.employees.count() == 5
         assert store.customers.count() == 5
 
-    def test_regresstion_many_to_many_field_is_accepted_as_kwargs(self):
+    def test_regression_many_to_many_field_is_accepted_as_kwargs(self):
         employees = baker.make(models.Person, _quantity=3)
         customers = baker.make(models.Person, _quantity=3)
 
@@ -1032,3 +1036,16 @@ class TestBakerMakeCanFetchInstanceFromDefaultManager:
             _from_manager="objects",
         )
         assert movie.title == movie.name
+
+
+class TestCreateM2MWhenBulkCreate(TestCase):
+    @pytest.mark.django_db
+    def test_create(self):
+        query_count = 13 if DJANGO_VERSION >= (4, 0) else 14
+        with self.assertNumQueries(query_count):
+            person = baker.make(models.Person)
+            baker.make(
+                models.Classroom, students=[person], _quantity=10, _bulk_create=True
+            )
+        c1, c2 = models.Classroom.objects.all()[:2]
+        assert list(c1.students.all()) == list(c2.students.all()) == [person]