Skip to content

Commit 45530f6

Browse files
Resolved bug cloning models with UniqueConstraint. (#423)
* Resolved bug cloning models with UniqueConstraint. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixed test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixed test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixed test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixed test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Updated test. * Updated test. * Updated test. * Update utils.py * Update test_clone_mixin.py * Update test_clone_mixin.py * Update test_clone_mixin.py * Update test_clone_mixin.py * Update utils.py Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 72b5b15 commit 45530f6

File tree

7 files changed

+268
-15
lines changed

7 files changed

+268
-15
lines changed

django_clone/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"sample_driver",
4848
]
4949

50-
if sys.version_info >= (3, 6):
50+
if sys.version_info >= (3, 6) and "--fix" in sys.argv:
5151
INSTALLED_APPS += ["migration_fixer"]
5252

5353

model_clone/mixins/clone.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
clean_value,
1515
context_mutable_attribute,
1616
get_fields_and_unique_fields_from_cls,
17+
get_unique_default,
1718
get_unique_value,
1819
transaction_autocommit,
1920
)
@@ -341,9 +342,26 @@ def _create_copy_of_instance(instance, using=None, force=False, sub_clone=False)
341342
and not f.choices
342343
):
343344
value = clean_value(value, unique_duplicate_suffix)
344-
if use_unique_duplicate_suffix:
345+
346+
if f.has_default():
347+
value = f.get_default()
348+
349+
if not callable(f.default):
350+
value = get_unique_default(
351+
model=cls,
352+
fname=f.attname,
353+
value=value,
354+
transform=(
355+
slugify if isinstance(f, SlugField) else str
356+
),
357+
suffix=unique_duplicate_suffix,
358+
max_length=f.max_length,
359+
max_attempts=max_unique_duplicate_query_attempts,
360+
)
361+
362+
elif use_unique_duplicate_suffix:
345363
value = get_unique_value(
346-
obj=instance,
364+
model=cls,
347365
fname=f.attname,
348366
value=value,
349367
transform=(slugify if isinstance(f, SlugField) else str),

model_clone/tests/test_clone_mixin.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,34 @@ def test_cloning_without_explicit_clone_m2m_fields(self):
233233
list(book_clone.authors.values_list("first_name", "last_name")),
234234
)
235235

236+
def test_cloning_with_unique_constraint_is_valid(self):
237+
sale_tag = SaleTag.objects.create(name="test-sale-tag")
238+
clone_sale_tag_1 = sale_tag.make_clone()
239+
240+
self.assertNotEqual(sale_tag.pk, clone_sale_tag_1.pk)
241+
self.assertRegexpMatches(
242+
clone_sale_tag_1.name,
243+
r"{}\s[\d]".format(SaleTag.UNIQUE_DUPLICATE_SUFFIX),
244+
)
245+
246+
clone_sale_tag_2 = clone_sale_tag_1.make_clone()
247+
248+
self.assertNotEqual(clone_sale_tag_1.pk, clone_sale_tag_2.pk)
249+
self.assertRegexpMatches(
250+
clone_sale_tag_2.name,
251+
r"{}\s[\d]".format(SaleTag.UNIQUE_DUPLICATE_SUFFIX),
252+
)
253+
254+
def test_cloning_with_unique_constraint_uses_field_default(self):
255+
tag = Tag.objects.create(name="test-tag")
256+
clone_tag = tag.make_clone()
257+
258+
self.assertNotEqual(tag.pk, clone_tag.pk)
259+
self.assertRegexpMatches(
260+
clone_tag.name,
261+
r"\s[\d]",
262+
)
263+
236264
@patch("sample.models.Book._clone_m2m_fields", new_callable=PropertyMock)
237265
def test_cloning_with_explicit_clone_m2m_fields(
238266
self,
@@ -416,17 +444,51 @@ def test_cloning_unique_together_fields_with_enum_field(self):
416444
created_by=self.user1,
417445
)
418446

419-
author_clone = author.make_clone()
447+
author_clone_1 = author.make_clone()
420448

