Skip to content

Commit cdc07ac

Browse files
committed
PLAT-245 -- Add core of library.
1 parent d3c5a3a commit cdc07ac

File tree

4 files changed

+513
-0
lines changed

4 files changed

+513
-0
lines changed

sqlalchemydiff/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# -*- coding: utf-8 -*-
2+
3+
from .comparer import compare
4+
5+
6+
__all__ = ['compare']

sqlalchemydiff/comparer.py

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
# -*- coding: utf-8 -*-
2+
from copy import deepcopy
3+
4+
from .util import TablesInfo, DiffResult, InspectorFactory, CompareResult
5+
6+
7+
def compare(left_uri, right_uri, ignore_tables=None):
8+
"""Compare two databases, given two URIs.
9+
10+
Compare two databases, given two URIs and a (possibly empty) set of
11+
tables to ignore during the comparison.
12+
13+
The ``info`` dict has this structure::
14+
15+
info = {
16+
'uris': {
17+
'left': 'left_uri',
18+
'right': 'right_uri',
19+
},
20+
'tables': {
21+
'left': 'tables_left',
22+
'left_only': 'tables_left_only',
23+
'right': 'tables_right',
24+
'right_only': 'tables_right_only',
25+
'common': ['table_name_1', 'table_name_2'],
26+
},
27+
'tables_data': {
28+
29+
'table_name_1': {
30+
'foreign_keys': {
31+
'left_only': [...],
32+
'right_only': [...],
33+
'common': [...],
34+
'diff': [...],
35+
},
36+
'primary_keys': {
37+
'left_only': [...],
38+
'right_only': [...],
39+
'common': [...],
40+
'diff': [...],
41+
},
42+
'indexes': {
43+
'left_only': [...],
44+
'right_only': [...],
45+
'common': [...],
46+
'diff': [...],
47+
},
48+
'columns': {
49+
'left_only': [...],
50+
'right_only': [...],
51+
'common': [...],
52+
'diff': [...],
53+
}
54+
},
55+
56+
'table_name_2': { ... },
57+
}
58+
}
59+
60+
The ``errors`` dict will follow the same structure of the ``info``
61+
dict, but it will only have the data that is showing a discrepancy
62+
between the two databases.
63+
64+
:param string left_uri: The URI for the first (left) database.
65+
:param string right_uri: The URI for the second (right) database.
66+
:param set ignore_tables:
67+
A set of string values to be excluded from both databases (if
68+
present) when doing the comparison. String matching is case
69+
sensitive.
70+
:return:
71+
A :class:`~.util.CompareResult` object with ``info`` and
72+
``errors`` dicts populated with the comparison result.
73+
"""
74+
if ignore_tables is None:
75+
ignore_tables = set()
76+
77+
left_inspector, right_inspector = _get_inspectors(left_uri, right_uri)
78+
79+
tables_info = _get_tables_info(
80+
left_inspector, right_inspector, ignore_tables)
81+
82+
info = _get_info_dict(left_uri, right_uri, tables_info)
83+
84+
info['tables_data'] = _get_tables_data(
85+
tables_info.common, left_inspector, right_inspector)
86+
87+
errors = _compile_errors(info)
88+
result = _make_result(info, errors)
89+
90+
return result
91+
92+
93+
def _get_inspectors(left_uri, right_uri):
94+
left_inspector = InspectorFactory.from_uri(left_uri)
95+
right_inspector = InspectorFactory.from_uri(right_uri)
96+
return left_inspector, right_inspector
97+
98+
99+
def _get_tables_info(left_inspector, right_inspector, ignore_tables):
100+
"""Get information about the differences at the table level. """
101+
tables_left, tables_right = _get_tables(
102+
left_inspector, right_inspector, ignore_tables)
103+
104+
tables_left_only, tables_right_only = _get_tables_diff(
105+
tables_left, tables_right)
106+
107+
tables_common = _get_common_tables(tables_left, tables_right)
108+
109+
return TablesInfo(
110+
left=tables_left, right=tables_right, left_only=tables_left_only,
111+
right_only=tables_right_only, common=tables_common)
112+
113+
114+
def _get_tables(left_inspector, right_inspector, ignore_tables):
115+
"""Get table names for both databases. ``ignore_tables`` are removed. """
116+
tables_left = _get_tables_names(left_inspector, ignore_tables)
117+
tables_right = _get_tables_names(right_inspector, ignore_tables)
118+
return tables_left, tables_right
119+
120+
121+
def _get_tables_names(inspector, ignore_tables):
122+
return sorted(set(inspector.get_table_names()) - ignore_tables)
123+
124+
125+
def _get_tables_diff(tables_left, tables_right):
126+
return (
127+
_diff_table_lists(tables_left, tables_right),
128+
_diff_table_lists(tables_right, tables_left)
129+
)
130+
131+
132+
def _diff_table_lists(tables_left, tables_right):
133+
return sorted(set(tables_left) - set(tables_right))
134+
135+
136+
def _get_common_tables(tables_left, tables_right):
137+
return sorted(set(tables_left) & set(tables_right))
138+
139+
140+
def _get_info_dict(left_uri, right_uri, tables_info):
141+
"""Create an empty stub for the `info` dict. """
142+
info = {
143+
'uris': {
144+
'left': left_uri,
145+
'right': right_uri,
146+
},
147+
'tables': {
148+
'left': tables_info.left,
149+
'left_only': tables_info.left_only,
150+
'right': tables_info.right,
151+
'right_only': tables_info.right_only,
152+
'common': tables_info.common,
153+
},
154+
'tables_data': {},
155+
}
156+
157+
return info
158+
159+
160+
def _get_tables_data(tables_common, left_inspector, right_inspector):
161+
tables_data = {}
162+
163+
for table_name in tables_common:
164+
table_data = _get_table_data(
165+
left_inspector, right_inspector, table_name)
166+
tables_data[table_name] = table_data
167+
168+
return tables_data
169+
170+
171+
def _get_table_data(left_inspector, right_inspector, table_name):
172+
table_data = {}
173+
174+
# foreign keys
175+
table_data['foreign_keys'] = _get_foreign_keys_info(
176+
left_inspector, right_inspector, table_name)
177+
178+
table_data['primary_keys'] = _get_primary_keys_info(
179+
left_inspector, right_inspector, table_name)
180+
181+
table_data['indexes'] = _get_indexes_info(
182+
left_inspector, right_inspector, table_name)
183+
184+
table_data['columns'] = _get_columns_info(
185+
left_inspector, right_inspector, table_name)
186+
187+
return table_data
188+
189+
190+
def _diff_dicts(left, right):
191+
"""Makes the diff of two dictionaries, based on keys and values.
192+
193+
:return:
194+
A 4-tuple with elements::
195+
196+
* A list of elements only in left
197+
* A list of elements only in right
198+
* A list of common elements
199+
* A list of diff elements
200+
{'key':..., 'left':..., 'right':...}
201+
"""
202+
left_only_key = set(left) - set(right)
203+
right_only_key = set(right) - set(left)
204+
205+
left_only = [left[key] for key in left_only_key]
206+
right_only = [right[key] for key in right_only_key]
207+
208+
# common and diff
209+
common_keys = set(left) & set(right)
210+
common = []
211+
diff = []
212+
213+
for key in common_keys:
214+
if left[key] == right[key]:
215+
common.append(left[key])
216+
else:
217+
diff.append({
218+
'key': key,
219+
'left': left[key],
220+
'right': right[key],
221+
})
222+
223+
return DiffResult(
224+
left_only=left_only, right_only=right_only, common=common, diff=diff
225+
)._asdict()
226+
227+
228+
def _get_foreign_keys_info(left_inspector, right_inspector, table_name):
229+
left_fk_list = _get_foreign_keys(left_inspector, table_name)
230+
right_fk_list = _get_foreign_keys(right_inspector, table_name)
231+
232+
# process into dict
233+
left_fk = dict((elem['name'], elem) for elem in left_fk_list)
234+
right_fk = dict((elem['name'], elem) for elem in right_fk_list)
235+
236+
return _diff_dicts(left_fk, right_fk)
237+
238+
239+
def _get_foreign_keys(inspector, table_name):
240+
return inspector.get_foreign_keys(table_name)
241+
242+
243+
def _get_primary_keys_info(left_inspector, right_inspector, table_name):
244+
left_pk_list = _get_primary_keys(left_inspector, table_name)
245+
right_pk_list = _get_primary_keys(right_inspector, table_name)
246+
247+
# process into dict
248+
left_pk = dict((elem, elem) for elem in left_pk_list)
249+
right_pk = dict((elem, elem) for elem in right_pk_list)
250+
251+
return _diff_dicts(left_pk, right_pk)
252+
253+
254+
def _get_primary_keys(inspector, table_name):
255+
return inspector.get_primary_keys(table_name)
256+
257+
258+
def _get_indexes_info(left_inspector, right_inspector, table_name):
259+
left_index_list = _get_indexes(left_inspector, table_name)
260+
right_index_list = _get_indexes(right_inspector, table_name)
261+
262+
# process into dict
263+
left_index = dict((elem['name'], elem) for elem in left_index_list)
264+
right_index = dict((elem['name'], elem) for elem in right_index_list)
265+
266+
return _diff_dicts(left_index, right_index)
267+
268+
269+
def _get_indexes(inspector, table_name):
270+
return inspector.get_indexes(table_name)
271+
272+
273+
def _get_columns_info(left_inspector, right_inspector, table_name):
274+
left_columns_list = _get_columns(left_inspector, table_name)
275+
right_columns_list = _get_columns(right_inspector, table_name)
276+
277+
# process into dict
278+
left_columns = dict((elem['name'], elem) for elem in left_columns_list)
279+
right_columns = dict((elem['name'], elem) for elem in right_columns_list)
280+
281+
# process `type` fields
282+
_process_types(left_columns)
283+
_process_types(right_columns)
284+
285+
return _diff_dicts(left_columns, right_columns)
286+
287+
288+
def _get_columns(inspector, table_name):
289+
return inspector.get_columns(table_name)
290+
291+
292+
def _process_types(column_dict):
293+
for column in column_dict:
294+
column_dict[column]['type'] = _process_type(
295+
column_dict[column]['type'])
296+
297+
298+
def _process_type(type_):
299+
"""Process the SQLAlchemy Column Type ``type_``.
300+
301+
Calls :meth:`sqlalchemy.sql.type_api.TypeEngine.compile` on
302+
``type_`` to produce a string-compiled form of it. "string-compiled"
303+
meaning as it would be used for a SQL clause.
304+
"""
305+
return type_.compile()
306+
307+
308+
def _compile_errors(info):
309+
"""Create ``errors`` dict from ``info`` dict. """
310+
errors_template = {
311+
'tables': {},
312+
'tables_data': {},
313+
}
314+
errors = deepcopy(errors_template)
315+
316+
# first check if tables aren't a match
317+
if info['tables']['left_only']:
318+
errors['tables']['left_only'] = info['tables']['left_only']
319+
320+
if info['tables']['right_only']:
321+
errors['tables']['right_only'] = info['tables']['right_only']
322+
323+
# then check if there is a discrepancy in the data for each table
324+
keys = ['foreign_keys', 'primary_keys', 'indexes', 'columns']
325+
subkeys = ['left_only', 'right_only', 'diff']
326+
327+
for table_name in info['tables_data']:
328+
for key in keys:
329+
for subkey in subkeys:
330+
if info['tables_data'][table_name][key][subkey]:
331+
table_d = errors['tables_data'].setdefault(table_name, {})
332+
table_d.setdefault(key, {})[subkey] = info[
333+
'tables_data'][table_name][key][subkey]
334+
335+
if errors != errors_template:
336+
errors['uris'] = info['uris']
337+
return errors
338+
return {}
339+
340+
341+
def _make_result(info, errors):
342+
"""Create a :class:`~.util.CompareResult` object. """
343+
return CompareResult(info, errors)

sqlalchemydiff/pyfixtures.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# -*- coding: utf-8 -*-
2+
import pytest
3+
4+
from .util import (
5+
get_temporary_uri,
6+
new_db,
7+
destroy_database,
8+
)
9+
10+
11+
@pytest.fixture(scope="module")
12+
def db_uri():
13+
return "mysql+mysqlconnector://root:@localhost/sqlalchemydiff"
14+
15+
16+
@pytest.fixture(scope="module")
17+
def uri_left(db_uri):
18+
return get_temporary_uri(db_uri)
19+
20+
21+
@pytest.fixture(scope="module")
22+
def uri_right(db_uri):
23+
return get_temporary_uri(db_uri)
24+
25+
26+
@pytest.yield_fixture
27+
def new_db_left(uri_left):
28+
new_db(uri_left)
29+
yield
30+
destroy_database(uri_left)
31+
32+
33+
@pytest.yield_fixture
34+
def new_db_right(uri_right):
35+
new_db(uri_right)
36+
yield
37+
destroy_database(uri_right)

0 commit comments

Comments
 (0)