Skip to content

Commit 08c2721

Browse files
committed
Add support for nullable fields in unique_together
1 parent 8bf0154 commit 08c2721

File tree

6 files changed

+157
-16
lines changed

6 files changed

+157
-16
lines changed

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[flake8]
2-
exclude = .git,__pycache__,
2+
exclude = .git,__pycache__,migrations
33
# W504 is mutually exclusive with W503
44
ignore = W504
55
max-line-length = 119

sql_server/pyodbc/features.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ class DatabaseFeatures(BaseDatabaseFeatures):
2323
supports_ignore_conflicts = False
2424
supports_index_on_text_field = False
2525
supports_paramstyle_pyformat = False
26-
supports_partially_nullable_unique_constraints = False
2726
supports_regex_backreferencing = False
2827
supports_sequence_reset = False
2928
supports_subqueries_in_group_by = False
@@ -41,6 +40,10 @@ def has_bulk_insert(self):
4140
def supports_nullable_unique_constraints(self):
4241
return self.connection.sql_server_version > 2005
4342

43+
@cached_property
44+
def supports_partially_nullable_unique_constraints(self):
45+
return self.connection.sql_server_version > 2005
46+
4447
@cached_property
4548
def supports_partial_indexes(self):
4649
return self.connection.sql_server_version > 2005

sql_server/pyodbc/schema.py

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,70 @@ def _alter_column_type_sql(self, model, old_field, new_field, new_type):
123123
new_type = self._set_field_new_type_null_status(old_field, new_type)
124124
return super()._alter_column_type_sql(model, old_field, new_field, new_type)
125125