421-
self.assertNotEqual(author.pk, author_clone.pk)
422-
self.assertEqual(author.sex, author_clone.sex)
449+
self.assertNotEqual(author.pk, author_clone_1.pk)
450+
self.assertEqual(author.sex, author_clone_1.sex)
423451
self.assertEqual(
424-
author_clone.first_name,
452+
author_clone_1.first_name,
425453
"{} {} {}".format(first_name, Author.UNIQUE_DUPLICATE_SUFFIX, 1),
426454
)
427455
self.assertEqual(
428-
author_clone.last_name,
429-
"{} {} {}".format(last_name, Author.UNIQUE_DUPLICATE_SUFFIX, 1),
456+
author_clone_1.last_name,
457+
Author._meta.get_field("last_name").get_default(),
458+
)
459+
460+
author_clone_2 = author.make_clone()
461+
462+
self.assertNotEqual(author.pk, author_clone_2.pk)
463+
self.assertEqual(author.sex, author_clone_2.sex)
464+
self.assertEqual(
465+
author_clone_2.first_name,
466+
"{} {} {}".format(first_name, Author.UNIQUE_DUPLICATE_SUFFIX, 2),
467+
)
468+
self.assertEqual(
469+
author_clone_2.last_name,
470+
"{} {} {}".format(
471+
Author._meta.get_field("last_name").get_default(),
472+
Author.UNIQUE_DUPLICATE_SUFFIX,
473+
1,
474+
),
475+
)
476+
477+
author_clone_3 = author.make_clone()
478+
479+
self.assertNotEqual(author.pk, author_clone_3.pk)
480+
self.assertEqual(author.sex, author_clone_3.sex)
481+
self.assertEqual(
482+
author_clone_3.first_name,
483+
"{} {} {}".format(first_name, Author.UNIQUE_DUPLICATE_SUFFIX, 3),
484+
)
485+
self.assertEqual(
486+
author_clone_3.last_name,
487+
"{} {} {}".format(
488+
Author._meta.get_field("last_name").get_default(),
489+
Author.UNIQUE_DUPLICATE_SUFFIX,
490+
2,
491+
),
430492
)
431493

432494
def test_cloning_unique_slug_field(self):

model_clone/utils.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ def create_copy_of_instance(
9999
return new_obj
100100

101101

102+
def unpack_unique_constraints(opts, only_fields=()):
103+
"""
104+
Unpack unique constraint fields.
105+
106+
:param opts: Model options
107+
:type opts: `django.db.models.options.Options`
108+
:param only_fields: Fields that should be considered.
109+
:type only_fields: `collections.Iterable`
110+
:return: Flat list of fields.
111+
"""
112+
fields = []
113+
constraints = getattr(
114+
opts, "total_unique_constraints", getattr(opts, "constraints", [])
115+
)
116+
for constraint in constraints:
117+
fields.extend([f for f in constraint.fields if f in only_fields])
118+
return fields
119+
120+
102121
def unpack_unique_together(opts, only_fields=()):
103122
"""
104123
Unpack unique together fields.
@@ -163,7 +182,7 @@ def get_value(value, suffix, transform, max_length, index):
163182
Append a suffix to a string value and apply a pass directly to a
164183
transformation function.
165184
"""
166-
duplicate_suffix = " {} {}".format(suffix, index)
185+
duplicate_suffix = " " + "{} {}".format(suffix, index).strip()
167186
total_length = len(value + duplicate_suffix)
168187

169188
if max_length is not None and total_length > max_length:
@@ -189,12 +208,20 @@ def generate_value(value, suffix, transform, max_length, max_attempts):
189208
)
190209

191210

192-
def get_unique_value(obj, fname, value, transform, suffix, max_length, max_attempts):
211+
def get_unique_value(
212+
model,
213+
fname,
214+
value="",
215+
transform=lambda v: v,
216+
suffix="",
217+
max_length=None,
218+
max_attempts=100,
219+
):
193220
"""
194221
Generate a unique value using current value and query the model
195222
for existing objects with the new value.
196223
"""
197-
qs = obj.__class__._default_manager.all()
224+
qs = model._default_manager.all()
198225
it = generate_value(value, suffix, transform, max_length, max_attempts)
199226

200227
new = six.next(it)
@@ -251,10 +278,54 @@ def get_fields_and_unique_fields_from_cls(
251278
only_fields=[f.attname for f in fields],
252279
)
253280

281+
unique_constraint_field_names = unpack_unique_constraints(
282+
opts=cls._meta,
283+
only_fields=[f.attname for f in fields],
284+
)
285+
254286
unique_fields = [
255287
f.name
256288
for f in fields
257-
if not f.auto_created and (f.unique or f.name in unique_field_names)
289+
if not f.auto_created
290+
and (
291+
f.unique
292+
or f.name in unique_field_names
293+
or f.name in unique_constraint_field_names
294+
)
258295
]
259296

