Fixed #12881: Corrected handling of inherited unique constraints. Thanks for report fgaudin.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@12797 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Karen Tracey 2010-03-16 19:32:11 +00:00
parent 883329ecb3
commit 47a822207f
6 changed files with 245 additions and 150 deletions

View File

@ -707,37 +707,50 @@ class Model(object):
if exclude is None: if exclude is None:
exclude = [] exclude = []
unique_checks = [] unique_checks = []
for check in self._meta.unique_together:
unique_togethers = [(self.__class__, self._meta.unique_together)]
for parent_class in self._meta.parents.keys():
if parent_class._meta.unique_together:
unique_togethers.append((parent_class, parent_class._meta.unique_together))
for model_class, unique_together in unique_togethers:
for check in unique_together:
for name in check: for name in check:
# If this is an excluded field, don't add this check. # If this is an excluded field, don't add this check.
if name in exclude: if name in exclude:
break break
else: else:
unique_checks.append(tuple(check)) unique_checks.append((model_class, tuple(check)))
# These are checks for the unique_for_<date/year/month>. # These are checks for the unique_for_<date/year/month>.
date_checks = [] date_checks = []
# Gather a list of checks for fields declared as unique and add them to # Gather a list of checks for fields declared as unique and add them to
# the list of checks. # the list of checks.
for f in self._meta.fields:
fields_with_class = [(self.__class__, self._meta.local_fields)]
for parent_class in self._meta.parents.keys():
fields_with_class.append((parent_class, parent_class._meta.local_fields))
for model_class, fields in fields_with_class:
for f in fields:
name = f.name name = f.name
if name in exclude: if name in exclude:
continue continue
if f.unique: if f.unique:
unique_checks.append((name,)) unique_checks.append((model_class, (name,)))
if f.unique_for_date: if f.unique_for_date:
date_checks.append(('date', name, f.unique_for_date)) date_checks.append((model_class, 'date', name, f.unique_for_date))
if f.unique_for_year: if f.unique_for_year:
date_checks.append(('year', name, f.unique_for_year)) date_checks.append((model_class, 'year', name, f.unique_for_year))
if f.unique_for_month: if f.unique_for_month:
date_checks.append(('month', name, f.unique_for_month)) date_checks.append((model_class, 'month', name, f.unique_for_month))
return unique_checks, date_checks return unique_checks, date_checks
def _perform_unique_checks(self, unique_checks): def _perform_unique_checks(self, unique_checks):
errors = {} errors = {}
for unique_check in unique_checks: for model_class, unique_check in unique_checks:
# Try to look up an existing object with the same values as this # Try to look up an existing object with the same values as this
# object's values for all the unique field. # object's values for all the unique field.
@ -757,7 +770,7 @@ class Model(object):
if len(unique_check) != len(lookup_kwargs.keys()): if len(unique_check) != len(lookup_kwargs.keys()):
continue continue
qs = self.__class__._default_manager.filter(**lookup_kwargs) qs = model_class._default_manager.filter(**lookup_kwargs)
# Exclude the current object from the query if we are editing an # Exclude the current object from the query if we are editing an
# instance (as opposed to creating a new one) # instance (as opposed to creating a new one)
@ -769,13 +782,13 @@ class Model(object):
key = unique_check[0] key = unique_check[0]
else: else:
key = NON_FIELD_ERRORS key = NON_FIELD_ERRORS
errors.setdefault(key, []).append(self.unique_error_message(unique_check)) errors.setdefault(key, []).append(self.unique_error_message(model_class, unique_check))
return errors return errors
def _perform_date_checks(self, date_checks): def _perform_date_checks(self, date_checks):
errors = {} errors = {}
for lookup_type, field, unique_for in date_checks: for model_class, lookup_type, field, unique_for in date_checks:
lookup_kwargs = {} lookup_kwargs = {}
# there's a ticket to add a date lookup, we can remove this special # there's a ticket to add a date lookup, we can remove this special
# case if that makes it's way in # case if that makes it's way in
@ -788,7 +801,7 @@ class Model(object):
lookup_kwargs['%s__%s' % (unique_for, lookup_type)] = getattr(date, lookup_type) lookup_kwargs['%s__%s' % (unique_for, lookup_type)] = getattr(date, lookup_type)
lookup_kwargs[field] = getattr(self, field) lookup_kwargs[field] = getattr(self, field)
qs = self.__class__._default_manager.filter(**lookup_kwargs) qs = model_class._default_manager.filter(**lookup_kwargs)
# Exclude the current object from the query if we are editing an # Exclude the current object from the query if we are editing an
# instance (as opposed to creating a new one) # instance (as opposed to creating a new one)
if not getattr(self, '_adding', False) and self.pk is not None: if not getattr(self, '_adding', False) and self.pk is not None:
@ -808,8 +821,8 @@ class Model(object):
'lookup': lookup_type, 'lookup': lookup_type,
} }
def unique_error_message(self, unique_check): def unique_error_message(self, model_class, unique_check):
opts = self._meta opts = model_class._meta
model_name = capfirst(opts.verbose_name) model_name = capfirst(opts.verbose_name)
# A unique field # A unique field