126+
def alter_unique_together(self, model, old_unique_together, new_unique_together):
127+
"""
128+
Deal with a model changing its unique_together. The input
129+
unique_togethers must be doubly-nested, not the single-nested
130+
["foo", "bar"] format.
131+
"""
132+
olds = {tuple(fields) for fields in old_unique_together}
133+
news = {tuple(fields) for fields in new_unique_together}
134+
# Deleted uniques
135+
for fields in olds.difference(news):
136+
self._delete_composed_index(model, fields, {'unique': True}, self.sql_delete_index)
137+
# Created uniques
138+
for fields in news.difference(olds):
139+
columns = [model._meta.get_field(field).column for field in fields]
140+
condition = ' AND '.join(["[%s] IS NOT NULL" % col for col in columns])
141+
sql = self._create_unique_sql(model, columns, condition=condition)
142+
self.execute(sql)
143+
144+
def _model_indexes_sql(self, model):
145+
"""
146+
Return a list of all index SQL statements (field indexes,
147+
index_together, Meta.indexes) for the specified model.
148+
"""
149+
if not model._meta.managed or model._meta.proxy or model._meta.swapped:
150+
return []
151+
output = []
152+
for field in model._meta.local_fields:
153+
output.extend(self._field_indexes_sql(model, field))
154+
155+
for field_names in model._meta.index_together:
156+
fields = [model._meta.get_field(field) for field in field_names]
157+
output.append(self._create_index_sql(model, fields, suffix="_idx"))
158+
159+
for field_names in model._meta.unique_together:
160+
columns = [model._meta.get_field(field).column for field in field_names]
161+
condition = ' AND '.join(["[%s] IS NOT NULL" % col for col in columns])
162+
sql = self._create_unique_sql(model, columns, condition=condition)
163+
output.append(sql)
164+
165+
for index in model._meta.indexes:
166+
output.append(index.create_sql(model, self))
167+
return output
168+
169+
def _alter_many_to_many(self, model, old_field, new_field, strict):
170+
"""Alter M2Ms to repoint their to= endpoints."""
171+
172+
for idx in self._constraint_names(old_field.remote_field.through, index=True, unique=True):
173+
self.execute(self.sql_delete_index % {'name': idx, 'table': old_field.remote_field.through._meta.db_table})
174+
175+
return super()._alter_many_to_many(model, old_field, new_field, strict)
176+
177+
def alter_db_table(self, model, old_db_table, new_db_table):
178+
index_names = self._constraint_names(model, index=True)
179+
for index_name in index_names:
180+
self.execute(self._delete_constraint_sql(self.sql_delete_index, model, index_name))
181+
182+
model._meta.db_table = old_db_table
183+
index_names = self._constraint_names(model, index=True)
184+
for index_name in index_names:
185+
self.execute(self._delete_constraint_sql(self.sql_delete_index, model, index_name))
186+
model._meta.db_table = new_db_table
187+
188+
return super().alter_db_table(model, old_db_table, new_db_table)
189+
126190
def _alter_field(self, model, old_field, new_field, old_type, new_type,
127191
old_db_params, new_db_params, strict=False):
128192
"""Actually perform a "physical" (non-ManyToMany) field update."""
@@ -223,12 +287,18 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
223287
for constraint_name in constraint_names:
224288
self.execute(self._delete_constraint_sql(self.sql_delete_check, model, constraint_name))
225289
# Have they renamed the column?
290+
old_create_indexes_sql = []
226291
if old_field.column != new_field.column:
292+
# remove old indices
293+
old_create_indexes_sql = self._model_indexes_sql(model)
294+
self._delete_indexes(model, old_field, new_field)
295+
227296
self.execute(self._rename_field_sql(model._meta.db_table, old_field, new_field, new_type))
228297
# Rename all references to the renamed column.
229298
for sql in self.deferred_sql:
230299
if isinstance(sql, Statement):
231300
sql.rename_column_references(model._meta.db_table, old_field.column, new_field.column)
301+
232302
# Next, start accumulating actions to do
233303
actions = []
234304
null_actions = []
@@ -286,6 +356,7 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
286356
actions = [(", ".join(sql), sum(params, []))]
287357
# Apply those actions
288358
for sql, params in actions:
359+
self._delete_indexes(model, old_field, new_field)
289360
self.execute(
290361
self.sql_alter_column % {
291362
"table": self.quote_name(model._meta.db_table),
@@ -438,6 +509,14 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
438509
"changes": changes_sql,
439510
}
440511
self.execute(sql, params)
512+
513+
for o in old_create_indexes_sql:
514+
o = str(o)
515+
if old_field.column not in o:
516+
continue
517+
o = o.replace('[%s]' % old_field.column, '[%s]' % new_field.column)
518+
self.execute(o)
519+
441520
# Reset connection if required
442521
if self.connection.features.connection_persists_old_columns:
443522
self.connection.close()
@@ -446,11 +525,15 @@ def _delete_indexes(self, model, old_field, new_field):
446525
index_columns = []
447526
if old_field.db_index and new_field.db_index:
448527
index_columns.append([old_field.column])
449-
else:
450-
for fields in model._meta.index_together:
451-
columns = [model._meta.get_field(field).column for field in fields]
452-
if old_field.column in columns:
453-
index_columns.append(columns)
528+
for fields in model._meta.index_together:
529+
columns = [model._meta.get_field(field).column for field in fields]
530+
if old_field.column in columns:
531+
index_columns.append(columns)
532+
533+
for fields in model._meta.unique_together:
534+
columns = [model._meta.get_field(field).column for field in fields]
535+
if old_field.column in columns:
536+
index_columns.append(columns)
454537
if index_columns:
455538
for columns in index_columns:
456539
index_names = self._constraint_names(model, columns, index=True)
@@ -601,11 +684,6 @@ def create_model(self, model):
601684
if autoinc_sql:
602685
self.deferred_sql.extend(autoinc_sql)
603686

604-
# Add any unique_togethers (always deferred, as some fields might be
605-
# created afterwards, like geometry fields with some backends)
606-
for fields in model._meta.unique_together:
607-
columns = [model._meta.get_field(field).column for field in fields]
608-
self.deferred_sql.append(self._create_unique_sql(model, columns))
609687
# Make the table
610688
sql = self.sql_create_table % {
611689
"table": self.quote_name(model._meta.db_table),

testapp/migrations/0001_initial.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,41 @@ class Migration(migrations.Migration):
1414
]
1515

1616
operations = [
17+
migrations.CreateModel(
18+
name='Author',
19+
fields=[
20+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21+
('name', models.CharField(max_length=100)),
22+
],
23+
),
24+
migrations.CreateModel(
25+
name='Editor',
26+
fields=[
27+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
28+
('name', models.CharField(max_length=100)),
29+
],
30+
),
1731
migrations.CreateModel(
1832
name='Post',
1933
fields=[
2034
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
2135
('title', models.CharField(max_length=255, verbose_name='title')),
2236
],
2337
),
38+
migrations.AddField(
39+
model_name='post',
40+
name='alt_editor',
41+
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='testapp.Editor'),
42+
),
43+
migrations.AddField(
44+
model_name='post',
45+
name='author',
46+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.Author'),
47+
),
48+
migrations.AlterUniqueTogether(
49+
name='post',
50+
unique_together={('author', 'title', 'alt_editor')},
51+
),
2452
migrations.CreateModel(
2553
name='Comment',
2654
fields=[

testapp/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,24 @@
44
from django.utils import timezone
55

66

7+
class Author(models.Model):
8+
name = models.CharField(max_length=100)
9+
10+
11+
class Editor(models.Model):
12+
name = models.CharField(max_length=100)
13+
14+
715
class Post(models.Model):
816
title = models.CharField('title', max_length=255)
17+
author = models.ForeignKey(Author, models.CASCADE)
18+
# Optional secondary author
19+
alt_editor = models.ForeignKey(Editor, models.SET_NULL, blank=True, null=True)
20+
21+
class Meta:
22+
unique_together = (
23+
('author', 'title', 'alt_editor'),
24+
)
925

1026
def __str__(self):
1127
return self.title

testapp/tests/test_expressions.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from django.db.models.expressions import Exists, OuterRef, Subquery
2-
from django.test import TestCase
2+
from django.db.utils import IntegrityError
3+
from django.test import TestCase, skipUnlessDBFeature
34

4-
from ..models import Comment, Post
5+
from ..models import Author, Comment, Editor, Post
56

67

78
class TestSubquery(TestCase):
89
def setUp(self):
9-
self.post = Post.objects.create(title="foo")
10+
self.author = Author.objects.create(name="author")
11+
self.post = Post.objects.create(title="foo", author=self.author)
1012

1113
def test_with_count(self):
1214
newest = Comment.objects.filter(post=OuterRef('pk')).order_by('-created_at')
@@ -17,9 +19,23 @@ def test_with_count(self):
1719

1820
class TestExists(TestCase):
1921
def setUp(self):
20-
self.post = Post.objects.create(title="foo")
22+
self.author = Author.objects.create(name="author")
23+
self.post = Post.objects.create(title="foo", author=self.author)
2124

2225
def test_with_count(self):
2326
Post.objects.annotate(
2427
post_exists=Exists(Post.objects.all())
2528
).filter(post_exists=True).count()
29+
30+
31+
@skipUnlessDBFeature('supports_partially_nullable_unique_constraints')
32+
class TestPartiallyNullableUniqueTogether(TestCase):
33+
def test_partially_nullable(self):
34+
author = Author.objects.create(name="author")
35+
Post.objects.create(title="foo", author=author)
36+
Post.objects.create(title="foo", author=author)
37+
38+
editor = Editor.objects.create(name="editor")
39+
Post.objects.create(title="foo", author=author, alt_editor=editor)
40+
with self.assertRaises(IntegrityError):
41+
Post.objects.create(title="foo", author=author, alt_editor=editor)

0 commit comments

Comments
 (0)