260297
return fields, unique_fields
298+
299+
300+
def get_unique_default(
301+
model,
302+
fname,
303+
value,
304+
transform=lambda v: v,
305+
suffix="",
306+
max_length=None,
307+
max_attempts=100,
308+
):
309+
"""Get a unique value using the value and adding a suffix if needed."""
310+
311+
qs = model._default_manager.all()
312+
313+
if not qs.filter(**{fname: value}).exists():
314+
return value
315+
316+
it = generate_value(
317+
value,
318+
suffix,
319+
transform,
320+
max_length,
321+
max_attempts,
322+
)
323+
324+
new = six.next(it)
325+
kwargs = {fname: new}
326+
327+
while qs.filter(**kwargs).exists():
328+
new = six.next(it)
329+
kwargs[fname] = new
330+
331+
return new
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Generated by Django 3.2.5 on 2021-07-17 20:26
2+
3+
import django
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("sample", "0018_auto_20210628_2301"),
11+
]
12+
13+
operations = (
14+
[
15+
migrations.AddConstraint(
16+
model_name="saletag",
17+
constraint=models.UniqueConstraint(
18+
fields=("name",), name="sale_tag_unique_name"
19+
),
20+
),
21+
]
22+
if django.VERSION >= (2, 2)
23+
else [
24+
migrations.AlterField(
25+
model_name="saletag",
26+
name="name",
27+
field=models.CharField(max_length=255, unique=True),
28+
),
29+
]
30+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Generated by Django 3.2.5 on 2021-07-17 22:30
2+
import django
3+
from django.db import migrations, models
4+
5+
import sample.models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
("sample", "0019_saletag_sale_tag_unique_name"),
12+
]
13+
14+
operations = (
15+
[
16+
migrations.AlterField(
17+
model_name="tag",
18+
name="name",
19+
field=models.CharField(
20+
default=sample.models.get_unique_tag_name, max_length=255
21+
),
22+
),
23+
migrations.AddConstraint(
24+
model_name="tag",
25+
constraint=models.UniqueConstraint(
26+
fields=("name",), name="tag_unique_name"
27+
),
28+
),
29+
]
30+
if django.VERSION >= (2, 2)
31+
else [
32+
migrations.AlterField(
33+
model_name="tag",
34+
name="name",
35+
field=models.CharField(
36+
default=sample.models.get_unique_tag_name,
37+
max_length=255,
38+
unique=True,
39+
),
40+
),
41+
]
42+
)

sample/models.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
from uuid import uuid4
22

3+
import django
34
from django.conf import settings
45
from django.db import models
56
from django.utils import timezone
67
from django.utils.translation import gettext as _
78

9+
from model_clone.utils import get_unique_default
10+
11+
if django.VERSION >= (2, 2):
12+
from django.db.models import UniqueConstraint
13+
814
from model_clone import CloneMixin
915
from model_clone.models import CloneModel
1016

@@ -38,15 +44,39 @@ class Meta:
3844
unique_together = (("first_name", "last_name", "sex"),)
3945

4046

47+
def get_unique_tag_name():
48+
return get_unique_default(
49+
model=Tag,
50+
fname="name",
51+
value="test-tag",
52+
)
53+
54+
4155
class Tag(CloneModel):
42-
name = models.CharField(max_length=255)
56+
name = models.CharField(
57+
max_length=255, default=get_unique_tag_name, unique=django.VERSION < (2, 2)
58+
)
59+
60+
if django.VERSION >= (2, 2):
61+
62+
class Meta:
63+
constraints = [
64+
UniqueConstraint(fields=["name"], name="tag_unique_name"),
65+
]
4366

4467
def __str__(self):
4568
return _(self.name)
4669

4770

4871
class SaleTag(CloneModel):
49-
name = models.CharField(max_length=255)
72+
name = models.CharField(max_length=255, unique=django.VERSION < (2, 2))
73+
74+
if django.VERSION >= (2, 2):
75+
76+
class Meta:
77+
constraints = [
78+
UniqueConstraint(fields=["name"], name="sale_tag_unique_name"),
79+
]
5080

5181
def __str__(self):
5282
return _(self.name)

0 commit comments

Comments
 (0)