View File

@ -488,7 +488,7 @@ class BaseModelFormSet(BaseFormSet):
errors = [] errors = []
# Do each of the unique checks (unique and unique_together) # Do each of the unique checks (unique and unique_together)
for unique_check in all_unique_checks: for uclass, unique_check in all_unique_checks:
seen_data = set() seen_data = set()
for form in self.forms: for form in self.forms:
# if the form doesn't have cleaned_data then we ignore it, # if the form doesn't have cleaned_data then we ignore it,
@ -512,7 +512,7 @@ class BaseModelFormSet(BaseFormSet):
# iterate over each of the date checks now # iterate over each of the date checks now
for date_check in all_date_checks: for date_check in all_date_checks:
seen_data = set() seen_data = set()
lookup, field, unique_for = date_check uclass, lookup, field, unique_for = date_check
for form in self.forms: for form in self.forms:
# if the form doesn't have cleaned_data then we ignore it, # if the form doesn't have cleaned_data then we ignore it,
# it's already invalid # it's already invalid
@ -556,9 +556,9 @@ class BaseModelFormSet(BaseFormSet):
def get_date_error_message(self, date_check): def get_date_error_message(self, date_check):
return ugettext("Please correct the duplicate data for %(field_name)s " return ugettext("Please correct the duplicate data for %(field_name)s "
"which must be unique for the %(lookup)s in %(date_field)s.") % { "which must be unique for the %(lookup)s in %(date_field)s.") % {
'field_name': date_check[1], 'field_name': date_check[2],
'date_field': date_check[2], 'date_field': date_check[3],
'lookup': unicode(date_check[0]), 'lookup': unicode(date_check[1]),
} }
def get_form_error(self): def get_form_error(self):

View File

@ -0,0 +1,32 @@
from django.forms import ModelForm
from models import Product, Price, Book, DerivedBook, ExplicitPK, Post, DerivedPost
class ProductForm(ModelForm):
class Meta:
model = Product
class PriceForm(ModelForm):
class Meta:
model = Price
class BookForm(ModelForm):
class Meta:
model = Book
class DerivedBookForm(ModelForm):
class Meta:
model = DerivedBook
class ExplicitPKForm(ModelForm):
class Meta:
model = ExplicitPK
fields = ('key', 'desc',)
class PostForm(ModelForm):
class Meta:
model = Post
class DerivedPostForm(ModelForm):
class Meta:
model = DerivedPost

View File

