xml2db 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- debug.py +34 -0
- xml2db/__init__.py +21 -0
- xml2db/document.py +650 -0
- xml2db/exceptions.py +4 -0
- xml2db/model.py +619 -0
- xml2db/table/__init__.py +5 -0
- xml2db/table/column.py +190 -0
- xml2db/table/duplicated_table.py +180 -0
- xml2db/table/relations.py +243 -0
- xml2db/table/reused_table.py +152 -0
- xml2db/table/table.py +356 -0
- xml2db/table/transformed_table.py +314 -0
- xml2db/xml_converter.py +258 -0
- xml2db-0.9.0.dist-info/LICENSE +19 -0
- xml2db-0.9.0.dist-info/METADATA +100 -0
- xml2db-0.9.0.dist-info/RECORD +18 -0
- xml2db-0.9.0.dist-info/WHEEL +5 -0
- xml2db-0.9.0.dist-info/top_level.txt +2 -0
xml2db/document.py
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import datetime
|
|
3
|
+
import logging
|
|
4
|
+
import multiprocessing
|
|
5
|
+
from hashlib import sha1
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Union, TYPE_CHECKING, Dict
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import Column, Table, text, select
|
|
10
|
+
from sqlalchemy.engine import Connection
|
|
11
|
+
from sqlalchemy.sql.expression import TextClause
|
|
12
|
+
from lxml import etree
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .model import DataModel
|
|
16
|
+
|
|
17
|
+
from xml2db.exceptions import DataModelConfigError
|
|
18
|
+
from xml2db.xml_converter import XMLConverter
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Document:
|
|
24
|
+
"""A class to represent a single XML file with its data, based on a given XSD.
|
|
25
|
+
|
|
26
|
+
Based on a given DataModel object which represents the data model defined in the XSD, this class deals with \
|
|
27
|
+
the data itself. It allows parsing an XML file to extract the data into the data model format \
|
|
28
|
+
(performing the transforms defined in the DataModel object) and inserting the data into the database.
|
|
29
|
+
|
|
30
|
+
:param model: A `DataModel` object for this document
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, model: "DataModel"):
|
|
34
|
+
self.model = model
|
|
35
|
+
self.data = {}
|
|
36
|
+
self.xml_file_path = None
|
|
37
|
+
|
|
38
|
+
def parse_xml(
|
|
39
|
+
self,
|
|
40
|
+
xml_file: Union[str, BytesIO],
|
|
41
|
+
xml_file_path: str = None,
|
|
42
|
+
skip_validation: bool = True,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Parse an XML document and apply transformation corresponding to the target data model
|
|
45
|
+
|
|
46
|
+
This method will first parse the XML file into a dict (document tree) using lxml
|
|
47
|
+
and then compute hash for all nodes based on their content, and finally convert
|
|
48
|
+
the document tree to tables data, creating primary keys and relations, ready to
|
|
49
|
+
be inserted in the database.
|
|
50
|
+
|
|
51
|
+
:param xml_file: the path or the file object of an XML file to parse
|
|
52
|
+
:param xml_file_path: path of the XML file, must be provided if 'xml_file' is provided as a file object \
|
|
53
|
+
(type 'BytesIO'), in order to fill the 'xml2db_input_file_path' column of the root table.
|
|
54
|
+
:param skip_validation: should we validate the document against the schema first? default to skip for \
|
|
55
|
+
backward compatibility
|
|
56
|
+
"""
|
|
57
|
+
if type(xml_file) == BytesIO:
|
|
58
|
+
if xml_file_path is None:
|
|
59
|
+
error_message = (
|
|
60
|
+
"If 'xml_file' is provided as a file object (type 'BytesIO') then 'xml_file_path' must be provided "
|
|
61
|
+
"too in order to fill the 'xml2db_input_file_path' column of the root table."
|
|
62
|
+
)
|
|
63
|
+
logger.error(error_message)
|
|
64
|
+
raise ValueError(error_message)
|
|
65
|
+
self.xml_file_path = xml_file_path if xml_file_path is not None else xml_file
|
|
66
|
+
|
|
67
|
+
document_tree = self.model.xml_converter.parse_xml(
|
|
68
|
+
xml_file, self.xml_file_path, skip_validation
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
logger.info(f"Computing records hashes for {self.xml_file_path}")
|
|
72
|
+
self._compute_records_hashes(document_tree)
|
|
73
|
+
|
|
74
|
+
if "document_tree_hook" in self.model.model_config:
|
|
75
|
+
if not callable(self.model.model_config["document_tree_hook"]):
|
|
76
|
+
raise DataModelConfigError(
|
|
77
|
+
"document_tree_hook provided in config must be callable"
|
|
78
|
+
)
|
|
79
|
+
logger.info(f"Running document_tree_hook function for {self.xml_file_path}")
|
|
80
|
+
document_tree = self.model.model_config["document_tree_hook"](document_tree)
|
|
81
|
+
|
|
82
|
+
logger.info(f"Adding records to data model for {self.xml_file_path}")
|
|
83
|
+
self.data = self.doc_tree_to_flat_data(document_tree)
|
|
84
|
+
|
|
85
|
+
logger.debug(self.__repr__())
|
|
86
|
+
|
|
87
|
+
def to_xml(
|
|
88
|
+
self, out_file: str = None, nsmap: dict = None, indent: str = " "
|
|
89
|
+
) -> etree.Element:
|
|
90
|
+
"""Convert a document tree (nested dict) into an XML file
|
|
91
|
+
|
|
92
|
+
:param out_file: If provided, write output to a file.
|
|
93
|
+
:param nsmap: An optional namespace mapping.
|
|
94
|
+
:param indent: A string used as indentin XML output.
|
|
95
|
+
:return: The etree object corresponding to the root XML node.
|
|
96
|
+
"""
|
|
97
|
+
converter = XMLConverter(self.model)
|
|
98
|
+
converter.document_tree = self.flat_data_to_doc_tree()
|
|
99
|
+
return converter.to_xml(out_file=out_file, nsmap=nsmap, indent=indent)
|
|
100
|
+
|
|
101
|
+
def _compute_records_hashes(self, node: Dict) -> bytes:
|
|
102
|
+
"""Compute the hash of records recursively, taking into account children, for deduplication purpose.
|
|
103
|
+
|
|
104
|
+
:param node: a node of the parsed document tree
|
|
105
|
+
:return: the hash string representation of the node
|
|
106
|
+
"""
|
|
107
|
+
if node is None:
|
|
108
|
+
return b""
|
|
109
|
+
h = sha1()
|
|
110
|
+
table = self.model.tables[node["type"]]
|
|
111
|
+
for field_type, name, _ in table.fields:
|
|
112
|
+
if field_type == "col":
|
|
113
|
+
h.update(str(node["content"].get(name, None)).encode("utf-8"))
|
|
114
|
+
elif field_type == "rel1":
|
|
115
|
+
h.update(
|
|
116
|
+
self._compute_records_hashes(node["content"].get(name, [None])[0])
|
|
117
|
+
)
|
|
118
|
+
elif field_type == "reln":
|
|
119
|
+
h_children = [
|
|
120
|
+
self._compute_records_hashes(v)
|
|
121
|
+
for v in node["content"].get(name, [])
|
|
122
|
+
]
|
|
123
|
+
for h_child in sorted(h_children):
|
|
124
|
+
h.update(h_child)
|
|
125
|
+
node["xml2db_record_hash"] = h.digest()
|
|
126
|
+
return node["xml2db_record_hash"]
|
|
127
|
+
|
|
128
|
+
def doc_tree_to_flat_data(self, document_tree: dict) -> dict:
|
|
129
|
+
"""Convert document tree (nested dict) to flat tables data model to prepare database import
|
|
130
|
+
|
|
131
|
+
:param document_tree: A nested dict which represents an XML document
|
|
132
|
+
:returns: A dict containing flat tables
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def _extract_node(
|
|
136
|
+
node: Dict, pk_parent_node: int, row_number: int, data_model: dict
|
|
137
|
+
) -> int:
|
|
138
|
+
"""Extract nodes recursively
|
|
139
|
+
|
|
140
|
+
:param node: a dict containing a node of the document tree
|
|
141
|
+
:param pk_parent_node: the primary key of its parent node
|
|
142
|
+
:param data_model: the dict to write output to
|
|
143
|
+
:return: the primary key given to this node
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
# get the corresponding table model
|
|
147
|
+
model_table = self.model.tables[node["type"]]
|
|
148
|
+
|
|
149
|
+
# initialize data structure
|
|
150
|
+
if node["type"] not in data_model:
|
|
151
|
+
data_model[node["type"]] = {"next_pk": 1, "records": []}
|
|
152
|
+
if model_table.is_reused:
|
|
153
|
+
data_model[node["type"]]["hashmap"] = {}
|
|
154
|
+
if any(
|
|
155
|
+
[
|
|
156
|
+
rel.other_table.is_reused
|
|
157
|
+
for rel in model_table.relations_n.values()
|
|
158
|
+
]
|
|
159
|
+
):
|
|
160
|
+
data_model[node["type"]]["relations_n"] = {
|
|
161
|
+
rel.rel_table_name: {"next_pk": 1, "records": []}
|
|
162
|
+
for rel in model_table.relations_n.values()
|
|
163
|
+
if rel.other_table.is_reused
|
|
164
|
+
}
|
|
165
|
+
data = data_model[node["type"]]
|
|
166
|
+
|
|
167
|
+
hex_hash = str(node["xml2db_record_hash"])
|
|
168
|
+
|
|
169
|
+
# if node is reused and a record with identical hash is already inserted, return its pk
|
|
170
|
+
if model_table.is_reused:
|
|
171
|
+
if hex_hash in data["hashmap"]:
|
|
172
|
+
return data["hashmap"][hex_hash]
|
|
173
|
+
|
|
174
|
+
record = {}
|
|
175
|
+
|
|
176
|
+
# add pk
|
|
177
|
+
record_pk = data["next_pk"]
|
|
178
|
+
record[f"temp_pk_{model_table.name}"] = record_pk
|
|
179
|
+
data["next_pk"] += 1
|
|
180
|
+
|
|
181
|
+
# add parent pk if node is not reused
|
|
182
|
+
if not model_table.is_reused:
|
|
183
|
+
record[f"temp_fk_parent_{model_table.parent.name}"] = pk_parent_node
|
|
184
|
+
if self.model.model_config["row_numbers"]:
|
|
185
|
+
record["xml2db_row_number"] = row_number
|
|
186
|
+
|
|
187
|
+
# build record from fields for columns and n-1 relations
|
|
188
|
+
for field_type, key, _ in model_table.fields:
|
|
189
|
+
if field_type == "col":
|
|
190
|
+
if key in node["content"]:
|
|
191
|
+
if model_table.columns[key].data_type in ["decimal", "float"]:
|
|
192
|
+
val = [float(v) for v in node["content"][key]]
|
|
193
|
+
elif model_table.columns[key].data_type == "integer":
|
|
194
|
+
val = [int(v) for v in node["content"][key]]
|
|
195
|
+
elif model_table.columns[key].data_type == "boolean":
|
|
196
|
+
val = [
|
|
197
|
+
v == "true" or v == "1" for v in node["content"][key]
|
|
198
|
+
]
|
|
199
|
+
else:
|
|
200
|
+
val = node["content"][key]
|
|
201
|
+
|
|
202
|
+
if len(val) == 1:
|
|
203
|
+
record[key] = val[0]
|
|
204
|
+
else:
|
|
205
|
+
esc_val = [str(v).replace('"', '\\"') for v in val]
|
|
206
|
+
esc_val = [
|
|
207
|
+
f'"{v}"' if "," in v or '"' in v else v for v in esc_val
|
|
208
|
+
]
|
|
209
|
+
record[key] = ",".join(esc_val)
|
|
210
|
+
else:
|
|
211
|
+
record[key] = None
|
|
212
|
+
|
|
213
|
+
elif field_type == "rel1":
|
|
214
|
+
rel = model_table.relations_1[key]
|
|
215
|
+
if key in node["content"]:
|
|
216
|
+
record[f"temp_{rel.field_name}"] = _extract_node(
|
|
217
|
+
node["content"][key][0],
|
|
218
|
+
record_pk,
|
|
219
|
+
0,
|
|
220
|
+
data_model,
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
record[f"temp_{rel.field_name}"] = None
|
|
224
|
+
|
|
225
|
+
record["xml2db_record_hash"] = bytes(node["xml2db_record_hash"])
|
|
226
|
+
|
|
227
|
+
# add integration meta data if root table
|
|
228
|
+
if model_table.type_name == self.model.root_table:
|
|
229
|
+
record["xml2db_input_file_path"] = self.xml_file_path
|
|
230
|
+
record["xml2db_processed_at"] = self.model.processed_at
|
|
231
|
+
|
|
232
|
+
# add n-n relationship data for reused children nodes
|
|
233
|
+
for rel in model_table.relations_n.values():
|
|
234
|
+
if rel.name in node["content"]:
|
|
235
|
+
if rel.other_table.is_reused:
|
|
236
|
+
rel_data = data["relations_n"][rel.rel_table_name]
|
|
237
|
+
i = 1
|
|
238
|
+
for rel_child in node["content"][rel.name]:
|
|
239
|
+
rel_row = {
|
|
240
|
+
f"temp_fk_{model_table.name}": record_pk,
|
|
241
|
+
f"temp_fk_{rel.other_table.name}": _extract_node(
|
|
242
|
+
rel_child,
|
|
243
|
+
record_pk,
|
|
244
|
+
i,
|
|
245
|
+
data_model,
|
|
246
|
+
),
|
|
247
|
+
}
|
|
248
|
+
if self.model.model_config["row_numbers"]:
|
|
249
|
+
rel_row["xml2db_row_number"] = i
|
|
250
|
+
rel_data["records"].append(rel_row)
|
|
251
|
+
i += 1
|
|
252
|
+
else:
|
|
253
|
+
i = 1
|
|
254
|
+
for rel_child in node["content"][rel.name]:
|
|
255
|
+
_extract_node(rel_child, record_pk, i, data_model)
|
|
256
|
+
i += 1
|
|
257
|
+
|
|
258
|
+
data["records"].append(record)
|
|
259
|
+
|
|
260
|
+
if model_table.is_reused:
|
|
261
|
+
data["hashmap"][hex_hash] = record_pk
|
|
262
|
+
|
|
263
|
+
return record_pk
|
|
264
|
+
|
|
265
|
+
flat_tables = {}
|
|
266
|
+
_extract_node(document_tree, 0, 0, flat_tables)
|
|
267
|
+
|
|
268
|
+
return flat_tables
|
|
269
|
+
|
|
270
|
+
def flat_data_to_doc_tree(self) -> dict:
|
|
271
|
+
"""Convert the data stored in flat tables into a document tree (nested dict)
|
|
272
|
+
|
|
273
|
+
:return: The document tree (nested dict)
|
|
274
|
+
"""
|
|
275
|
+
data_index = {}
|
|
276
|
+
|
|
277
|
+
# convert data to keyed dict for easier access
|
|
278
|
+
temp = (
|
|
279
|
+
""
|
|
280
|
+
if f"pk_{self.model.tables[self.model.root_table].name}"
|
|
281
|
+
in self.data[self.model.root_table]["records"][0]
|
|
282
|
+
else "temp_"
|
|
283
|
+
)
|
|
284
|
+
for tb in self.model.tables.values():
|
|
285
|
+
data_index[tb.type_name] = {
|
|
286
|
+
"records": {},
|
|
287
|
+
"relations_n": {},
|
|
288
|
+
}
|
|
289
|
+
if tb.type_name in self.data:
|
|
290
|
+
data_index[tb.type_name]["records"] = {
|
|
291
|
+
row[f"{temp}pk_{tb.name}"]: row
|
|
292
|
+
for row in self.data[tb.type_name]["records"]
|
|
293
|
+
}
|
|
294
|
+
for rel in tb.relations_n.values():
|
|
295
|
+
index = {}
|
|
296
|
+
if rel.other_table.is_reused:
|
|
297
|
+
if tb.type_name in self.data:
|
|
298
|
+
for row in self.data[tb.type_name]["relations_n"][
|
|
299
|
+
rel.rel_table_name
|
|
300
|
+
]["records"]:
|
|
301
|
+
if row[f"{temp}fk_{tb.name}"] not in index:
|
|
302
|
+
index[row[f"{temp}fk_{tb.name}"]] = []
|
|
303
|
+
index[row[f"{temp}fk_{tb.name}"]].append(
|
|
304
|
+
row[f"{temp}fk_{rel.other_table.name}"]
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
if rel.other_table.type_name in self.data:
|
|
308
|
+
for row in self.data[rel.other_table.type_name]["records"]:
|
|
309
|
+
if row[f"{temp}fk_parent_{tb.name}"] not in index:
|
|
310
|
+
index[row[f"{temp}fk_parent_{tb.name}"]] = []
|
|
311
|
+
index[row[f"{temp}fk_parent_{tb.name}"]].append(
|
|
312
|
+
row[f"{temp}pk_{rel.other_table.name}"]
|
|
313
|
+
)
|
|
314
|
+
data_index[tb.type_name]["relations_n"][rel.rel_table_name] = index
|
|
315
|
+
|
|
316
|
+
def _build_node(node_type: str, node_pk: int) -> dict:
|
|
317
|
+
"""Build a dict node recursively
|
|
318
|
+
|
|
319
|
+
:param node_type: The node type
|
|
320
|
+
:param node_pk: The node primary key
|
|
321
|
+
:return: A node as a dict
|
|
322
|
+
"""
|
|
323
|
+
tb = self.model.tables[node_type]
|
|
324
|
+
node = {
|
|
325
|
+
"type": node_type,
|
|
326
|
+
"content": {},
|
|
327
|
+
}
|
|
328
|
+
record = data_index[node_type]["records"][node_pk]
|
|
329
|
+
for field_type, rel_name, rel in tb.fields:
|
|
330
|
+
if field_type == "col" and record[rel_name] is not None:
|
|
331
|
+
if rel.data_type in [
|
|
332
|
+
"decimal",
|
|
333
|
+
"float",
|
|
334
|
+
]: # remove trailing ".0" for decimal and float
|
|
335
|
+
node["content"][rel_name] = [
|
|
336
|
+
value.rstrip("0").rstrip(".") if "." in value else value
|
|
337
|
+
for value in str(record[rel_name]).split(",")
|
|
338
|
+
]
|
|
339
|
+
elif isinstance(record[rel_name], datetime.datetime):
|
|
340
|
+
node["content"][rel_name] = [
|
|
341
|
+
record[rel_name].isoformat(timespec="milliseconds")
|
|
342
|
+
]
|
|
343
|
+
else:
|
|
344
|
+
node["content"][rel_name] = (
|
|
345
|
+
list(csv.reader([str(record[rel_name])], escapechar="\\"))[
|
|
346
|
+
0
|
|
347
|
+
]
|
|
348
|
+
if "," in str(record[rel_name])
|
|
349
|
+
else [str(record[rel_name])]
|
|
350
|
+
)
|
|
351
|
+
elif (
|
|
352
|
+
field_type == "rel1"
|
|
353
|
+
and record[f"{temp}{rel.field_name}"] is not None
|
|
354
|
+
):
|
|
355
|
+
node["content"][rel_name] = [
|
|
356
|
+
_build_node(
|
|
357
|
+
rel.other_table.type_name, record[f"{temp}{rel.field_name}"]
|
|
358
|
+
)
|
|
359
|
+
]
|
|
360
|
+
elif (
|
|
361
|
+
field_type == "reln"
|
|
362
|
+
and node_pk
|
|
363
|
+
in data_index[tb.type_name]["relations_n"][rel.rel_table_name]
|
|
364
|
+
):
|
|
365
|
+
node["content"][rel_name] = [
|
|
366
|
+
_build_node(rel.other_table.type_name, pk)
|
|
367
|
+
for pk in data_index[tb.type_name]["relations_n"][
|
|
368
|
+
rel.rel_table_name
|
|
369
|
+
][node_pk]
|
|
370
|
+
]
|
|
371
|
+
return node
|
|
372
|
+
|
|
373
|
+
return _build_node(
|
|
374
|
+
self.model.root_table,
|
|
375
|
+
int(list(data_index[self.model.root_table]["records"].keys())[0]),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def insert_into_temp_tables(self) -> None:
|
|
379
|
+
"""Insert data into temporary tables
|
|
380
|
+
|
|
381
|
+
(Re)creates temp tables before inserting data.
|
|
382
|
+
"""
|
|
383
|
+
logger.info(f"Dropping temp tables if exist for {self.xml_file_path}")
|
|
384
|
+
self.model.drop_all_temp_tables()
|
|
385
|
+
|
|
386
|
+
logger.info(f"Creating temp tables for {self.xml_file_path}")
|
|
387
|
+
self.model.create_all_tables(temp=True)
|
|
388
|
+
|
|
389
|
+
logger.info(f"Inserting data into temporary tables from {self.xml_file_path}")
|
|
390
|
+
for tb in self.model.fk_ordered_tables:
|
|
391
|
+
for query, data in tb.get_insert_temp_records_statements(
|
|
392
|
+
self.data.get(tb.type_name, None)
|
|
393
|
+
):
|
|
394
|
+
with self.model.engine.begin() as conn:
|
|
395
|
+
conn.execute(query, data)
|
|
396
|
+
|
|
397
|
+
def merge_into_target_tables(self) -> int:
|
|
398
|
+
"""Merge data into target data model
|
|
399
|
+
|
|
400
|
+
Execute all update and insert statements needed to merge temporary tables content \
|
|
401
|
+
into target tables, within a transaction.
|
|
402
|
+
|
|
403
|
+
:return: The number of inserted rows
|
|
404
|
+
"""
|
|
405
|
+
inserted_rows_count = 0
|
|
406
|
+
with self.model.engine.begin() as conn:
|
|
407
|
+
for tb in self.model.fk_ordered_tables:
|
|
408
|
+
for query in tb.get_merge_temp_records_statements():
|
|
409
|
+
result = conn.execute(query)
|
|
410
|
+
if query.is_insert:
|
|
411
|
+
inserted_rows_count += result.rowcount
|
|
412
|
+
if inserted_rows_count == 0:
|
|
413
|
+
logger.warning("No rows were inserted!")
|
|
414
|
+
else:
|
|
415
|
+
logger.info(f"Inserted rows: {inserted_rows_count}")
|
|
416
|
+
|
|
417
|
+
return inserted_rows_count
|
|
418
|
+
|
|
419
|
+
def insert_into_target_tables(
|
|
420
|
+
self,
|
|
421
|
+
db_semaphore: multiprocessing.Semaphore = None,
|
|
422
|
+
) -> int:
|
|
423
|
+
"""Insert and merge data into the database
|
|
424
|
+
|
|
425
|
+
Insert data into temporary tables and then merge temporary tables into target tables.
|
|
426
|
+
|
|
427
|
+
:param db_semaphore: An optional semaphore to avoid concurrent access to the database. When provided, it will \
|
|
428
|
+
ensure that only one merging operation at a time is performed.
|
|
429
|
+
:return: The number of inserted rows
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
self.model.create_db_schema()
|
|
433
|
+
self.insert_into_temp_tables()
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.error(
|
|
436
|
+
f"Error while importing into temporary tables from {self.xml_file_path}"
|
|
437
|
+
)
|
|
438
|
+
logger.error(e)
|
|
439
|
+
raise
|
|
440
|
+
else:
|
|
441
|
+
logger.info(
|
|
442
|
+
f"Merging temporary tables into target tables for {self.xml_file_path}"
|
|
443
|
+
)
|
|
444
|
+
if db_semaphore is not None:
|
|
445
|
+
db_semaphore.acquire()
|
|
446
|
+
try:
|
|
447
|
+
self.model.create_all_tables() # Create target tables if not exist
|
|
448
|
+
inserted_rows = self.merge_into_target_tables()
|
|
449
|
+
except Exception as e:
|
|
450
|
+
logger.error(
|
|
451
|
+
f"Error while merging temporary tables into target tables for {self.xml_file_path}"
|
|
452
|
+
)
|
|
453
|
+
logger.error(e)
|
|
454
|
+
raise
|
|
455
|
+
finally:
|
|
456
|
+
if db_semaphore is not None:
|
|
457
|
+
db_semaphore.release()
|
|
458
|
+
finally:
|
|
459
|
+
logger.info(f"Dropping temporary tables for {self.xml_file_path}")
|
|
460
|
+
self.model.drop_all_temp_tables()
|
|
461
|
+
|
|
462
|
+
return inserted_rows
|
|
463
|
+
|
|
464
|
+
def extract_from_database(
|
|
465
|
+
self,
|
|
466
|
+
root_table_name: str,
|
|
467
|
+
root_select_where: str,
|
|
468
|
+
) -> dict:
|
|
469
|
+
"""Extract a subtree from the database and store it in a flat format
|
|
470
|
+
|
|
471
|
+
:param root_table_name: The root table name to start from
|
|
472
|
+
:param root_select_where: A where clause to apply to this root table
|
|
473
|
+
:return: A shallow dict of flat data tables
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
def _fetch_data(
|
|
477
|
+
sqla_table: Table,
|
|
478
|
+
key_column: Column,
|
|
479
|
+
join_sequence: list[tuple[Column, Table, Column]],
|
|
480
|
+
top_where_clause: TextClause,
|
|
481
|
+
order_by: Union[None, tuple[Column]],
|
|
482
|
+
append_to: list,
|
|
483
|
+
conn: Connection,
|
|
484
|
+
):
|
|
485
|
+
"""Fetch data from a specific table and write fetched rows in a dict keyed by the first row column"""
|
|
486
|
+
quer = select(*(sqla_table.columns.values()))
|
|
487
|
+
|
|
488
|
+
join_sequence = join_sequence.copy()
|
|
489
|
+
if len(join_sequence) > 0:
|
|
490
|
+
left_col, join_tb, right_col = join_sequence.pop()
|
|
491
|
+
sub_quer = select(right_col)
|
|
492
|
+
prev_join_col = left_col
|
|
493
|
+
for left_col, join_tb, right_col in reversed(join_sequence):
|
|
494
|
+
sub_quer = sub_quer.join(join_tb, right_col == prev_join_col)
|
|
495
|
+
prev_join_col = left_col
|
|
496
|
+
sub_quer = sub_quer.where(top_where_clause)
|
|
497
|
+
quer = quer.where(key_column.in_(sub_quer))
|
|
498
|
+
else:
|
|
499
|
+
quer = quer.where(top_where_clause)
|
|
500
|
+
|
|
501
|
+
if order_by:
|
|
502
|
+
quer = quer.order_by(*order_by)
|
|
503
|
+
|
|
504
|
+
col_names = sqla_table.columns.keys()
|
|
505
|
+
for row in conn.execute(quer):
|
|
506
|
+
append_to.append({key: val for key, val in zip(col_names, row)})
|
|
507
|
+
|
|
508
|
+
def _do_extract_table(
|
|
509
|
+
tb,
|
|
510
|
+
top_where_clause,
|
|
511
|
+
parent_table,
|
|
512
|
+
join_sequence,
|
|
513
|
+
res_dict,
|
|
514
|
+
conn,
|
|
515
|
+
):
|
|
516
|
+
"""Fetch tables and relationship tables recursively"""
|
|
517
|
+
if tb.type_name not in res_dict:
|
|
518
|
+
res_dict[tb.type_name] = {"records": []}
|
|
519
|
+
_fetch_data(
|
|
520
|
+
tb.table,
|
|
521
|
+
(
|
|
522
|
+
getattr(tb.table.c, f"pk_{tb.name}")
|
|
523
|
+
if tb.is_reused
|
|
524
|
+
else getattr(tb.table.c, f"fk_parent_{parent_table.name}")
|
|
525
|
+
),
|
|
526
|
+
join_sequence,
|
|
527
|
+
top_where_clause,
|
|
528
|
+
(
|
|
529
|
+
None
|
|
530
|
+
if tb.is_reused or not tb.data_model.model_config["row_numbers"]
|
|
531
|
+
else (
|
|
532
|
+
getattr(tb.table.c, f"fk_parent_{parent_table.name}"),
|
|
533
|
+
tb.table.c.xml2db_row_number,
|
|
534
|
+
)
|
|
535
|
+
),
|
|
536
|
+
res_dict[tb.type_name]["records"],
|
|
537
|
+
conn,
|
|
538
|
+
)
|
|
539
|
+
join_root = (
|
|
540
|
+
[(None, tb.table, getattr(tb.table.c, f"pk_{tb.name}"))]
|
|
541
|
+
if parent_table is None
|
|
542
|
+
else []
|
|
543
|
+
)
|
|
544
|
+
if len(tb.relations_n) > 0:
|
|
545
|
+
if "relations_n" not in res_dict[tb.type_name]:
|
|
546
|
+
res_dict[tb.type_name]["relations_n"] = {}
|
|
547
|
+
for rel in tb.relations_n.values():
|
|
548
|
+
if rel.rel_table_name not in res_dict[tb.type_name]["relations_n"]:
|
|
549
|
+
res_dict[tb.type_name]["relations_n"][rel.rel_table_name] = {
|
|
550
|
+
"records": []
|
|
551
|
+
}
|
|
552
|
+
new_join = []
|
|
553
|
+
if not tb.is_reused:
|
|
554
|
+
new_join = [
|
|
555
|
+
(
|
|
556
|
+
getattr(tb.table.c, f"fk_parent_{parent_table.name}"),
|
|
557
|
+
tb.table,
|
|
558
|
+
getattr(tb.table.c, f"pk_{tb.table.name}"),
|
|
559
|
+
)
|
|
560
|
+
]
|
|
561
|
+
if rel.other_table.is_reused:
|
|
562
|
+
_fetch_data(
|
|
563
|
+
rel.rel_table,
|
|
564
|
+
getattr(rel.rel_table.c, f"fk_{tb.name}"),
|
|
565
|
+
join_sequence + join_root + new_join,
|
|
566
|
+
top_where_clause,
|
|
567
|
+
(
|
|
568
|
+
(
|
|
569
|
+
getattr(rel.rel_table.c, f"fk_{tb.name}"),
|
|
570
|
+
rel.rel_table.c.xml2db_row_number,
|
|
571
|
+
)
|
|
572
|
+
if tb.data_model.model_config["row_numbers"]
|
|
573
|
+
else None
|
|
574
|
+
),
|
|
575
|
+
res_dict[tb.type_name]["relations_n"][rel.rel_table_name][
|
|
576
|
+
"records"
|
|
577
|
+
],
|
|
578
|
+
conn,
|
|
579
|
+
)
|
|
580
|
+
new_join = new_join + [
|
|
581
|
+
(
|
|
582
|
+
getattr(rel.rel_table.c, f"fk_{tb.name}"),
|
|
583
|
+
rel.rel_table,
|
|
584
|
+
getattr(rel.rel_table.c, f"fk_{rel.other_table.name}"),
|
|
585
|
+
)
|
|
586
|
+
]
|
|
587
|
+
_do_extract_table(
|
|
588
|
+
rel.other_table,
|
|
589
|
+
top_where_clause,
|
|
590
|
+
tb,
|
|
591
|
+
join_sequence + join_root + new_join,
|
|
592
|
+
res_dict,
|
|
593
|
+
conn,
|
|
594
|
+
)
|
|
595
|
+
for rel in tb.relations_1.values():
|
|
596
|
+
_do_extract_table(
|
|
597
|
+
rel.other_table,
|
|
598
|
+
top_where_clause,
|
|
599
|
+
tb,
|
|
600
|
+
join_sequence
|
|
601
|
+
+ [
|
|
602
|
+
(
|
|
603
|
+
getattr(
|
|
604
|
+
tb.table.c,
|
|
605
|
+
f"pk_{tb.name}"
|
|
606
|
+
if tb.is_reused
|
|
607
|
+
else f"fk_parent_{parent_table.name}",
|
|
608
|
+
),
|
|
609
|
+
tb.table,
|
|
610
|
+
getattr(tb.table.c, f"{rel.field_name}"),
|
|
611
|
+
)
|
|
612
|
+
],
|
|
613
|
+
res_dict,
|
|
614
|
+
conn,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
flat_tables = {}
|
|
618
|
+
|
|
619
|
+
with self.model.engine.connect() as conn:
|
|
620
|
+
_do_extract_table(
|
|
621
|
+
self.model.tables[root_table_name],
|
|
622
|
+
text(root_select_where),
|
|
623
|
+
None,
|
|
624
|
+
[],
|
|
625
|
+
flat_tables,
|
|
626
|
+
conn,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
self.data = flat_tables
|
|
630
|
+
return flat_tables
|
|
631
|
+
|
|
632
|
+
def __repr__(self) -> str:
|
|
633
|
+
"""Output a repr string for the current document with records count"""
|
|
634
|
+
settings = (
|
|
635
|
+
f"temp_prefix: {self.model.temp_prefix}, db_schema: {self.model.db_schema}"
|
|
636
|
+
)
|
|
637
|
+
if not self.data:
|
|
638
|
+
return f"Empty {self.model.data_flow_name} document ({settings})"
|
|
639
|
+
else:
|
|
640
|
+
n = sum([len(v["records"]) for v in self.data.values()])
|
|
641
|
+
return "\n".join(
|
|
642
|
+
[
|
|
643
|
+
f"Parsed {self.xml_file_path} into a {self.model.data_flow_name} document: {n} records",
|
|
644
|
+
f"({settings})",
|
|
645
|
+
]
|
|
646
|
+
+ [
|
|
647
|
+
f" {self.model.tables[k].name}: {len(v['records'])}"
|
|
648
|
+
for k, v in self.data.items()
|
|
649
|
+
]
|
|
650
|
+
)
|
xml2db/exceptions.py
ADDED