Code

Ticket #6095: 6095-beta-01.diff

File 6095-beta-01.diff, 21.5 KB (added by floguy, 6 years ago)
Line 
1Index: django/db/models/fields/related.py
2===================================================================
3--- django/db/models/fields/related.py  (revision 6979)
4+++ django/db/models/fields/related.py  (working copy)
5@@ -1,5 +1,5 @@
6 from django.db import connection, transaction
7-from django.db.models import signals, get_model
8+from django.db.models import signals, get_model, get_models
9 from django.db.models.fields import AutoField, Field, IntegerField, PositiveIntegerField, PositiveSmallIntegerField, get_ul_class
10 from django.db.models.related import RelatedObject
11 from django.utils.text import capfirst
12@@ -54,6 +54,21 @@
13     except klass.DoesNotExist:
14         raise validators.ValidationError, _("Please enter a valid %s.") % f.verbose_name
15 
16+def get_reverse_rel_field(from_model, to_model, related_name):
17+    "Gets the related field which points from one model to another."
18+    for field in from_model._meta.fields:
19+        if field.__class__ == ForeignKey:
20+            if field.rel.to == to_model:
21+                return field
22+    return None
23+
24+def get_model_for_db_table(db_table):
25+    "Gets a model class from a db_table string."
26+    for model in get_models():
27+        if model._meta.db_table == db_table:
28+            return model
29+    return None
30+
31 #HACK
32 class RelatedField(object):
33     def contribute_to_class(self, cls, name):
34@@ -267,7 +282,8 @@
35     and adds behavior for many-to-many related objects."""
36     class ManyRelatedManager(superclass):
37         def __init__(self, model=None, core_filters=None, instance=None, symmetrical=None,
38-                join_table=None, source_col_name=None, target_col_name=None):
39+                join_table=None, source_col_name=None, source_attname=None,
40+                target_attname=None, target_col_name=None):
41             super(ManyRelatedManager, self).__init__()
42             self.core_filters = core_filters
43             self.model = model
44@@ -276,6 +292,9 @@
45             self.join_table = join_table
46             self.source_col_name = source_col_name
47             self.target_col_name = target_col_name
48+            self.source_attname = source_attname
49+            self.target_attname = target_attname
50+            self.intermediary_model = get_model_for_db_table(self.join_table.replace('"',''))
51             self._pk_val = self.instance._get_pk_val()
52             if self._pk_val is None:
53                 raise ValueError("%r instance needs to have a primary key value before a many-to-many relationship can be used." % model)
54@@ -340,9 +359,15 @@
55 
56                 # Add the ones that aren't there already
57                 for obj_id in (new_ids - existing_ids):
58-                    cursor.execute("INSERT INTO %s (%s, %s) VALUES (%%s, %%s)" % \
59+                    if self.intermediary_model == None:
60+                        cursor.execute("INSERT INTO %s (%s, %s) VALUES (%%s, %%s)" % \
61                         (self.join_table, source_col_name, target_col_name),
62                         [self._pk_val, obj_id])
63+                    else:
64+                        new_obj = self.intermediary_model()
65+                        setattr(new_obj, self.source_attname, self._pk_val)
66+                        setattr(new_obj, self.target_attname, obj_id)
67+                        new_obj.save()
68                 transaction.commit_unless_managed()
69 
70         def _remove_items(self, source_col_name, target_col_name, *objs):
71@@ -398,14 +423,17 @@
72         RelatedManager = create_many_related_manager(superclass)
73 
74         qn = connection.ops.quote_name
75+        rel_field = self.related.field
76         manager = RelatedManager(
77             model=rel_model,
78             core_filters={'%s__pk' % self.related.field.name: instance._get_pk_val()},
79             instance=instance,
80             symmetrical=False,
81-            join_table=qn(self.related.field.m2m_db_table()),
82-            source_col_name=qn(self.related.field.m2m_reverse_name()),
83-            target_col_name=qn(self.related.field.m2m_column_name())
84+            join_table=qn(rel_field.m2m_db_table()),
85+            source_col_name=qn(rel_field.m2m_reverse_name()),
86+            target_col_name=qn(rel_field.m2m_column_name()),
87+            source_attname=rel_field.m2m_reverse_attname(),
88+            target_attname=rel_field.m2m_attname()
89         )
90 
91         return manager
92@@ -446,7 +474,9 @@
93             symmetrical=(self.field.rel.symmetrical and instance.__class__ == rel_model),
94             join_table=qn(self.field.m2m_db_table()),
95             source_col_name=qn(self.field.m2m_column_name()),
96-            target_col_name=qn(self.field.m2m_reverse_name())
97+            target_col_name=qn(self.field.m2m_reverse_name()),
98+            source_attname=self.field.m2m_attname(),
99+            target_attname=self.field.m2m_reverse_attname()
100         )
101 
102         return manager
103@@ -648,8 +678,11 @@
104             filter_interface=kwargs.pop('filter_interface', None),
105             limit_choices_to=kwargs.pop('limit_choices_to', None),
106             raw_id_admin=kwargs.pop('raw_id_admin', False),
107-            symmetrical=kwargs.pop('symmetrical', True))
108+            symmetrical=kwargs.pop('symmetrical', True),
109+            through=kwargs.pop('through', None))
110         self.db_table = kwargs.pop('db_table', None)
111+        if kwargs['rel'].through:
112+            assert not self.db_table, "Cannot specify a db_table if an intermediary model is used."
113         if kwargs["rel"].raw_id_admin:
114             kwargs.setdefault("validator_list", []).append(self.isValidIDList)
115         Field.__init__(self, **kwargs)
116@@ -672,23 +705,53 @@
117 
118     def _get_m2m_db_table(self, opts):
119         "Function that can be curried to provide the m2m table name for this relation"
120-        if self.db_table:
121+        if self.rel.through != None:
122+            return get_model(opts.app_label, self.rel.through)._meta.db_table
123+        elif self.db_table:
124             return self.db_table
125         else:
126             return '%s_%s' % (opts.db_table, self.name)
127 
128+    def _get_m2m_attname(self, related):
129+        try:
130+            through = get_model(related.opts.app_label, self.rel.through)
131+            field = get_reverse_rel_field(through, related.model, self.rel.related_name)
132+            attname, column = field.get_attname_column()
133+            return attname
134+        except:
135+            return None
136+
137     def _get_m2m_column_name(self, related):
138         "Function that can be curried to provide the source column name for the m2m table"
139         # If this is an m2m relation to self, avoid the inevitable name clash
140-        if related.model == related.parent_model:
141+        if self.rel.through != None:
142+            through = get_model(related.opts.app_label, self.rel.through)
143+            field = get_reverse_rel_field(through, related.model, self.rel.related_name)
144+            attname, column = field.get_attname_column()
145+            return column
146+        elif related.model == related.parent_model:
147             return 'from_' + related.model._meta.object_name.lower() + '_id'
148         else:
149             return related.model._meta.object_name.lower() + '_id'
150 
151+    def _get_m2m_reverse_attname(self, related):
152+        try:
153+            through = get_model(related.opts.app_label, self.rel.through)
154+            field = get_reverse_rel_field(through, related.parent_model, self.rel.related_name)
155+            attname, column = field.get_attname_column()
156+            return attname
157+        except:
158+            return None
159+
160     def _get_m2m_reverse_name(self, related):
161         "Function that can be curried to provide the related column name for the m2m table"
162         # If this is an m2m relation to self, avoid the inevitable name clash
163-        if related.model == related.parent_model:
164+        if self.rel.through != None:
165+            through = get_model(related.opts.app_label, self.rel.through)
166+            field = get_reverse_rel_field(through, related.parent_model, self.rel.related_name)
167+            attname, column = field.get_attname_column()
168+            return column
169+        elif related.model == related.parent_model:
170             return 'to_' + related.parent_model._meta.object_name.lower() + '_id'
171         else:
172             return related.parent_model._meta.object_name.lower() + '_id'
173@@ -745,6 +808,8 @@
174         # Set up the accessors for the column names on the m2m table
175         self.m2m_column_name = curry(self._get_m2m_column_name, related)
176         self.m2m_reverse_name = curry(self._get_m2m_reverse_name, related)
177+        self.m2m_attname = curry(self._get_m2m_attname, related)
178+        self.m2m_reverse_attname = curry(self._get_m2m_reverse_attname, related)
179 
180     def set_attributes_from_rel(self):
181         pass
182@@ -809,7 +874,8 @@
183 
184 class ManyToManyRel(object):
185     def __init__(self, to, num_in_admin=0, related_name=None,
186-        filter_interface=None, limit_choices_to=None, raw_id_admin=False, symmetrical=True):
187+        filter_interface=None, limit_choices_to=None, raw_id_admin=False, symmetrical=True,
188+        through = None):
189         self.to = to
190         self.num_in_admin = num_in_admin
191         self.related_name = related_name
192@@ -821,5 +887,6 @@
193         self.raw_id_admin = raw_id_admin
194         self.symmetrical = symmetrical
195         self.multiple = True
196+        self.through = through
197 
198         assert not (self.raw_id_admin and self.filter_interface), "ManyToManyRels may not use both raw_id_admin and filter_interface"
199Index: django/core/management/validation.py
200===================================================================
201--- django/core/management/validation.py        (revision 6979)
202+++ django/core/management/validation.py        (working copy)
203@@ -104,6 +104,8 @@
204                         if r.get_accessor_name() == rel_query_name:
205                             e.add(opts, "Reverse query name for field '%s' clashes with related field '%s.%s'. Add a related_name argument to the definition for '%s'." % (f.name, rel_opts.object_name, r.get_accessor_name(), f.name))
206 
207+        seen_intermediary_signatures = []
208+
209         for i, f in enumerate(opts.many_to_many):
210             # Check to see if the related m2m field will clash with any
211             # existing fields, m2m fields, m2m related objects or related objects
212@@ -113,6 +115,34 @@
213                 # so skip the next section
214                 if isinstance(f.rel.to, (str, unicode)):
215                     continue
216+            if hasattr(f.rel, 'through') and f.rel.through != None:
217+                intermediary_model = None
218+                for model in models.get_models():
219+                    if model._meta.module_name == f.rel.through.lower():
220+                        intermediary_model = model
221+                if intermediary_model == None:
222+                    e.add(opts, "%s has a manually-defined m2m relationship through a model (%s) which does not exist." % (f.name, f.rel.through))
223+                else:
224+                    signature = (f.rel.to, cls, intermediary_model)
225+                    if signature in seen_intermediary_signatures:
226+                        e.add(opts, "%s has two manually defined m2m relationships through the same model (%s), which is not possible.  Please use a field on your intermediary model instead." % (cls._meta.object_name, intermediary_model._meta.object_name))
227+                    else:
228+                        seen_intermediary_signatures.append(signature)
229+                    seen_related_fk, seen_this_fk, is_related = False, False, False
230+                    for field in intermediary_model._meta.fields:
231+                        if field.rel:
232+                            if field.rel.to == f.rel.to:
233+                                is_related = True
234+                                seen_related_fk = True
235+                            elif field.rel.to == cls:
236+                                is_related = True
237+                                seen_this_fk = True
238+                        if is_related == True:
239+                            is_related = False
240+                            if field.default == None and field.null == False:
241+                                e.add(opts, "%s is an intermediary model which has a non-nullable field (%s) with no default value" % (intermediary_model._meta.object_name, field.name))
242+                    if not seen_related_fk or not seen_this_fk:
243+                        e.add(opts, "%s has a manualy-defined m2m relationship through a model (%s) which does not have foreign keys to %s and %s" % (f.name, f.rel.through, f.rel.to._meta.object_name, cls._meta.object_name))
244 
245             rel_opts = f.rel.to._meta
246             rel_name = RelatedObject(f.rel.to, cls, f).get_accessor_name()
247Index: django/core/management/sql.py
248===================================================================
249--- django/core/management/sql.py       (revision 6979)
250+++ django/core/management/sql.py       (working copy)
251@@ -352,7 +352,7 @@
252     qn = connection.ops.quote_name
253     inline_references = connection.features.inline_fk_references
254     for f in opts.many_to_many:
255-        if not isinstance(f.rel, generic.GenericRel):
256+        if not isinstance(f.rel, generic.GenericRel) and getattr(f.rel, 'through', None) == None:
257             tablespace = f.db_tablespace or opts.db_tablespace
258             if tablespace and connection.features.supports_tablespaces and connection.features.autoindexes_primary_keys:
259                 tablespace_sql = ' ' + connection.ops.tablespace_sql(tablespace, inline=True)
260Index: tests/modeltests/invalid_models/models.py
261===================================================================
262--- tests/modeltests/invalid_models/models.py   (revision 6979)
263+++ tests/modeltests/invalid_models/models.py   (working copy)
264@@ -111,7 +111,32 @@
265 class MissingRelations(models.Model):
266     rel1 = models.ForeignKey("Rel1")
267     rel2 = models.ManyToManyField("Rel2")
268+   
269+class MissingManualM2MModel(models.Model):
270+    name = models.CharField(max_length=5)
271+    missing_m2m = models.ManyToManyField(Model, through="MissingM2MModel")
272+   
273+class Person(models.Model):
274+    name = models.CharField(max_length=5)
275 
276+class Group(models.Model):
277+    name = models.CharField(max_length=5)
278+    primary = models.ManyToManyField(Person, through="Membership", related_name="primary")
279+    secondary = models.ManyToManyField(Person, through="Membership", related_name="secondary")
280+
281+class GroupTwo(models.Model):
282+    name = models.CharField(max_length=5)
283+    primary = models.ManyToManyField(Person, through="Membership")
284+    secondary = models.ManyToManyField(Group, through="MembershipMissingFK")
285+
286+class Membership(models.Model):
287+    person = models.ForeignKey(Person)
288+    group = models.ForeignKey(Group)
289+    not_default_or_null = models.CharField(max_length=5)
290+
291+class MembershipMissingFK(models.Model):
292+    person = models.ForeignKey(Person)
293+
294 model_errors = """invalid_models.fielderrors: "charfield": CharFields require a "max_length" attribute.
295 invalid_models.fielderrors: "decimalfield": DecimalFields require a "decimal_places" attribute.
296 invalid_models.fielderrors: "decimalfield": DecimalFields require a "max_digits" attribute.
297@@ -197,4 +222,8 @@
298 invalid_models.selfclashm2m: Reverse query name for m2m field 'm2m_4' clashes with field 'SelfClashM2M.selfclashm2m'. Add a related_name argument to the definition for 'm2m_4'.
299 invalid_models.missingrelations: 'rel2' has m2m relation with model Rel2, which has not been installed
300 invalid_models.missingrelations: 'rel1' has relation with model Rel1, which has not been installed
301+invalid_models.group: Group has two manually defined m2m relationships through the same model (Membership), which is not possible.  Please use a field on your intermediary model instead.
302+invalid_models.missingmanualm2mmodel: missing_m2m has a manually-defined m2m relationship through a model (MissingM2MModel) which does not exist.
303+invalid_models.grouptwo: primary has a manualy-defined m2m relationship through a model (Membership) which does not have foreign keys to Person and GroupTwo
304+invalid_models.grouptwo: secondary has a manualy-defined m2m relationship through a model (MembershipMissingFK) which does not have foreign keys to Group and GroupTwo
305 """
306Index: tests/modeltests/m2m_manual/__init__.py
307===================================================================
308Index: tests/modeltests/m2m_manual/models.py
309===================================================================
310--- tests/modeltests/m2m_manual/models.py       (revision 0)
311+++ tests/modeltests/m2m_manual/models.py       (revision 0)
312@@ -0,0 +1,152 @@
313+from django.db import models
314+from datetime import datetime
315+
316+# M2M described on one of the models
317+class Person(models.Model):
318+    name = models.CharField(max_length=128)
319+
320+    def __unicode__(self):
321+        return self.name
322+
323+class Group(models.Model):
324+    name = models.CharField(max_length=128)
325+    members = models.ManyToManyField(Person, through='Membership')
326+    custom_members = models.ManyToManyField(Person, through='CustomMembership', related_name="custom")
327+   
328+    def __unicode__(self):
329+        return self.name
330+
331+class Membership(models.Model):
332+    person = models.ForeignKey(Person)
333+    group = models.ForeignKey(Group)
334+    date_joined = models.DateTimeField(default=datetime.now)
335+    invite_reason = models.CharField(max_length=64, null=True, blank=True)
336+   
337+    def __unicode__(self):
338+        return "%s is a member of %s" % (self.person.name, self.group.name)
339+
340+class CustomMembership(models.Model):
341+    person = models.ForeignKey(Person, db_column="custom_person_column", related_name="custom_person_related_name")
342+    group = models.ForeignKey(Group)
343+    weird_fk = models.ForeignKey(Membership, null=True)
344+    date_joined = models.DateTimeField(default=datetime.now)
345+   
346+    def __unicode__(self):
347+        return "%s is a member of %s" % (self.person.name, self.group.name)
348+
349+__test__ = {'API_TESTS':"""
350+>>> from datetime import datetime
351+
352+>>> bob = Person(name = 'Bob')
353+>>> bob.save()
354+>>> jim = Person(name = 'Jim')
355+>>> jim.save()
356+>>> jane = Person(name = 'Jane')
357+>>> jane.save()
358+>>> rock = Group(name = 'Rock')
359+>>> rock.save()
360+>>> roll = Group(name = 'Roll')
361+>>> roll.save()
362+
363+>>> rock.members.add(jim, jane)
364+>>> rock.members.all()
365+[<Person: Jim>, <Person: Jane>]
366+
367+>>> roll.members.add(bob, jim)
368+>>> roll.members.all()
369+[<Person: Bob>, <Person: Jim>]
370+
371+>>> jane.group_set.all()
372+[<Group: Rock>]
373+
374+>>> jane.group_set.add(roll)
375+>>> jane.group_set.all()
376+[<Group: Rock>, <Group: Roll>]
377+
378+>>> jim.group_set.all()
379+[<Group: Rock>, <Group: Roll>]
380+
381+# Check to make sure that the associated Membership object is created.
382+>>> m = Membership.objects.get(person = jane, group = rock)
383+>>> m
384+<Membership: Jane is a member of Rock>
385+
386+# Setting some date_joined dates
387+>>> m.invite_reason = "She was just awesome."
388+>>> m.date_joined = datetime(2006, 1, 1)
389+>>> m.save()
390+
391+>>> m = Membership.objects.get(person = jane, group = roll)
392+>>> m.date_joined = datetime(2004, 1, 1)
393+>>> m.save()
394+
395+>>> m = Membership.objects.get(person = bob, group = roll)
396+>>> m.date_joined = datetime(2004, 1, 1)
397+>>> m.save()
398+
399+>>> Membership.objects.filter(person = jim)
400+[<Membership: Jim is a member of Rock>, <Membership: Jim is a member of Roll>]
401+
402+>>> rock.custom_members.add(bob)
403+>>> rock.custom_members.all()
404+[<Person: Bob>]
405+
406+>>> jim.custom.add(rock)
407+>>> rock.custom_members.all()
408+[<Person: Bob>, <Person: Jim>]
409+
410+>>> jim.custom.all()
411+[<Group: Rock>]
412+
413+>>> jim.custom_person_related_name.all()
414+[<CustomMembership: Jim is a member of Rock>]
415+
416+###QUERY TESTS###
417+# Queries involving the related model (Person, in the case of Group) use its
418+# attname
419+>>> Group.objects.filter(members__name='Bob')
420+[<Group: Roll>]
421+
422+# Queries involving the relationship model (Membership, in the case of Group)
423+# use its model name
424+>>> Group.objects.filter(membership__invite_reason = "She was just awesome.")
425+[<Group: Rock>]
426+
427+# Queries involving the reverse related model (Group, in the case of Person)
428+# use its model name
429+>>> Person.objects.filter(group__name="Rock")
430+[<Person: Jim>, <Person: Jane>]
431+
432+# If the m2m field has specified a related_name, using that will work.
433+>>> Person.objects.filter(custom__name="Rock")
434+[<Person: Bob>, <Person: Jim>]
435+
436+# Queries involving the relationship model (Membership, in the case of Group)
437+# use its model name
438+>>> Person.objects.filter(membership__invite_reason = "She was just awesome.")
439+[<Person: Jane>]
440+
441+# Let's see all of the groups that Jane joined after 1 Jan 2005:
442+>>> Group.objects.filter(membership__date_joined__gt = datetime(2005, 1, 1),
443+... membership__person = jane)
444+[<Group: Rock>]
445+
446+# Now let's see all of the people that have joined Rock since 1 Jan 2005:
447+>>> Person.objects.filter(membership__date_joined__gt = datetime(2005, 1, 1),
448+... membership__group = rock)
449+[<Person: Jim>, <Person: Jane>]
450+
451+# Conceivably, queries through membership could return non-unique querysets.
452+# To demonstrate this, query for all people who have joined a group after 2004:
453+>>> Person.objects.filter(membership__date_joined__gt = datetime(2004, 1, 1))
454+[<Person: Jim>, <Person: Jim>, <Person: Jane>]
455+
456+# Jim showed up twice, because he joined two groups ('Rock', and 'Roll'):
457+>>> [(m.person.name, m.group.name) for m in
458+... Membership.objects.filter(date_joined__gt = datetime(2004, 1, 1))]
459+[(u'Jim', u'Rock'), (u'Jim', u'Roll'), (u'Jane', u'Rock')]
460+
461+# QuerySet's distinct() method can correct this problem.
462+>>> Person.objects.filter(membership__date_joined__gt = datetime(2004, 1, 1)).distinct()
463+[<Person: Jim>, <Person: Jane>]
464+"""}
465\ No newline at end of file
466Index: AUTHORS
467===================================================================
468--- AUTHORS     (revision 6979)
469+++ AUTHORS     (working copy)
470@@ -351,6 +351,7 @@
471     ymasuda@ethercube.com
472     Jarek Zgoda <jarek.zgoda@gmail.com>
473     Cheng Zhang
474+    Eric Florenzano <floguy@gmail.com>
475 
476 A big THANK YOU goes to:
477