Skip to content

Commit d5d4bf4

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

File tree

6 files changed

+136
-21
lines changed

6 files changed

+136
-21
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: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,56 @@ 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+
output.append(self._create_unique_sql(model, columns, condition=condition))
163+
164+
for index in model._meta.indexes:
165+
output.append(index.create_sql(model, self))
166+
return output
167+
168+
def _alter_many_to_many(self, model, old_field, new_field, strict):
169+
"""Alter M2Ms to repoint their to= endpoints."""
170+
171+
for idx in self._constraint_names(old_field.remote_field.through, index=True, unique=True):
172+
self.execute(self.sql_delete_index % {'name': idx, 'table': old_field.remote_field.through._meta.db_table})
173+
174+
return super()._alter_many_to_many(model, old_field, new_field, strict)
175+
126176
def _alter_field(self, model, old_field, new_field, old_type, new_type,
127177
old_db_params, new_db_params, strict=False):
128178
"""Actually perform a "physical" (non-ManyToMany) field update."""
@@ -223,12 +273,18 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
223273
for constraint_name in constraint_names:
224274
self.execute(self._delete_constraint_sql(self.sql_delete_check, model, constraint_name))
225275
# Have they renamed the column?
276+
old_create_indexes_sql = []
226277
if old_field.column != new_field.column:
278+
# remove old indices
279+
old_create_indexes_sql = self._model_indexes_sql(model)
280+
self._delete_indexes(model, old_field, new_field)
281+
227282
self.execute(self._rename_field_sql(model._meta.db_table, old_field, new_field, new_type))
228283
# Rename all references to the renamed column.
229284
for sql in self.deferred_sql:
230285
if isinstance(sql, Statement):
231286
sql.rename_column_references(model._meta.db_table, old_field.column, new_field.column)
287+
232288
# Next, start accumulating actions to do
233289
actions = []
234290
null_actions = []
@@ -286,6 +342,7 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
286342
actions = [(", ".join(sql), sum(params, []))]
287343
# Apply those actions
288344
for sql, params in actions:
345+
self._delete_indexes(model, old_field, new_field)
289346
self.execute(
290347
self.sql_alter_column % {
291348
"table": self.quote_name(model._meta.db_table),
@@ -438,6 +495,14 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
438495
"changes": changes_sql,
439496
}
440497
self.execute(sql, params)
498+
499+
for o in old_create_indexes_sql:
500+
o = str(o)
501+
if old_field.column not in o:
502+
continue
503+
o = o.replace('[%s]' % old_field.column, '[%s]' % new_field.column)
504+
self.execute(o)
505+
441506
# Reset connection if required
442507
if self.connection.features.connection_persists_old_columns:
443508
self.connection.close()
@@ -446,11 +511,14 @@ def _delete_indexes(self, model, old_field, new_field):
446511
index_columns = []
447512
if old_field.db_index and new_field.db_index:
448513
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)
514+
for fields in model._meta.index_together:
515+
columns = [model._meta.get_field(field).column for field in fields]
516+
if old_field.column in columns:
517+
index_columns.append(columns)
518+
519+
for fields in model._meta.unique_together:
520+
columns = [model._meta.get_field(field).column for field in fields]
521+
index_columns.append(columns)
454522
if index_columns:
455523
for columns in index_columns:
456524
index_names = self._constraint_names(model, columns, index=True)
@@ -461,11 +529,6 @@ def _delete_unique_constraints(self, model, old_field, new_field, strict=False):
461529
unique_columns = []
462530
if old_field.unique and new_field.unique:
463531
unique_columns.append([old_field.column])
464-
else:
465-
for fields in model._meta.unique_together:
466-
columns = [model._meta.get_field(field).column for field in fields]
467-
if old_field.column in columns:
468-
unique_columns.append(columns)
469532
if unique_columns:
470533
for columns in unique_columns:
471534
constraint_names = self._constraint_names(model, columns, unique=True)
@@ -601,11 +664,6 @@ def create_model(self, model):
601664
if autoinc_sql:
602665
self.deferred_sql.extend(autoinc_sql)
603666

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))
609667
# Make the table
610668
sql = self.sql_create_table % {
611669
"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: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
from django.db.models.expressions import Exists, OuterRef, Subquery
2-
from django.test import TestCase
2+
from django.test import TestCase, skipUnlessDBFeature
33

4-
from ..models import Comment, Post
4+
from ..models import Author, Comment, Post
55

66

77
class TestSubquery(TestCase):
88
def setUp(self):
9-
self.post = Post.objects.create(title="foo")
9+
self.author = Author.objects.create(name="author")
10+
self.post = Post.objects.create(title="foo", author=self.author)
1011

1112
def test_with_count(self):
1213
newest = Comment.objects.filter(post=OuterRef('pk')).order_by('-created_at')
@@ -17,9 +18,18 @@ def test_with_count(self):
1718

1819
class TestExists(TestCase):
1920
def setUp(self):
20-
self.post = Post.objects.create(title="foo")
21+
self.author = Author.objects.create(name="author")
22+
self.post = Post.objects.create(title="foo", author=self.author)
2123

2224
def test_with_count(self):
2325
Post.objects.annotate(
2426
post_exists=Exists(Post.objects.all())
2527
).filter(post_exists=True).count()
28+
29+
30+
@skipUnlessDBFeature('supports_partially_nullable_unique_constraints')
31+
class TestPartiallyNullableUniqueTogether(TestCase):
32+
def test_partially_nullable(self):
33+
author = Author.objects.create(name="author")
34+
Post.objects.create(title="foo", author=author)
35+
Post.objects.create(title="foo", author=author)

0 commit comments

Comments
 (0)