Skip to content

Commit 984bc64

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

File tree

6 files changed

+108
-12
lines changed

6 files changed

+108
-12
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: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,24 @@ 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+
126144
def _alter_field(self, model, old_field, new_field, old_type, new_type,
127145
old_db_params, new_db_params, strict=False):
128146
"""Actually perform a "physical" (non-ManyToMany) field update."""
@@ -223,12 +241,18 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
223241
for constraint_name in constraint_names:
224242
self.execute(self._delete_constraint_sql(self.sql_delete_check, model, constraint_name))
225243
# Have they renamed the column?
244+
old_create_indexes_sql = []
226245
if old_field.column != new_field.column:
246+
# remove old indices
247+
old_create_indexes_sql = self._model_indexes_sql(model)
248+
self._delete_indexes(model, old_field, new_field)
249+
227250
self.execute(self._rename_field_sql(model._meta.db_table, old_field, new_field, new_type))
228251
# Rename all references to the renamed column.
229252
for sql in self.deferred_sql:
230253
if isinstance(sql, Statement):
231254
sql.rename_column_references(model._meta.db_table, old_field.column, new_field.column)
255+
232256
# Next, start accumulating actions to do
233257
actions = []
234258
null_actions = []
@@ -286,6 +310,7 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
286310
actions = [(", ".join(sql), sum(params, []))]
287311
# Apply those actions
288312
for sql, params in actions:
313+
self._delete_indexes(model, old_field, new_field)
289314
self.execute(
290315
self.sql_alter_column % {
291316
"table": self.quote_name(model._meta.db_table),
@@ -438,6 +463,14 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
438463
"changes": changes_sql,
439464
}
440465
self.execute(sql, params)
466+
467+
for o in old_create_indexes_sql:
468+
o = str(o)
469+
if old_field.column not in o:
470+
continue
471+
o = o.replace('[%s]' % old_field.column, '[%s]' % new_field.column)
472+
self.execute(o)
473+
441474
# Reset connection if required
442475
if self.connection.features.connection_persists_old_columns:
443476
self.connection.close()
@@ -446,11 +479,14 @@ def _delete_indexes(self, model, old_field, new_field):
446479
index_columns = []
447480
if old_field.db_index and new_field.db_index:
448481
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)
482+
for fields in model._meta.index_together:
483+
columns = [model._meta.get_field(field).column for field in fields]
484+
if old_field.column in columns:
485+
index_columns.append(columns)
486+
487+
for fields in model._meta.unique_together:
488+
columns = [model._meta.get_field(field).column for field in fields]
489+
index_columns.append(columns)
454490
if index_columns:
455491
for columns in index_columns:
456492
index_names = self._constraint_names(model, columns, index=True)
@@ -605,7 +641,10 @@ def create_model(self, model):
605641
# created afterwards, like geometry fields with some backends)
606642
for fields in model._meta.unique_together:
607643
columns = [model._meta.get_field(field).column for field in fields]
608-
self.deferred_sql.append(self._create_unique_sql(model, columns))
644+
condition = ' AND '.join(["[%s] IS NOT NULL" % col for col in columns])
645+
sql_without_condition = self._create_unique_sql(model, columns)
646+
sql_with_condition = self._create_unique_sql(model, columns, condition=condition)
647+
self.deferred_sql.append(sql_with_condition)
609648
# Make the table
610649
sql = self.sql_create_table % {
611650
"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)