#34207: Incorrect SQL query when adding a ManyToMany related object with a
"through" table prevents adding a new relationship if the new relationship
is identical except for a different value for "through_defaults"
-------------------------------------+-------------------------------------
               Reporter:             |          Owner:  nobody
  Credentive                         |
                   Type:  Bug        |         Status:  new
              Component:  Database   |        Version:  4.1
  layer (models, ORM)                |       Keywords:  ManyToManyField
               Severity:  Normal     |  through
           Triage Stage:             |      Has patch:  0
  Unreviewed                         |
    Needs documentation:  0          |    Needs tests:  0
Patch needs improvement:  0          |  Easy pickings:  0
                  UI/UX:  0          |
-------------------------------------+-------------------------------------
 I cannot add a related element to an object that is related via a
 ManyToMany relationship with a through field. I believe I have traced the
 error to a bug in the SQL query generated.

 Some Code extracts to set the stage:

 models.py:

 {{{
 class Version(models.Model):
 uuid = models.UUIDField(primary_key=True, unique=True, editable=False,
 default=uuid.uuid4, )
 <...>

 class Section(models.Model):
 uuid = models.UUIDField(primary_key=True, unique=True, editable=False,
 default=uuid.uuid4, )
 <...>


 class Statement(models.Model):
 uuid = models.UUIDField(primary_key=True, unique=True, editable=False,
 default=uuid.uuid4, )
 <...>
 section = models.ManyToManyField(
         Section, through="StatementLocation", symmetrical=False,
 related_name="statements_in_section"
     )


 class StatementLocation(models.Model):
     section = models.ForeignKey(Section, on_delete=models.CASCADE)
     statement = models.ForeignKey(Statement, on_delete=models.CASCADE)
     sl_version = models.ForeignKey(Version, on_delete=models.CASCADE)

     class Meta:
         constraints = [
             models.UniqueConstraint(
                 fields=["section", "statement", "sl_version"],
 name="unique_statement_location_per_version"
             ),
             models.UniqueConstraint(fields=["statement", "sl_version"],
 name="statement_appears_once_per_version"),
         ]
 }}}

 The idea behind the model is that we are representing a document that has
 statements in sections. Statements can be moved around in different
 versions, so the relationship between a statement and a section, called
 "statement location", must be tied to a version.

 I have a function under the Version model called "copy". The "copy"
 function produces a copy of the version and recreates the existing
 relationships.

 models.Version (extract):

 {{{
     def copy(self, name: str, public: bool) -> "Version":
         # Make a copy of a published version and return a reference to the
 copy
         # Create the new version object in the Database
         new_version = Version(
             name=name,
             state="DRAFT",
             public=public,
             obsoletes=self,
             authority=self.authority,
         )
         new_version.save()

         return new_version
         <...>

         for statement in self.version_data.statements:
             <...>
             sect = statement.section.filter(version=self).get()
             statement.section.add(sect, through_defaults={"sl_version":
 new_version})
             <...>
 }}}

 When executing the function,  I noticed that the new "StatementLocation"
 objects were not being created. For example:


 {{{
 >>> Version.objects.all()
 <QuerySet [<Version: Document - 2.2>]>
 >>> version = Version.objects.get()
 >>> StatementLocation.objects.count()
 89
 >>> StatementLocation.objects.filter(sl_version=version).count()
 89
 >>> new_verson = version.copy(name="2.3", public=True)
 >>> new_verson
 <Version: Document - 2.3>
 >>> StatementLocation.objects.count()
 89
 >>> StatementLocation.objects.filter(sl_version=new_verson).count()
 0
 >>>
 }}}

 If I try a simplified version, with SQL query logging enabled, I get the
 following:


 {{{
 # Create New Version
 >>> new_version = Version(name="2.3", state="DRAFT", public=True,
 obsoletes=version, authority=version.authority)

 # Save It
 >>> new_version.save()
 (0.002) SELECT "policypublisher_version"."uuid",
 "policypublisher_version"."name", "policypublisher_version"."state",
 "policypublisher_version"."public",
 "policypublisher_version"."published_date",
 "policypublisher_version"."effective_date",
 "policypublisher_version"."obsoletes_id",
 "policypublisher_version"."authority_id" FROM "policypublisher_version"
 WHERE "policypublisher_version"."uuid" =
 'bcbb218ebdc5435b9e5caf879b55cd24' LIMIT 21;
 args=('bcbb218ebdc5435b9e5caf879b55cd24',); alias=default
 (0.014) INSERT INTO "policypublisher_version" ("uuid", "name", "state",
 "public", "published_date", "effective_date", "obsoletes_id",
 "authority_id") VALUES ('bcbb218ebdc5435b9e5caf879b55cd24', '2.3',
 'DRAFT', 1, NULL, NULL, 'be8225c85fab4a0ba8ea879a3d992abe',
 '833c1b9b30964f3a90b045e2a1254c1b');
 args=('bcbb218ebdc5435b9e5caf879b55cd24', '2.3', 'DRAFT', True, None,
 None, 'be8225c85fab4a0ba8ea879a3d992abe',
 '833c1b9b30964f3a90b045e2a1254c1b'); alias=default

 # Get a random statement
 >>> statement=Statement.objects.first()
 (0.003) SELECT "policypublisher_statement"."uuid",
 "policypublisher_statement"."text", "policypublisher_statement"."type",
 "policypublisher_statement"."public",
 "policypublisher_statement"."obsoletes_id" FROM
 "policypublisher_statement" ORDER BY "policypublisher_statement"."uuid"
 ASC LIMIT 1; args=(); alias=default

 # Make sure we got one
 >>> statement
 <Statement: Rules for interpreting the pivFASC-N name>

 # Add the new version to the statement's version list
 >>> statement.version.add(new_version)
 (0.000) BEGIN; args=None; alias=default
 (0.006) INSERT OR IGNORE INTO "policypublisher_statement_version"
 ("statement_id", "version_id") VALUES ('0509afa269604028818dc1f53b8f93d2',
 'bcbb218ebdc5435b9e5caf879b55cd24');
 args=('0509afa269604028818dc1f53b8f93d2',
 'bcbb218ebdc5435b9e5caf879b55cd24'); alias=default

 # get the section for the statement under the old version
 >>> sect=statement.section.filter(version=version).get()
 (0.001) SELECT "policypublisher_section"."uuid",
 "policypublisher_section"."name", "policypublisher_section"."public" FROM
 "policypublisher_section" INNER JOIN "policypublisher_statementlocation"
 ON ("policypublisher_section"."uuid" =
 "policypublisher_statementlocation"."section_id") INNER JOIN
 "policypublisher_section_version" ON ("policypublisher_section"."uuid" =
 "policypublisher_section_version"."section_id") WHERE
 ("policypublisher_statementlocation"."statement_id" =
 '0509afa269604028818dc1f53b8f93d2' AND
 "policypublisher_section_version"."version_id" =
 'be8225c85fab4a0ba8ea879a3d992abe') LIMIT 21;
 args=('0509afa269604028818dc1f53b8f93d2',
 'be8225c85fab4a0ba8ea879a3d992abe'); alias=default

 # Make Sure we got one
 >>> sect
 <Section: Rules for Interpreting Various Name Forms>

 # Add a new section via the through field # NOTE NO INSERT!
 >>> statement.section.add(sect,
 through_defaults={"sl_version":new_version})
 (0.000) BEGIN; args=None; alias=default
 (0.002) SELECT "policypublisher_statementlocation"."section_id" FROM
 "policypublisher_statementlocation" WHERE
 ("policypublisher_statementlocation"."section_id" IN
 ('1cc10c2e5bba4d238d5fabbfdfd72d83') AND
 "policypublisher_statementlocation"."statement_id" =
 '0509afa269604028818dc1f53b8f93d2');
 args=('1cc10c2e5bba4d238d5fabbfdfd72d83',
 '0509afa269604028818dc1f53b8f93d2'); alias=default

 # No new object was created
 >>> StatementLocation.objects.filter(sl_version=new_version).count()
 (0.003) SELECT COUNT(*) AS "__count" FROM
 "policypublisher_statementlocation" WHERE
 "policypublisher_statementlocation"."sl_version_id" =
 'bcbb218ebdc5435b9e5caf879b55cd24';
 args=('bcbb218ebdc5435b9e5caf879b55cd24',); alias=default
 0
 }}}

 On the second to last statement, I believe Django is checking to see
 whether there is an existing M2M relationship, but it is not adding the
 version_id to the query, so it thinks the relationship already exists!

 Note that I can manually create the relationship, so this is not a
 constraints problem:


 {{{
 >>> new_sl = StatementLocation(section=sect, statement=statement,
 sl_version=new_version)
 >>> new_sl.save()
 (0.027) INSERT INTO "policypublisher_statementlocation" ("section_id",
 "statement_id", "sl_version_id") VALUES
 ('1cc10c2e5bba4d238d5fabbfdfd72d83', '0509afa269604028818dc1f53b8f93d2',
 'bcbb218ebdc5435b9e5caf879b55cd24');
 args=['1cc10c2e5bba4d238d5fabbfdfd72d83',
 '0509afa269604028818dc1f53b8f93d2', 'bcbb218ebdc5435b9e5caf879b55cd24'];
 alias=default
 >>> StatementLocation.objects.filter(sl_version=new_version).count()
 (0.001) SELECT COUNT(*) AS "__count" FROM
 "policypublisher_statementlocation" WHERE
 "policypublisher_statementlocation"."sl_version_id" =
 'bcbb218ebdc5435b9e5caf879b55cd24';
 args=('bcbb218ebdc5435b9e5caf879b55cd24',); alias=default
 1
 >>>
 }}}


 The FIX:

 When a ManyToMany relationship is created with a "through" table, and
 through_defaults are provided, that should be part of the search query
 that django uses to determine if the relationship already exists.

 e.g.
 SELECT "policypublisher_statementlocation"."section_id" FROM
 "policypublisher_statementlocation" WHERE
 ("policypublisher_statementlocation"."section_id" IN
 ('1cc10c2e5bba4d238d5fabbfdfd72d83') AND
 "policypublisher_statementlocation"."statement_id" =
 '0509afa269604028818dc1f53b8f93d2') AND
 "policypublisher_statementlocation"."sl_version_id" =
 'bcbb218ebdc5435b9e5caf879b55cd24';

-- 
Ticket URL: <https://code.djangoproject.com/ticket/34207>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

-- 
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion on the web visit 
https://groups.google.com/d/msgid/django-updates/01070184fc54eebb-bec4ad73-25d9-484f-8677-434d801e65d5-000000%40eu-central-1.amazonses.com.

Reply via email to