@ -181,6 +181,18 @@ class Book(models.Model):
class Meta: class Meta:
unique_together = ('title', 'author') unique_together = ('title', 'author')
class BookXtra(models.Model):
isbn = models.CharField(max_length=16, unique=True)
suffix1 = models.IntegerField(blank=True, default=0)
suffix2 = models.IntegerField(blank=True, default=0)
class Meta:
unique_together = (('suffix1', 'suffix2'))
abstract = True
class DerivedBook(Book, BookXtra):
pass
class ExplicitPK(models.Model): class ExplicitPK(models.Model):
key = models.CharField(max_length=20, primary_key=True) key = models.CharField(max_length=20, primary_key=True)
desc = models.CharField(max_length=20, blank=True, unique=True) desc = models.CharField(max_length=20, blank=True, unique=True)
@ -199,6 +211,9 @@ class Post(models.Model):
def __unicode__(self): def __unicode__(self):
return self.name return self.name
class DerivedPost(Post):
pass
class BigInt(models.Model): class BigInt(models.Model):
biggie = models.BigIntegerField() biggie = models.BigIntegerField()
@ -1424,41 +1439,6 @@ True
>>> f.cleaned_data >>> f.cleaned_data
{'field': u'1'} {'field': u'1'}
# unique/unique_together validation
>>> class ProductForm(ModelForm):
... class Meta:
... model = Product
>>> form = ProductForm({'slug': 'teddy-bear-blue'})
>>> form.is_valid()
True
>>> obj = form.save()
>>> obj
<Product: teddy-bear-blue>
>>> form = ProductForm({'slug': 'teddy-bear-blue'})
>>> form.is_valid()
False
>>> form._errors
{'slug': [u'Product with this Slug already exists.']}
>>> form = ProductForm({'slug': 'teddy-bear-blue'}, instance=obj)
>>> form.is_valid()
True
# ModelForm test of unique_together constraint
>>> class PriceForm(ModelForm):
... class Meta:
... model = Price
>>> form = PriceForm({'price': '6.00', 'quantity': '1'})
>>> form.is_valid()
True
>>> form.save()
<Price: 1 for 6.00>
>>> form = PriceForm({'price': '6.00', 'quantity': '1'})
>>> form.is_valid()
False
>>> form._errors
{'__all__': [u'Price with this Price and Quantity already exists.']}
This Price instance generated by this form is not valid because the quantity This Price instance generated by this form is not valid because the quantity
field is required, but the form is valid because the field is excluded from field is required, but the form is valid because the field is excluded from
the form. This is for backwards compatibility. the form. This is for backwards compatibility.
@ -1495,51 +1475,6 @@ True
>>> form.instance.pk is None >>> form.instance.pk is None
True True
# Unique & unique together with null values
>>> class BookForm(ModelForm):
... class Meta:
... model = Book
>>> w = Writer.objects.get(name='Mike Royko')
>>> form = BookForm({'title': 'I May Be Wrong But I Doubt It', 'author' : w.pk})
>>> form.is_valid()
True
>>> form.save()
<Book: Book object>
>>> form = BookForm({'title': 'I May Be Wrong But I Doubt It', 'author' : w.pk})
>>> form.is_valid()
False
>>> form._errors
{'__all__': [u'Book with this Title and Author already exists.']}
>>> form = BookForm({'title': 'I May Be Wrong But I Doubt It'})
>>> form.is_valid()
True
>>> form.save()
<Book: Book object>
>>> form = BookForm({'title': 'I May Be Wrong But I Doubt It'})
>>> form.is_valid()
True
# Test for primary_key being in the form and failing validation.
>>> class ExplicitPKForm(ModelForm):
... class Meta:
... model = ExplicitPK
... fields = ('key', 'desc',)
>>> form = ExplicitPKForm({'key': u'', 'desc': u'' })
>>> form.is_valid()
False
# Ensure keys and blank character strings are tested for uniqueness.
>>> form = ExplicitPKForm({'key': u'key1', 'desc': u''})
>>> form.is_valid()
True
>>> form.save()
<ExplicitPK: key1>
>>> form = ExplicitPKForm({'key': u'key1', 'desc': u''})
>>> form.is_valid()
False
>>> sorted(form.errors.items())
[('__all__', [u'Explicit pk with this Key and Desc already exists.']), ('desc', [u'Explicit pk with this Desc already exists.']), ('key', [u'Explicit pk with this Key already exists.'])]
# Choices on CharField and IntegerField # Choices on CharField and IntegerField
>>> class ArticleForm(ModelForm): >>> class ArticleForm(ModelForm):
... class Meta: ... class Meta:
@ -1605,38 +1540,6 @@ ValidationError: [u'Select a valid choice. z is not one of the available choices
<tr><th><label for="id_description">Description:</label></th><td><input type="text" name="description" id="id_description" /></td></tr> <tr><th><label for="id_description">Description:</label></th><td><input type="text" name="description" id="id_description" /></td></tr>
<tr><th><label for="id_url">The URL:</label></th><td><input id="id_url" type="text" name="url" maxlength="40" /></td></tr> <tr><th><label for="id_url">The URL:</label></th><td><input id="id_url" type="text" name="url" maxlength="40" /></td></tr>
### Validation on unique_for_date
>>> p = Post.objects.create(title="Django 1.0 is released", slug="Django 1.0", subtitle="Finally", posted=datetime.date(2008, 9, 3))
>>> class PostForm(ModelForm):
... class Meta:
... model = Post
>>> f = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-03'})
>>> f.is_valid()
False
>>> f.errors
{'title': [u'Title must be unique for Posted date.']}
>>> f = PostForm({'title': "Work on Django 1.1 begins", 'posted': '2008-09-03'})
>>> f.is_valid()
True
>>> f = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-04'})
>>> f.is_valid()
True
>>> f = PostForm({'slug': "Django 1.0", 'posted': '2008-01-01'})
>>> f.is_valid()
False
>>> f.errors
{'slug': [u'Slug must be unique for Posted year.']}
>>> f = PostForm({'subtitle': "Finally", 'posted': '2008-09-30'})
>>> f.is_valid()
False
>>> f.errors
{'subtitle': [u'Subtitle must be unique for Posted month.']}
>>> f = PostForm({'subtitle': "Finally", "title": "Django 1.0 is released", "slug": "Django 1.0", 'posted': '2008-09-03'}, instance=p)
>>> f.is_valid()
True
# Clean up # Clean up
>>> import shutil >>> import shutil
>>> shutil.rmtree(temp_storage_dir) >>> shutil.rmtree(temp_storage_dir)

View File

@ -1,6 +1,8 @@
import datetime
from django.test import TestCase from django.test import TestCase
from django import forms from django import forms
from models import Category from models import Category, Writer, Book, DerivedBook, Post
from mforms import ProductForm, PriceForm, BookForm, DerivedBookForm, ExplicitPKForm, PostForm, DerivedPostForm
class IncompleteCategoryFormWithFields(forms.ModelForm): class IncompleteCategoryFormWithFields(forms.ModelForm):
@ -35,3 +37,140 @@ class ValidationTest(TestCase):
form = IncompleteCategoryFormWithExclude(data={'name': 'some name', 'slug': 'some-slug'}) form = IncompleteCategoryFormWithExclude(data={'name': 'some name', 'slug': 'some-slug'})
assert form.is_valid() assert form.is_valid()
# unique/unique_together validation
class UniqueTest(TestCase):
def setUp(self):
self.writer = Writer.objects.create(name='Mike Royko')
def test_simple_unique(self):
form = ProductForm({'slug': 'teddy-bear-blue'})
self.assertTrue(form.is_valid())
obj = form.save()
form = ProductForm({'slug': 'teddy-bear-blue'})
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['slug'], [u'Product with this Slug already exists.'])
form = ProductForm({'slug': 'teddy-bear-blue'}, instance=obj)
self.assertTrue(form.is_valid())
def test_unique_together(self):
"""ModelForm test of unique_together constraint"""
form = PriceForm({'price': '6.00', 'quantity': '1'})
self.assertTrue(form.is_valid())
form.save()
form = PriceForm({'price': '6.00', 'quantity': '1'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['__all__'], [u'Price with this Price and Quantity already exists.'])
def test_unique_null(self):
title = 'I May Be Wrong But I Doubt It'
form = BookForm({'title': title, 'author': self.writer.pk})
self.assertTrue(form.is_valid())
form.save()
form = BookForm({'title': title, 'author': self.writer.pk})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['__all__'], [u'Book with this Title and Author already exists.'])
form = BookForm({'title': title})
self.assertTrue(form.is_valid())
form.save()
form = BookForm({'title': title})
self.assertTrue(form.is_valid())
def test_inherited_unique(self):
title = 'Boss'
Book.objects.create(title=title, author=self.writer, special_id=1)
form = DerivedBookForm({'title': 'Other', 'author': self.writer.pk, 'special_id': u'1', 'isbn': '12345'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['special_id'], [u'Book with this Special id already exists.'])
def test_inherited_unique_together(self):
title = 'Boss'
form = BookForm({'title': title, 'author': self.writer.pk})
self.assertTrue(form.is_valid())
form.save()
form = DerivedBookForm({'title': title, 'author': self.writer.pk, 'isbn': '12345'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['__all__'], [u'Book with this Title and Author already exists.'])
def test_abstract_inherited_unique(self):
title = 'Boss'
isbn = '12345'
dbook = DerivedBook.objects.create(title=title, author=self.writer, isbn=isbn)
form = DerivedBookForm({'title': 'Other', 'author': self.writer.pk, 'isbn': isbn})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['isbn'], [u'Derived book with this Isbn already exists.'])
def test_abstract_inherited_unique_together(self):
title = 'Boss'
isbn = '12345'
dbook = DerivedBook.objects.create(title=title, author=self.writer, isbn=isbn)
form = DerivedBookForm({'title': 'Other', 'author': self.writer.pk, 'isbn': '9876', 'suffix1': u'0', 'suffix2': u'0'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['__all__'], [u'Derived book with this Suffix1 and Suffix2 already exists.'])
def test_explicitpk_unspecified(self):
"""Test for primary_key being in the form and failing validation."""
form = ExplicitPKForm({'key': u'', 'desc': u'' })
self.assertFalse(form.is_valid())
def test_explicitpk_unique(self):
"""Ensure keys and blank character strings are tested for uniqueness."""
form = ExplicitPKForm({'key': u'key1', 'desc': u''})
self.assertTrue(form.is_valid())
form.save()
form = ExplicitPKForm({'key': u'key1', 'desc': u''})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 3)
self.assertEqual(form.errors['__all__'], [u'Explicit pk with this Key and Desc already exists.'])
self.assertEqual(form.errors['desc'], [u'Explicit pk with this Desc already exists.'])
self.assertEqual(form.errors['key'], [u'Explicit pk with this Key already exists.'])
def test_unique_for_date(self):
p = Post.objects.create(title="Django 1.0 is released",
slug="Django 1.0", subtitle="Finally", posted=datetime.date(2008, 9, 3))
form = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-03'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['title'], [u'Title must be unique for Posted date.'])
form = PostForm({'title': "Work on Django 1.1 begins", 'posted': '2008-09-03'})
self.assertTrue(form.is_valid())
form = PostForm({'title': "Django 1.0 is released", 'posted': '2008-09-04'})
self.assertTrue(form.is_valid())
form = PostForm({'slug': "Django 1.0", 'posted': '2008-01-01'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['slug'], [u'Slug must be unique for Posted year.'])
form = PostForm({'subtitle': "Finally", 'posted': '2008-09-30'})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors['subtitle'], [u'Subtitle must be unique for Posted month.'])
form = PostForm({'subtitle': "Finally", "title": "Django 1.0 is released",
"slug": "Django 1.0", 'posted': '2008-09-03'}, instance=p)
self.assertTrue(form.is_valid())
def test_inherited_unique_for_date(self):
p = Post.objects.create(title="Django 1.0 is released",
slug="Django 1.0", subtitle="Finally", posted=datetime.date(2008, 9, 3))
form = DerivedPostForm({'title': "Django 1.0 is released", 'posted': '2008-09-03'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['title'], [u'Title must be unique for Posted date.'])
form = DerivedPostForm({'title': "Work on Django 1.1 begins", 'posted': '2008-09-03'})
self.assertTrue(form.is_valid())
form = DerivedPostForm({'title': "Django 1.0 is released", 'posted': '2008-09-04'})
self.assertTrue(form.is_valid())
form = DerivedPostForm({'slug': "Django 1.0", 'posted': '2008-01-01'})
self.assertFalse(form.is_valid())
self.assertEqual(len(form.errors), 1)
self.assertEqual(form.errors['slug'], [u'Slug must be unique for Posted year.'])
form = DerivedPostForm({'subtitle': "Finally", 'posted': '2008-09-30'})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors['subtitle'], [u'Subtitle must be unique for Posted month.'])
form = DerivedPostForm({'subtitle': "Finally", "title": "Django 1.0 is released",
"slug": "Django 1.0", 'posted': '2008-09-03'}, instance=p)
self.assertTrue(form.is_valid())

View File

@ -9,26 +9,34 @@ class GetUniqueCheckTests(unittest.TestCase):
def test_unique_fields_get_collected(self): def test_unique_fields_get_collected(self):
m = UniqueFieldsModel() m = UniqueFieldsModel()
self.assertEqual( self.assertEqual(
([('id',), ('unique_charfield',), ('unique_integerfield',)], []), ([(UniqueFieldsModel, ('id',)),
(UniqueFieldsModel, ('unique_charfield',)),
(UniqueFieldsModel, ('unique_integerfield',))],
[]),
m._get_unique_checks() m._get_unique_checks()
) )
def test_unique_together_gets_picked_up_and_converted_to_tuple(self): def test_unique_together_gets_picked_up_and_converted_to_tuple(self):
m = UniqueTogetherModel() m = UniqueTogetherModel()
self.assertEqual( self.assertEqual(
([('ifield', 'cfield',),('ifield', 'efield'), ('id',), ], []), ([(UniqueTogetherModel, ('ifield', 'cfield',)),
(UniqueTogetherModel, ('ifield', 'efield')),
(UniqueTogetherModel, ('id',)), ],
[]),
m._get_unique_checks() m._get_unique_checks()
) )
def test_primary_key_is_considered_unique(self): def test_primary_key_is_considered_unique(self):
m = CustomPKModel() m = CustomPKModel()
self.assertEqual(([('my_pk_field',)], []), m._get_unique_checks()) self.assertEqual(([(CustomPKModel, ('my_pk_field',))], []), m._get_unique_checks())
def test_unique_for_date_gets_picked_up(self): def test_unique_for_date_gets_picked_up(self):
m = UniqueForDateModel() m = UniqueForDateModel()
self.assertEqual(( self.assertEqual((
[('id',)], [(UniqueForDateModel, ('id',))],
[('date', 'count', 'start_date'), ('year', 'count', 'end_date'), ('month', 'order', 'end_date')] [(UniqueForDateModel, 'date', 'count', 'start_date'),
(UniqueForDateModel, 'year', 'count', 'end_date'),
(UniqueForDateModel, 'month', 'order', 'end_date')]
), m._get_unique_checks() ), m._get_unique_checks()
) )