Skip to content

Commit 1bef061

Browse files
Add ignore_pk option to Fixture and diff functions
Enhanced the Fixture class and diff utility to support an ignore_pk parameter, allowing records to be matched by field content instead of primary key. Updated tests to verify this new functionality.
1 parent 892a863 commit 1bef061

File tree

4 files changed

+95
-10
lines changed

4 files changed

+95
-10
lines changed

dbdiff/fixture.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class Fixture(object):
4646

4747
exclude = dict()
4848

49-
def __init__(self, relative_path, models=None, database=None):
49+
def __init__(self, relative_path, models=None, database=None, ignore_pk=False):
5050
"""
5151
Instanciate a FixtureDiff on a database.
5252
@@ -58,10 +58,15 @@ def __init__(self, relative_path, models=None, database=None):
5858
5959
database should be the name of the database to use, `default` by
6060
default.
61+
62+
ignore_pk when True, matches records by field content instead of
63+
primary key. Records with the same content but different pks
64+
are considered equal.
6165
"""
6266
self.path = get_absolute_path(relative_path)
6367
self.models = models if models else self.parse_models()
6468
self.database = database or 'default'
69+
self.ignore_pk = ignore_pk
6570

6671
def parse_models(self):
6772
"""Return the list of models inside the fixture file."""
@@ -91,12 +96,15 @@ def indent(self):
9196

9297
return len(line) - len(line.lstrip(' '))
9398

94-
def diff(self, exclude=None):
99+
def diff(self, exclude=None, ignore_pk=None):
95100
"""
96101
Diff the fixture against a datadump of fixture models.
97102
98103
If passed, exclude should be a list of field names to exclude from
99104
being diff'ed.
105+
106+
ignore_pk when True, matches records by field content instead of
107+
primary key. Defaults to the instance's ignore_pk attribute.
100108
"""
101109
fh, dump_path = tempfile.mkstemp('_dbdiff')
102110

@@ -109,9 +117,13 @@ def diff(self, exclude=None):
109117
with open(self.path, 'r') as e, open(dump_path, 'r') as r:
110118
expected, result = json.load(e), json.load(r)
111119

120+
if ignore_pk is None:
121+
ignore_pk = self.ignore_pk
122+
112123
unexpected, missing, different = diff(
113124
get_tree(expected, exclude_final),
114125
get_tree(result, exclude_final),
126+
ignore_pk=ignore_pk,
115127
)
116128

117129
if not unexpected and not missing and not diff:

dbdiff/test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def test_db_import(self):
5252
Fixture(
5353
self.dbdiff_expected,
5454
models=self.dbdiff_models,
55+
ignore_pk=getattr(self, 'dbdiff_ignore_pk', False),
5556
).assertNoDiff(
5657
exclude=self.dbdiff_exclude,
5758
)

dbdiff/tests/test_utils.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,43 @@
11
import os
22

3-
from dbdiff.utils import get_absolute_path, get_model_names
3+
from dbdiff.utils import diff, get_absolute_path, get_model_names
44

55
from django.contrib.auth.models import Group
66

77

8+
def test_diff_ignore_pk():
9+
"""With ignore_pk=True, records are matched by content, not pk."""
10+
expected = {
11+
'auth.group': {
12+
1: {'name': 'testgroup', 'permissions': []},
13+
},
14+
}
15+
result = {
16+
'auth.group': {
17+
99: {'name': 'testgroup', 'permissions': []}, # same content, diff pk
18+
},
19+
}
20+
unexpected, missing, different = diff(expected, result, ignore_pk=True)
21+
assert not unexpected and not missing and not different
22+
23+
24+
def test_diff_with_pk_by_default():
25+
"""With ignore_pk=False, same content but different pk yields missing/unexpected."""
26+
expected = {
27+
'auth.group': {
28+
1: {'name': 'testgroup', 'permissions': []},
29+
},
30+
}
31+
result = {
32+
'auth.group': {
33+
99: {'name': 'testgroup', 'permissions': []},
34+
},
35+
}
36+
unexpected, missing, different = diff(expected, result, ignore_pk=False)
37+
assert missing == {'auth.group': {1: {'name': 'testgroup', 'permissions': []}}}
38+
assert unexpected == {'auth.group': {99: {'name': 'testgroup', 'permissions': []}}}
39+
40+
841
def test_get_model_names():
942
assert get_model_names([Group, 'auth.user']) == ['auth.group', 'auth.user']
1043

dbdiff/utils.py

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,18 @@ def _get_unexpected(expected, result):
4343
return unexpected
4444

4545

46-
def diff(expected, result):
47-
"""Return unexpected, missing and diff between expected and result."""
48-
missing, diff = {}, {}
46+
def diff(expected, result, ignore_pk=False):
47+
"""Return unexpected, missing and diff between expected and result.
48+
49+
When ignore_pk is True, records are matched by their field values (content)
50+
instead of primary key. Records with the same content but different pks
51+
are considered matching.
52+
"""
53+
missing, different = {}, {}
54+
55+
if ignore_pk:
56+
unexpected, missing, different = _diff_by_content(expected, result)
57+
return unexpected, missing, different
4958

5059
unexpected = _get_unexpected(expected, result)
5160

@@ -60,20 +69,50 @@ def diff(expected, result):
6069
if expected_fields == result_fields:
6170
continue
6271

63-
diff.setdefault(model, {})
64-
diff[model].setdefault(pk, {})
72+
different.setdefault(model, {})
73+
different[model].setdefault(pk, {})
6574

6675
for expected_field, expected_value in expected_fields.items():
6776
result_value = result_fields[expected_field]
6877

6978
if expected_value == result_value:
7079
continue
7180

72-
diff[model][pk][expected_field] = (
81+
different[model][pk][expected_field] = (
7382
expected_value,
7483
result_value
7584
)
76-
return unexpected, missing, diff
85+
return unexpected, missing, different
86+
87+
88+
def _diff_by_content(expected, result):
89+
"""Diff by matching records on field content instead of primary key."""
90+
unexpected, missing, different = {}, {}, {}
91+
92+
for model in set(expected.keys()) | set(result.keys()):
93+
expected_list = list(expected.get(model, {}).items())
94+
result_list = list(result.get(model, {}).items())
95+
matched_result_indices = set()
96+
97+
for exp_pk, exp_fields in expected_list:
98+
found = False
99+
for i, (res_pk, res_fields) in enumerate(result_list):
100+
if i in matched_result_indices:
101+
continue
102+
if exp_fields == res_fields:
103+
matched_result_indices.add(i)
104+
found = True
105+
break
106+
if not found:
107+
missing.setdefault(model, {})
108+
missing[model][exp_pk] = exp_fields
109+
110+
for i, (res_pk, res_fields) in enumerate(result_list):
111+
if i not in matched_result_indices:
112+
unexpected.setdefault(model, {})
113+
unexpected[model][res_pk] = res_fields
114+
115+
return unexpected, missing, different
77116

78117

79118
def get_absolute_path(path):

0 commit comments

Comments
 (0)