@dforge-core/dforge-mcp 0.2.45 → 0.2.46-next
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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/package.json +48 -46
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +374 -0
- package/skills/dforge-1c-import/references/environment.md +97 -0
- package/skills/dforge-1c-import/references/loading.md +161 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +110 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Copy the infobase's rows into a dForge tenant database.
|
|
5
|
+
|
|
6
|
+
Straight from MS SQL into Postgres with `COPY`, no 1C software and no API: the
|
|
7
|
+
column map (`out/columnmap.json`) says where every ported column comes from, and
|
|
8
|
+
everything in between is the eight conversions described in docs/design-migration.md.
|
|
9
|
+
|
|
10
|
+
The target's physical shape is read from the tenant database itself - `"dForge".entity`
|
|
11
|
+
gives an entity's schema and table, `information_schema` gives that table's columns - so
|
|
12
|
+
the loader cannot disagree with what the installer actually built.
|
|
13
|
+
|
|
14
|
+
python tools/migrate.py plan masterdata # what would be copied
|
|
15
|
+
python tools/migrate.py run masterdata --truncate # do it
|
|
16
|
+
python tools/migrate.py verify masterdata # counts, sums, orphans
|
|
17
|
+
python tools/migrate.py check masterdata # is anything mandatory empty?
|
|
18
|
+
python tools/migrate.py fill masterdata # placeholders on their own
|
|
19
|
+
|
|
20
|
+
Source credentials come from DF1C_SERVER / DF1C_DB / DF1C_USER / DF1C_PASS (Windows
|
|
21
|
+
auth when DF1C_USER is unset). The target from DF_PG_HOST / DF_PG_PORT / DF_PG_USER /
|
|
22
|
+
DF_PG_PASS plus DF_TENANT, which names the database through the platform's own
|
|
23
|
+
`dforge_{tenantCd}` pattern. Both sides are read from `.env` if one is present.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import print_function
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import binascii
|
|
29
|
+
import codecs
|
|
30
|
+
import collections
|
|
31
|
+
import datetime
|
|
32
|
+
import decimal
|
|
33
|
+
import io
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
import uuid
|
|
39
|
+
|
|
40
|
+
import attrs
|
|
41
|
+
import paths
|
|
42
|
+
import delta
|
|
43
|
+
import dump1c
|
|
44
|
+
|
|
45
|
+
# 1C stores dates shifted by the infobase's _YearOffset: 4012 in the column is 2012.
|
|
46
|
+
# The empty date is 0001-01-01, which shifted lands on 2001-01-01.
|
|
47
|
+
YEAR_OFFSET = 2000
|
|
48
|
+
ZERO_REF = b"\x00" * 16
|
|
49
|
+
|
|
50
|
+
CHUNK = 20000
|
|
51
|
+
|
|
52
|
+
TRUE_TEXT = set([u"1", u"true", u"t", u"yes", u"y", u"\u0414\u0430"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- config
|
|
56
|
+
|
|
57
|
+
def load_env():
|
|
58
|
+
"""Read `.env` into the environment without overriding what is already set."""
|
|
59
|
+
path = os.path.join(os.path.dirname(attrs.OUT), ".env")
|
|
60
|
+
if not os.path.isfile(path):
|
|
61
|
+
return
|
|
62
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
63
|
+
for line in f:
|
|
64
|
+
line = line.strip()
|
|
65
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
66
|
+
continue
|
|
67
|
+
key, value = line.split("=", 1)
|
|
68
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pg_connect():
|
|
72
|
+
import psycopg2
|
|
73
|
+
tenant = paths.required("DF_TENANT", "which dForge tenant to write into")
|
|
74
|
+
db = os.environ.get("DF_PG_DB", "dforge_%s" % tenant)
|
|
75
|
+
host = os.environ.get("DF_PG_HOST", "localhost")
|
|
76
|
+
port = int(os.environ.get("DF_PG_PORT", "5438"))
|
|
77
|
+
user = os.environ.get("DF_PG_USER", "dforge")
|
|
78
|
+
password = os.environ.get("DF_PG_PASS", "dforge")
|
|
79
|
+
sys.stderr.write("[i] %s:%d/%s as %s\n" % (host, port, db, user))
|
|
80
|
+
return psycopg2.connect(host=host, port=port, dbname=db, user=user, password=password)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mssql_connect():
|
|
84
|
+
return dump1c.connect(paths.required("DF1C_SERVER", "which MS SQL server holds it"),
|
|
85
|
+
paths.required("DF1C_DB", "which MS SQL infobase to read"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- the map
|
|
89
|
+
|
|
90
|
+
def column_map():
|
|
91
|
+
path = paths.derived("columnmap.json")
|
|
92
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
93
|
+
return json.load(f, object_pairs_hook=collections.OrderedDict)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def source_schema():
|
|
97
|
+
"""Physical column types on the 1C side, from the schema dump."""
|
|
98
|
+
path = os.path.join(attrs.OUT, "schema.json")
|
|
99
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
100
|
+
doc = json.load(f)
|
|
101
|
+
out = {}
|
|
102
|
+
for table in doc["tables"]:
|
|
103
|
+
out[table["name"]] = {
|
|
104
|
+
"rows": table.get("rows") or 0,
|
|
105
|
+
"columns": dict((c["column"], c) for c in table["columns"]),
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def target_tables(pg, module):
|
|
111
|
+
"""entity_cd -> (schema, table, {column: info}), straight from the tenant DB."""
|
|
112
|
+
cur = pg.cursor()
|
|
113
|
+
cur.execute("""SELECT e.entity_cd, e.schema_name, e.db_object
|
|
114
|
+
FROM "dForge".entity e
|
|
115
|
+
JOIN "dForge".module m ON m.module_id = e.module_id
|
|
116
|
+
WHERE m.module_cd = %s""", (module,))
|
|
117
|
+
entities = cur.fetchall()
|
|
118
|
+
out = collections.OrderedDict()
|
|
119
|
+
for entity_cd, schema, table in sorted(entities):
|
|
120
|
+
if not schema or not table:
|
|
121
|
+
continue
|
|
122
|
+
cur.execute("""SELECT column_name, data_type, is_nullable, column_default,
|
|
123
|
+
is_generated, is_identity
|
|
124
|
+
FROM information_schema.columns
|
|
125
|
+
WHERE table_schema = %s AND table_name = %s
|
|
126
|
+
ORDER BY ordinal_position""", (schema, table))
|
|
127
|
+
columns = collections.OrderedDict()
|
|
128
|
+
for name, datatype, nullable, default, generated, identity in cur.fetchall():
|
|
129
|
+
columns[name] = {
|
|
130
|
+
"type": datatype,
|
|
131
|
+
"nullable": nullable == "YES",
|
|
132
|
+
"default": default,
|
|
133
|
+
"generated": generated != "NEVER" or identity == "YES",
|
|
134
|
+
}
|
|
135
|
+
if columns:
|
|
136
|
+
out[entity_cd] = (schema, table, columns)
|
|
137
|
+
|
|
138
|
+
# A module can also own columns on someone else's entity. dForge keeps those in a
|
|
139
|
+
# 1:1 side table named by `entity_column.storage_table`, and the entity they belong
|
|
140
|
+
# to is the host's - so they are addressed the way a manifest addresses them, by the
|
|
141
|
+
# host's dotted name, and the column map carries an entry under the same key.
|
|
142
|
+
cur.execute("""SELECT DISTINCT host_m.module_cd, e.entity_cd, ec.storage_table
|
|
143
|
+
FROM "dForge".entity_column ec
|
|
144
|
+
JOIN "dForge".entity e ON e.entity_id = ec.entity_id
|
|
145
|
+
JOIN "dForge".module m ON m.module_id = ec.module_id
|
|
146
|
+
LEFT JOIN "dForge".module host_m ON host_m.module_id = e.module_id
|
|
147
|
+
WHERE m.module_cd = %s AND ec.storage_table IS NOT NULL""",
|
|
148
|
+
(module,))
|
|
149
|
+
for host_module, entity_cd, storage in sorted(cur.fetchall()):
|
|
150
|
+
schema, table = split_storage(storage)
|
|
151
|
+
if not table:
|
|
152
|
+
continue
|
|
153
|
+
cur.execute("""SELECT column_name, data_type, is_nullable, column_default,
|
|
154
|
+
is_generated, is_identity
|
|
155
|
+
FROM information_schema.columns
|
|
156
|
+
WHERE table_schema = %s AND table_name = %s
|
|
157
|
+
ORDER BY ordinal_position""", (schema, table))
|
|
158
|
+
columns = collections.OrderedDict()
|
|
159
|
+
for name, datatype, nullable, default, generated, identity in cur.fetchall():
|
|
160
|
+
columns[name] = {
|
|
161
|
+
"type": datatype,
|
|
162
|
+
"nullable": nullable == "YES",
|
|
163
|
+
"default": default,
|
|
164
|
+
"generated": generated != "NEVER" or identity == "YES",
|
|
165
|
+
}
|
|
166
|
+
if columns:
|
|
167
|
+
out["%s.%s" % (host_module, entity_cd)] = (schema, table, columns)
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def split_storage(storage):
|
|
172
|
+
"""`"shipping"."addendum_contract_ext"` -> ("shipping", "addendum_contract_ext")."""
|
|
173
|
+
parts = [p.strip().strip('"') for p in (storage or "").split(".")]
|
|
174
|
+
if len(parts) == 2:
|
|
175
|
+
return parts[0], parts[1]
|
|
176
|
+
return None, None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------- conversions
|
|
180
|
+
|
|
181
|
+
def to_uuid(raw):
|
|
182
|
+
"""`_IDRRef binary(16)` -> uuid text. 1C writes the halves the other way round."""
|
|
183
|
+
if raw is None:
|
|
184
|
+
return None
|
|
185
|
+
raw = bytes(raw)
|
|
186
|
+
if raw == ZERO_REF:
|
|
187
|
+
# The empty reference. Left alone it would become a uuid pointing at a row
|
|
188
|
+
# that does not exist, and the foreign key would reject the load.
|
|
189
|
+
return None
|
|
190
|
+
hexed = binascii.hexlify(raw).decode("ascii")
|
|
191
|
+
hexed = hexed[16:] + hexed[:16]
|
|
192
|
+
return "%s-%s-%s-%s-%s" % (hexed[0:8], hexed[8:12], hexed[12:16],
|
|
193
|
+
hexed[16:20], hexed[20:32])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def shift_date(value):
|
|
197
|
+
"""Undo the +2000 year offset; 1C's empty date becomes NULL."""
|
|
198
|
+
if value is None:
|
|
199
|
+
return None
|
|
200
|
+
year = value.year - YEAR_OFFSET
|
|
201
|
+
if year <= 1:
|
|
202
|
+
return None
|
|
203
|
+
return value.replace(year=year)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def text_date(value):
|
|
207
|
+
"""A date 1C parked in a STRING column, which it formatted itself.
|
|
208
|
+
|
|
209
|
+
A dimension declared `Строка(10)` and assigned `НачалоДня(Дата)` leaves `13.01.2016`
|
|
210
|
+
in the column. The year is the real one: the +2000 offset is how 1C stores a
|
|
211
|
+
datetime, and nothing stored a datetime here - the configuration formatted a string.
|
|
212
|
+
Shifting it as well would move every such value back two millennia.
|
|
213
|
+
"""
|
|
214
|
+
if value is None:
|
|
215
|
+
return None
|
|
216
|
+
if isinstance(value, datetime.datetime):
|
|
217
|
+
return shift_date(value)
|
|
218
|
+
if not isinstance(value, type(u"")):
|
|
219
|
+
return None
|
|
220
|
+
text = value.strip()
|
|
221
|
+
if not text:
|
|
222
|
+
return None
|
|
223
|
+
for fmt in ("%d.%m.%Y", "%Y-%m-%d"):
|
|
224
|
+
try:
|
|
225
|
+
return datetime.datetime.strptime(text, fmt).date()
|
|
226
|
+
except ValueError:
|
|
227
|
+
pass
|
|
228
|
+
raise ValueError("cannot read %r as a date" % text)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def to_bool(value):
|
|
232
|
+
if value is None:
|
|
233
|
+
return None
|
|
234
|
+
if isinstance(value, bool):
|
|
235
|
+
return value
|
|
236
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
237
|
+
raw = bytes(value)
|
|
238
|
+
return bool(raw) and raw[0] != 0
|
|
239
|
+
if isinstance(value, (int, float, decimal.Decimal)):
|
|
240
|
+
return value != 0
|
|
241
|
+
return value.strip().lower() in TRUE_TEXT
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def to_number(value):
|
|
245
|
+
"""A number 1C parked in a string column uses a comma for the decimal point."""
|
|
246
|
+
if value is None:
|
|
247
|
+
return None
|
|
248
|
+
if isinstance(value, (int, float, decimal.Decimal)):
|
|
249
|
+
return value
|
|
250
|
+
text = value.strip().replace(u"\u00a0", u"").replace(u" ", u"")
|
|
251
|
+
if not text:
|
|
252
|
+
return None
|
|
253
|
+
return text.replace(u",", u".")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def to_text(value):
|
|
257
|
+
if value is None:
|
|
258
|
+
return None
|
|
259
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
260
|
+
return to_uuid(value) if len(bytes(value)) == 16 else None
|
|
261
|
+
if isinstance(value, datetime.datetime):
|
|
262
|
+
value = shift_date(value)
|
|
263
|
+
return value.isoformat() if value else None
|
|
264
|
+
if not isinstance(value, type(u"")):
|
|
265
|
+
# A number or a flag landing in a text column - the port widened the type.
|
|
266
|
+
return str(value)
|
|
267
|
+
# 1C pads its fixed-width strings, `_Code nchar(18)` being the one that shows.
|
|
268
|
+
return value.rstrip()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_ENUM_CACHE = {}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def enum_decoder(mssql, spec):
|
|
275
|
+
"""An enum reference -> the option code the port gave that value.
|
|
276
|
+
|
|
277
|
+
1C keeps an enum value's position in `_EnumOrder`, and the port read the values in
|
|
278
|
+
that same order, so position is what pairs a GUID with a code.
|
|
279
|
+
"""
|
|
280
|
+
key = (spec["table"], tuple(spec["options"]))
|
|
281
|
+
if key not in _ENUM_CACHE:
|
|
282
|
+
options = spec["options"]
|
|
283
|
+
found = {}
|
|
284
|
+
mssql.execute("SELECT _IDRRef, _EnumOrder FROM [%s]" % spec["table"])
|
|
285
|
+
for raw, order in mssql.fetchall():
|
|
286
|
+
position = int(order)
|
|
287
|
+
if 0 <= position < len(options):
|
|
288
|
+
found[to_uuid(raw)] = options[position]
|
|
289
|
+
_ENUM_CACHE[key] = found
|
|
290
|
+
found = _ENUM_CACHE[key]
|
|
291
|
+
|
|
292
|
+
def decode(value):
|
|
293
|
+
return found.get(to_uuid(value))
|
|
294
|
+
return decode
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def tableref_decoder(table):
|
|
298
|
+
"""The table half of a composite reference -> what the port calls that kind.
|
|
299
|
+
|
|
300
|
+
1C stores a composite reference as three columns: a type tag, which table, and which
|
|
301
|
+
row. The table half is a plain number, and it is the only thing that says what the
|
|
302
|
+
row on the other end actually is.
|
|
303
|
+
"""
|
|
304
|
+
found = dict((int(number), code) for number, code in table.items())
|
|
305
|
+
|
|
306
|
+
def decode(value):
|
|
307
|
+
if value is None:
|
|
308
|
+
return None
|
|
309
|
+
return found.get(int(binascii.hexlify(bytes(value)), 16))
|
|
310
|
+
return decode
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def bind_enums(job, mssql):
|
|
314
|
+
"""Swap in the enum decoders once there is a connection to read the tables with."""
|
|
315
|
+
if not any(job.enums):
|
|
316
|
+
return job
|
|
317
|
+
converters = [enum_decoder(mssql, spec) if spec else f
|
|
318
|
+
for f, spec in zip(job.converters, job.enums)]
|
|
319
|
+
return job._replace(converters=converters)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
NUMERIC_TYPES = set(["smallint", "integer", "bigint", "numeric", "decimal",
|
|
323
|
+
"real", "double precision"])
|
|
324
|
+
DATE_TYPES = set(["date", "timestamp without time zone", "timestamp with time zone"])
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def to_blob(value):
|
|
328
|
+
"""1C's serialized value, carried as it is.
|
|
329
|
+
|
|
330
|
+
Decoding it needs 1C's own format and is a piece of work on its own; losing it
|
|
331
|
+
needs only a `DROP COLUMN`. The bytes are kept so the choice stays open.
|
|
332
|
+
"""
|
|
333
|
+
if value is None:
|
|
334
|
+
return None
|
|
335
|
+
raw = bytes(value)
|
|
336
|
+
return raw or None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def converter(target_type, source_type):
|
|
340
|
+
"""One conversion per (target, source) pair - everything is column-level."""
|
|
341
|
+
if target_type == "bytea":
|
|
342
|
+
return to_blob
|
|
343
|
+
if target_type == "uuid":
|
|
344
|
+
return to_uuid
|
|
345
|
+
if target_type == "boolean":
|
|
346
|
+
return to_bool
|
|
347
|
+
if target_type in NUMERIC_TYPES:
|
|
348
|
+
return to_number
|
|
349
|
+
if target_type in DATE_TYPES:
|
|
350
|
+
# A date the port declares, held as text by the infobase, is not a 1C date value
|
|
351
|
+
# and must not be shifted - see text_date.
|
|
352
|
+
return text_date if source_type in TEXTUAL else shift_date
|
|
353
|
+
return to_text
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def magnitude(value):
|
|
357
|
+
"""The size of a movement, without its direction."""
|
|
358
|
+
number = to_number(value)
|
|
359
|
+
return None if number is None else abs(number)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def direction(value):
|
|
363
|
+
"""Which way a movement went: the platform's `journalDirection` stamp, -1 or +1.
|
|
364
|
+
|
|
365
|
+
A turnover register requires one, and the infobase does not have the column: this
|
|
366
|
+
register's author kept the direction in the SIGN of the value, so the same 1C column
|
|
367
|
+
feeds both our magnitude and this. Zero reads as +1 rather than 0 - a movement of
|
|
368
|
+
nothing still went one way, and 0 is not one of the two values the platform allows.
|
|
369
|
+
|
|
370
|
+
Its sibling for a balance register is `record_kind` below.
|
|
371
|
+
"""
|
|
372
|
+
number = to_number(value)
|
|
373
|
+
return 1 if number is None or number >= 0 else -1
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def record_kind(value):
|
|
377
|
+
"""The direction stamp of a BALANCE register's movement, read off `_RecordKind`.
|
|
378
|
+
|
|
379
|
+
A balance register keeps its resources unsigned and says which way each row went in
|
|
380
|
+
`_RecordKind` - 0 the receipt, 1 the expense - so 0 maps to +1 and 1 to -1. That is the
|
|
381
|
+
same split the platform's `journalDirection` makes, which is what lets a loaded movement
|
|
382
|
+
table be the register's journal as it stands.
|
|
383
|
+
|
|
384
|
+
Anything else is refused rather than guessed: a stamp that read a NULL as +1 would add
|
|
385
|
+
an expense to the balance, and every as-of read would carry the error silently.
|
|
386
|
+
"""
|
|
387
|
+
if value is None:
|
|
388
|
+
raise ValueError("a movement with no _RecordKind has no direction")
|
|
389
|
+
kind = int(value)
|
|
390
|
+
if kind not in (0, 1):
|
|
391
|
+
raise ValueError("_RecordKind %r is neither receipt (0) nor expense (1)" % value)
|
|
392
|
+
return 1 if kind == 0 else -1
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# Named in a project's register overlay (`load`), applied instead of the converter the
|
|
396
|
+
# column's type would have chosen. Keyed by name so the overlay stays declarative.
|
|
397
|
+
def increase(value):
|
|
398
|
+
"""Always +1: a turnover register whose two sides are RESOURCES, not directions.
|
|
399
|
+
|
|
400
|
+
`мсбВыпускПродукцииВЦехах` carries what was reworked and what was handed over as two
|
|
401
|
+
pairs of resources, so every row is additive and the platform's `journalDirection` stamp
|
|
402
|
+
is a constant. Declaring `formula: "1"` on the column was meant to cover this through a
|
|
403
|
+
DB default, and on the upgrade path it did not: the column arrived with no default and
|
|
404
|
+
484,153 rows read NULL, which would make every SUM(value * direction) over the register
|
|
405
|
+
come back NULL - an empty answer rather than an error.
|
|
406
|
+
|
|
407
|
+
So it is filled here instead, where the loader can be held to it. The value it reads is
|
|
408
|
+
ignored; it takes a source column only because that is how the overlay names one.
|
|
409
|
+
"""
|
|
410
|
+
return 1
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def since_beginning(value):
|
|
414
|
+
"""A register period, where 1C's EMPTY date is a value rather than an absence.
|
|
415
|
+
|
|
416
|
+
`shift_date` turns the empty date into NULL, which is right for an ordinary date field -
|
|
417
|
+
nobody filled it in. On an information register's PERIOD it is wrong, and the difference
|
|
418
|
+
is visible in what a slice answers. 1C stores the empty date as 0001-01-01 (2001-01-01
|
|
419
|
+
in the column, before the offset is undone), so `СрезПоследних` on any date finds such a
|
|
420
|
+
record: it is in force from the beginning of time. Load it as NULL and the record answers
|
|
421
|
+
NOTHING, because the engine's slice bounds `period <= @OnDate` and NULL never satisfies
|
|
422
|
+
that - the row is in the table and invisible to every read of it.
|
|
423
|
+
|
|
424
|
+
Measured on `мсбАналогиНоменклатурыДляРецептур`: 330 of its 1,053 rows carry the empty
|
|
425
|
+
date, on 330 distinct dimension tuples, and 2 of those keys ALSO have a dated row - so the
|
|
426
|
+
floor is what makes the dated one supersede rather than be the only answer. With the floor
|
|
427
|
+
the slice key is unique over all 1,053 rows, which is what lets the register be declared
|
|
428
|
+
independent at all: a unique index over a NULL-bearing key enforces nothing, since NULLs
|
|
429
|
+
are distinct to it.
|
|
430
|
+
|
|
431
|
+
It takes the raw column, as every transform does, so it undoes the offset itself.
|
|
432
|
+
"""
|
|
433
|
+
if value is None:
|
|
434
|
+
return None
|
|
435
|
+
year = value.year - YEAR_OFFSET
|
|
436
|
+
return datetime.datetime(1, 1, 1) if year <= 1 else value.replace(year=year)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
EMPTY_KEY = "00000000-0000-0000-0000-000000000000"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def ref_key(raw):
|
|
443
|
+
"""A reference as a register KEY, where 1C's empty reference is a value, not an absence.
|
|
444
|
+
|
|
445
|
+
`to_uuid` turns the empty reference into NULL, which is right for a link - nothing is
|
|
446
|
+
linked. In a register dimension it is wrong: 1C keys the empty reference as a value of
|
|
447
|
+
its own (`ПартииТоваровНаСкладах` holds a batch with no series on 1,920,300 of its
|
|
448
|
+
13.8M movements), and a dForge dimension holds no NULL, since NULL is not equal to NULL
|
|
449
|
+
in a balance, a GROUP BY or a unique key. So it reads as the nil uuid - which is also
|
|
450
|
+
how 1C stores it, sixteen zero bytes - in a plain key column beside the link, never in
|
|
451
|
+
the link itself, whose foreign key has no row to point at.
|
|
452
|
+
"""
|
|
453
|
+
if raw is None or bytes(raw) == ZERO_REF:
|
|
454
|
+
return EMPTY_KEY
|
|
455
|
+
return to_uuid(raw)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
TRANSFORMS = {"magnitude": magnitude, "direction": direction, "record_kind": record_kind,
|
|
459
|
+
"increase": increase, "since_beginning": since_beginning, "ref_key": ref_key}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
# --------------------------------------------------------------------------- COPY
|
|
463
|
+
|
|
464
|
+
def derived_key(src_table, row):
|
|
465
|
+
"""A stable key for a register row 1C never gave one to: the row is the key."""
|
|
466
|
+
seed = src_table + u"|" + u" ".join(encode(v) for v in row)
|
|
467
|
+
return str(uuid.uuid5(uuid.NAMESPACE_OID, seed))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def encode(value):
|
|
471
|
+
"""One field in Postgres' COPY text format."""
|
|
472
|
+
if value is None:
|
|
473
|
+
return "\\N"
|
|
474
|
+
if value is True:
|
|
475
|
+
return "t"
|
|
476
|
+
if value is False:
|
|
477
|
+
return "f"
|
|
478
|
+
if isinstance(value, (datetime.datetime, datetime.date)):
|
|
479
|
+
return value.isoformat()
|
|
480
|
+
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
481
|
+
# COPY reads `\\x...` as hex bytea, and the backslash itself is escaped.
|
|
482
|
+
return u"\\\\x%s" % binascii.hexlify(bytes(value)).decode("ascii")
|
|
483
|
+
if not isinstance(value, type(u"")):
|
|
484
|
+
return str(value)
|
|
485
|
+
return (value.replace(u"\\", u"\\\\").replace(u"\n", u"\\n")
|
|
486
|
+
.replace(u"\r", u"\\r").replace(u"\t", u"\\t"))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def copy_rows(pg, schema, table, columns, rows):
|
|
490
|
+
buf = io.StringIO()
|
|
491
|
+
for row in rows:
|
|
492
|
+
buf.write(u"\t".join(encode(v) for v in row))
|
|
493
|
+
buf.write(u"\n")
|
|
494
|
+
buf.seek(0)
|
|
495
|
+
cur = pg.cursor()
|
|
496
|
+
cur.copy_expert(
|
|
497
|
+
'COPY "%s"."%s" (%s) FROM STDIN'
|
|
498
|
+
% (schema, table, ", ".join('"%s"' % c for c in columns)), buf)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
# ------------------------------------------------------------------- dangling references
|
|
502
|
+
|
|
503
|
+
FOREIGN_KEYS = """
|
|
504
|
+
SELECT c.conname,
|
|
505
|
+
tn.nspname, t.relname, sa.attname,
|
|
506
|
+
fn.nspname, f.relname, fa.attname
|
|
507
|
+
FROM pg_constraint c
|
|
508
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
509
|
+
JOIN pg_namespace tn ON tn.oid = t.relnamespace
|
|
510
|
+
JOIN pg_class f ON f.oid = c.confrelid
|
|
511
|
+
JOIN pg_namespace fn ON fn.oid = f.relnamespace
|
|
512
|
+
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
|
513
|
+
JOIN unnest(c.confkey) WITH ORDINALITY AS fk(attnum, ord) ON fk.ord = k.ord
|
|
514
|
+
JOIN pg_attribute sa ON sa.attrelid = t.oid AND sa.attnum = k.attnum
|
|
515
|
+
JOIN pg_attribute fa ON fa.attrelid = f.oid AND fa.attnum = fk.attnum
|
|
516
|
+
WHERE c.contype = 'f' AND array_length(c.conkey, 1) = 1
|
|
517
|
+
AND tn.nspname = ANY(%s)
|
|
518
|
+
ORDER BY 2, 3, 1
|
|
519
|
+
"""
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def foreign_keys(pg, schemas):
|
|
523
|
+
cur = pg.cursor()
|
|
524
|
+
cur.execute(FOREIGN_KEYS, (sorted(set(schemas)),))
|
|
525
|
+
return cur.fetchall()
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def table_columns(pg, schema, table):
|
|
529
|
+
cur = pg.cursor()
|
|
530
|
+
cur.execute("""SELECT column_name, data_type, is_nullable, column_default,
|
|
531
|
+
is_generated, is_identity, character_maximum_length
|
|
532
|
+
FROM information_schema.columns
|
|
533
|
+
WHERE table_schema = %s AND table_name = %s
|
|
534
|
+
ORDER BY ordinal_position""", (schema, table))
|
|
535
|
+
return cur.fetchall()
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def filler(data_type, max_length, key):
|
|
539
|
+
"""What to put in a column the target insists on but the placeholder has no value for.
|
|
540
|
+
|
|
541
|
+
A text column gets the missing key itself, so the row is traceable back to whatever
|
|
542
|
+
referenced it rather than being one of a crowd of identical blanks.
|
|
543
|
+
"""
|
|
544
|
+
if data_type in ("character varying", "character", "text"):
|
|
545
|
+
if max_length:
|
|
546
|
+
return 'left(%s::text, %d)' % (key, max_length)
|
|
547
|
+
return "%s::text" % key
|
|
548
|
+
if data_type in NUMERIC_TYPES:
|
|
549
|
+
return "0"
|
|
550
|
+
if data_type == "boolean":
|
|
551
|
+
return "false"
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def note(skipped, cur, schema, table, column,
|
|
556
|
+
target_schema, target_table, target_column, why):
|
|
557
|
+
"""Record a target that cannot take a stand-in - but only if one was wanted.
|
|
558
|
+
|
|
559
|
+
Most of these are harmless: a document table has a mandatory date and no default,
|
|
560
|
+
so no placeholder can be built for it, and nothing ever asks for one. Reporting
|
|
561
|
+
those reads like a defect and is not one, so the count comes first.
|
|
562
|
+
"""
|
|
563
|
+
cur.execute('SELECT count(*) FROM "%s"."%s" a WHERE a."%s" IS NOT NULL'
|
|
564
|
+
' AND NOT EXISTS (SELECT 1 FROM "%s"."%s" b WHERE b."%s" = a."%s")'
|
|
565
|
+
% (schema, table, column,
|
|
566
|
+
target_schema, target_table, target_column, column))
|
|
567
|
+
count = cur.fetchone()[0]
|
|
568
|
+
if count:
|
|
569
|
+
skipped.append(("%s.%s -> %s.%s" % (schema, table, target_schema, target_table),
|
|
570
|
+
why, count))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
UNENFORCED = """
|
|
574
|
+
SELECT m.module_cd, e.entity_cd, e.schema_name, lower(e.db_object), lower(ec.this_key),
|
|
575
|
+
ref.schema_name, lower(ref.db_object), lower(ec.other_key)
|
|
576
|
+
FROM "dForge".entity_column ec
|
|
577
|
+
JOIN "dForge".entity e ON e.entity_id = ec.entity_id
|
|
578
|
+
JOIN "dForge".module m ON m.module_id = e.module_id
|
|
579
|
+
JOIN "dForge".entity ref ON ref.entity_id = ec.ref_entity_id
|
|
580
|
+
JOIN information_schema.columns c ON c.table_schema = e.schema_name
|
|
581
|
+
AND c.table_name = lower(e.db_object)
|
|
582
|
+
AND c.column_name = lower(ec.this_key)
|
|
583
|
+
WHERE ec.column_type = 'R' AND ec.this_key IS NOT NULL AND ec.other_key IS NOT NULL
|
|
584
|
+
AND e.schema_name = ANY(%s)
|
|
585
|
+
AND NOT EXISTS (
|
|
586
|
+
SELECT 1 FROM pg_constraint con
|
|
587
|
+
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = con.conkey[1]
|
|
588
|
+
WHERE con.contype = 'f'
|
|
589
|
+
AND con.conrelid = format('%%I.%%I', e.schema_name, lower(e.db_object))::regclass
|
|
590
|
+
AND a.attname = lower(ec.this_key))
|
|
591
|
+
ORDER BY 3, 4, 5
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def unenforced(pg, schemas, mapping=None):
|
|
596
|
+
"""References the metadata declares and no foreign key enforces, shaped like
|
|
597
|
+
`foreign_keys` rows - except those copied from a composite column.
|
|
598
|
+
|
|
599
|
+
A key is missing for one of two reasons. Either the install could not build it - it
|
|
600
|
+
adds a missing key on every reinstall and skips it, with a warning nobody reads, when
|
|
601
|
+
the rows already break it - and then the rows are exactly what this pass exists for.
|
|
602
|
+
Or the reference was never one: a value copied from 1C's composite `_RRRef` half points
|
|
603
|
+
at whatever its `_RTRef` says, and a stand-in for it in the declared target would be a
|
|
604
|
+
fabricated row (autospec.reconcile now makes such a column a raw GUID).
|
|
605
|
+
"""
|
|
606
|
+
mapping = mapping if mapping is not None else column_map()
|
|
607
|
+
cur = pg.cursor()
|
|
608
|
+
cur.execute(UNENFORCED, (sorted(set(schemas)),))
|
|
609
|
+
found = []
|
|
610
|
+
for (module, entity, schema, table, column,
|
|
611
|
+
target_schema, target_table, target_column) in cur.fetchall():
|
|
612
|
+
source = ((mapping.get(module) or {}).get(entity) or {}).get("columns", {}).get(column)
|
|
613
|
+
if isinstance(source, str) and source.endswith("_RRRef"):
|
|
614
|
+
continue
|
|
615
|
+
found.append((None, schema, table, column, target_schema, target_table, target_column))
|
|
616
|
+
return found
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def fill_missing(pg, schemas, verbose=True):
|
|
620
|
+
"""Insert a flagged placeholder for every reference whose target is not there.
|
|
621
|
+
|
|
622
|
+
1C does not enforce referential integrity, so a copy arrives carrying references
|
|
623
|
+
whose target was deleted from the infobase - 5,271 batches name a unit that is gone
|
|
624
|
+
in 1C itself. Nulling those would lose what the row actually said, and dropping the
|
|
625
|
+
foreign key would give up the integrity the port is here to gain. The row is created
|
|
626
|
+
instead, with `is_missing` raised, so the reference resolves and the placeholder is
|
|
627
|
+
visibly not reference data.
|
|
628
|
+
|
|
629
|
+
References no foreign key enforces are walked too (`unenforced`): a reinstall cannot
|
|
630
|
+
build the key while the rows break it, so the stand-ins have to come first.
|
|
631
|
+
"""
|
|
632
|
+
cur = pg.cursor()
|
|
633
|
+
filled, skipped = [], []
|
|
634
|
+
for (_name, schema, table, column,
|
|
635
|
+
target_schema, target_table, target_column) in (foreign_keys(pg, schemas)
|
|
636
|
+
+ unenforced(pg, schemas)):
|
|
637
|
+
columns = table_columns(pg, target_schema, target_table)
|
|
638
|
+
names = set(c[0] for c in columns)
|
|
639
|
+
key = 'a."%s"' % column
|
|
640
|
+
if "is_missing" not in names:
|
|
641
|
+
note(skipped, cur, schema, table, column,
|
|
642
|
+
target_schema, target_table, target_column, "no is_missing column")
|
|
643
|
+
continue
|
|
644
|
+
|
|
645
|
+
into, values = ['"%s"' % target_column, '"is_missing"'], [key, "true"]
|
|
646
|
+
blocked = None
|
|
647
|
+
for (name, data_type, nullable, default,
|
|
648
|
+
generated, identity, max_length) in columns:
|
|
649
|
+
if (name in (target_column, "is_missing") or nullable == "YES"
|
|
650
|
+
or default is not None or generated != "NEVER" or identity == "YES"):
|
|
651
|
+
continue
|
|
652
|
+
value = filler(data_type, max_length, key)
|
|
653
|
+
if value is None:
|
|
654
|
+
blocked = "%s %s" % (name, data_type)
|
|
655
|
+
break
|
|
656
|
+
into.append('"%s"' % name)
|
|
657
|
+
values.append(value)
|
|
658
|
+
if blocked:
|
|
659
|
+
note(skipped, cur, schema, table, column,
|
|
660
|
+
target_schema, target_table, target_column, blocked)
|
|
661
|
+
continue
|
|
662
|
+
|
|
663
|
+
cur.execute(
|
|
664
|
+
'INSERT INTO "%s"."%s" (%s) SELECT DISTINCT %s FROM "%s"."%s" a'
|
|
665
|
+
' WHERE %s IS NOT NULL AND NOT EXISTS ('
|
|
666
|
+
' SELECT 1 FROM "%s"."%s" b WHERE b."%s" = %s)'
|
|
667
|
+
' ON CONFLICT DO NOTHING'
|
|
668
|
+
% (target_schema, target_table, ", ".join(into), ", ".join(values),
|
|
669
|
+
schema, table, key,
|
|
670
|
+
target_schema, target_table, target_column, key))
|
|
671
|
+
if cur.rowcount:
|
|
672
|
+
filled.append((schema, table, column, target_table, cur.rowcount))
|
|
673
|
+
pg.commit()
|
|
674
|
+
|
|
675
|
+
if verbose and filled:
|
|
676
|
+
print("\nplaceholders for references 1C had already lost")
|
|
677
|
+
print("-" * 70)
|
|
678
|
+
for schema, table, column, target, count in filled:
|
|
679
|
+
print("%-26s %-24s -> %-20s %6d"
|
|
680
|
+
% ("%s.%s" % (schema, table), column, target, count))
|
|
681
|
+
if verbose and skipped:
|
|
682
|
+
print("\nreferences left dangling - no stand-in could be built:")
|
|
683
|
+
for target, why, count in sorted(set(skipped)):
|
|
684
|
+
print(" %-44s %6d (%s)" % (target, count, why))
|
|
685
|
+
return filled
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# --------------------------------------------------------------------------- planning
|
|
689
|
+
|
|
690
|
+
Job = collections.namedtuple(
|
|
691
|
+
"Job", "entity schema table source query pairs converters enums rows unmapped"
|
|
692
|
+
" blocking derived self_refs key_at")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def period_window(query, restrict=None):
|
|
696
|
+
"""Fill in a declared query's period markers from DF1C_FROM / DF1C_TO.
|
|
697
|
+
|
|
698
|
+
A query too big to run in one pass is run a year at a time, and where the bound goes
|
|
699
|
+
belongs to the query: the accounting pivot aggregates 99.8M analytic rows, and that
|
|
700
|
+
inner scan is what spills, so bounding only the outer one would not help. The marker
|
|
701
|
+
`/*window:<alias>/` names each place.
|
|
702
|
+
|
|
703
|
+
It is filled at READ time rather than baked into the map, because `out/columnmap.json`
|
|
704
|
+
is committed and describes what the loader copies: a map that said "2018 only" would
|
|
705
|
+
be a lie about the mapping.
|
|
706
|
+
|
|
707
|
+
The same markers carry a `restrict` - a function of the alias returning one more SQL
|
|
708
|
+
condition - which is how the delta loader narrows the pivot to the recorders that
|
|
709
|
+
changed, at the inner scan where narrowing pays, rather than around the whole query.
|
|
710
|
+
"""
|
|
711
|
+
start, end = os.environ.get("DF1C_FROM"), os.environ.get("DF1C_TO")
|
|
712
|
+
|
|
713
|
+
def window(match):
|
|
714
|
+
alias = match.group(1)
|
|
715
|
+
parts = []
|
|
716
|
+
if start and end:
|
|
717
|
+
parts.append("AND %s._Period >= '%s' AND %s._Period < '%s'"
|
|
718
|
+
% (alias, start, alias, end))
|
|
719
|
+
if restrict:
|
|
720
|
+
parts.append("AND (%s)" % restrict(alias))
|
|
721
|
+
return " ".join(parts)
|
|
722
|
+
|
|
723
|
+
return re.sub(r"/\*window:(\w+)\*/", window, query)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def windowed(query):
|
|
727
|
+
"""Does the query carry its own `/*window:...*/` markers?"""
|
|
728
|
+
return bool(re.search(r"/\*window:\w+\*/", query or ""))
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def reading(job, restrict=None):
|
|
732
|
+
"""What the loader selects from: the table, or the query declared in its place.
|
|
733
|
+
|
|
734
|
+
One of our rows is not always one of theirs. Where it is not, `sources.QUERIES`
|
|
735
|
+
declares a query whose output columns carry the physical names, so the column map,
|
|
736
|
+
the converters and the control sums all go on working unchanged.
|
|
737
|
+
|
|
738
|
+
`restrict(alias)` narrows the rows to a condition on the source's own columns - the
|
|
739
|
+
delta loader's "only what changed". A query with window markers takes it at each
|
|
740
|
+
marker; any other source takes it as a WHERE on the whole.
|
|
741
|
+
"""
|
|
742
|
+
if job.query:
|
|
743
|
+
if restrict and not windowed(job.query):
|
|
744
|
+
return "(%s) AS src WHERE %s" % (period_window(job.query), restrict("src"))
|
|
745
|
+
return "(%s) AS src" % period_window(job.query, restrict)
|
|
746
|
+
if restrict:
|
|
747
|
+
return "[%s] AS src WHERE %s" % (job.source, restrict("src"))
|
|
748
|
+
return "[%s]" % job.source
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def converted(job, batch):
|
|
752
|
+
"""One fetched batch through the job's conversions: what `run` copies, and the delta."""
|
|
753
|
+
rows = [[f(v) for f, v in zip(job.converters, row)] for row in batch]
|
|
754
|
+
if job.self_refs and job.key_at is not None:
|
|
755
|
+
for row in rows:
|
|
756
|
+
for at in job.self_refs:
|
|
757
|
+
if row[at] == row[job.key_at]:
|
|
758
|
+
row[at] = None
|
|
759
|
+
if job.derived:
|
|
760
|
+
for row in rows:
|
|
761
|
+
row.append(derived_key(job.source, row))
|
|
762
|
+
return rows
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def self_references(pg, schema, table):
|
|
766
|
+
"""Columns that point at this same table.
|
|
767
|
+
|
|
768
|
+
1C says "the payer is this counterparty itself" by storing the row's own key, and a
|
|
769
|
+
dForge self-reference carries a generated `CHK_..._acyclic` check that refuses
|
|
770
|
+
exactly that. The value means "no separate payer", so it is loaded as NULL and reads
|
|
771
|
+
back as `COALESCE(payer_id, party_id)`.
|
|
772
|
+
"""
|
|
773
|
+
cur = pg.cursor()
|
|
774
|
+
cur.execute("""SELECT a.attname
|
|
775
|
+
FROM pg_constraint c
|
|
776
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
777
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
778
|
+
JOIN unnest(c.conkey) k(attnum) ON true
|
|
779
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
780
|
+
WHERE c.contype = 'f' AND c.conrelid = c.confrelid
|
|
781
|
+
AND n.nspname = %s AND t.relname = %s""", (schema, table))
|
|
782
|
+
return set(r[0] for r in cur.fetchall())
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def primary_key(pg, schema, table):
|
|
786
|
+
cur = pg.cursor()
|
|
787
|
+
cur.execute("""SELECT a.attname
|
|
788
|
+
FROM pg_constraint c
|
|
789
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
790
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
791
|
+
JOIN unnest(c.conkey) k(attnum) ON true
|
|
792
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
793
|
+
WHERE c.contype = 'p' AND n.nspname = %s AND t.relname = %s""",
|
|
794
|
+
(schema, table))
|
|
795
|
+
return set(r[0] for r in cur.fetchall())
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def plan_entity(pg, entity, mapped, target, source):
|
|
799
|
+
"""What a single entity's copy would look like, or why it cannot run."""
|
|
800
|
+
schema, table, columns = target
|
|
801
|
+
src_table = mapped.get("table")
|
|
802
|
+
if not src_table or src_table not in source:
|
|
803
|
+
return None
|
|
804
|
+
src_columns = source[src_table]["columns"]
|
|
805
|
+
keys = primary_key(pg, schema, table)
|
|
806
|
+
pointing_here = self_references(pg, schema, table)
|
|
807
|
+
|
|
808
|
+
# Where the entity reads from a query rather than a table, some of the columns it
|
|
809
|
+
# selects belong to a table joined in there. The map says which, so the type comes
|
|
810
|
+
# from the table that actually holds the column.
|
|
811
|
+
joined = mapped.get("joined") or {}
|
|
812
|
+
|
|
813
|
+
def holding(column):
|
|
814
|
+
if column in joined:
|
|
815
|
+
return source.get(joined[column], {}).get("columns", {})
|
|
816
|
+
return src_columns
|
|
817
|
+
|
|
818
|
+
pairs, converters, enums, unmapped, blocking = [], [], [], [], []
|
|
819
|
+
derived = None
|
|
820
|
+
declared = mapped.get("enums") or {}
|
|
821
|
+
declared_refs = mapped.get("tablerefs") or {}
|
|
822
|
+
declared_transforms = mapped.get("transforms") or {}
|
|
823
|
+
unknown = set(declared_transforms.values()) - set(TRANSFORMS)
|
|
824
|
+
if unknown:
|
|
825
|
+
raise KeyError("%s: no such transform %s - see migrate.TRANSFORMS"
|
|
826
|
+
% (entity, ", ".join(sorted(unknown))))
|
|
827
|
+
for column, info in columns.items():
|
|
828
|
+
if info["generated"]:
|
|
829
|
+
continue
|
|
830
|
+
src = mapped.get("columns", {}).get(column)
|
|
831
|
+
if (not src or src not in holding(src)) and column in keys and info["type"] == "uuid":
|
|
832
|
+
# An information register has no key of its own in 1C. Where the infobase
|
|
833
|
+
# kept one anyway it is `_SimpleKey`; where it did not, the key is derived
|
|
834
|
+
# from the row itself, so a re-run lands on the same value.
|
|
835
|
+
if "_SimpleKey" in src_columns:
|
|
836
|
+
pairs.append((column, "_SimpleKey"))
|
|
837
|
+
converters.append(to_uuid)
|
|
838
|
+
enums.append(None)
|
|
839
|
+
else:
|
|
840
|
+
derived = column
|
|
841
|
+
continue
|
|
842
|
+
if src and mapped.get("query") and src not in holding(src):
|
|
843
|
+
# A declared query produces columns the source TABLE does not have. The
|
|
844
|
+
# pivot that turns the accounting register's analytic ROWS into slot
|
|
845
|
+
# COLUMNS produces eight of them, and the schema dump describes the table,
|
|
846
|
+
# not the query. The map names the column, so the target's own type decides
|
|
847
|
+
# the conversion and the source type is simply not known here.
|
|
848
|
+
pairs.append((column, src))
|
|
849
|
+
converters.append(converter(info["type"], None))
|
|
850
|
+
enums.append(None)
|
|
851
|
+
continue
|
|
852
|
+
if not src or src not in holding(src):
|
|
853
|
+
# Not a defect by itself: some columns are ours with no 1C original. It
|
|
854
|
+
# only blocks when the target insists on a value and has no default.
|
|
855
|
+
unmapped.append(column)
|
|
856
|
+
if not info["nullable"] and info["default"] is None:
|
|
857
|
+
blocking.append(column)
|
|
858
|
+
continue
|
|
859
|
+
pairs.append((column, src))
|
|
860
|
+
if column in declared_transforms:
|
|
861
|
+
# The register overlay says this column is not a copy of its source but a
|
|
862
|
+
# reading of it - the magnitude of a signed value, or its direction.
|
|
863
|
+
converters.append(TRANSFORMS[declared_transforms[column]])
|
|
864
|
+
elif column in declared_refs:
|
|
865
|
+
converters.append(tableref_decoder(declared_refs[column]))
|
|
866
|
+
else:
|
|
867
|
+
converters.append(converter(info["type"], holding(src)[src]["type"]))
|
|
868
|
+
enums.append(declared.get(column))
|
|
869
|
+
|
|
870
|
+
columns = [p[0] for p in pairs]
|
|
871
|
+
key_at = next((i for i, c in enumerate(columns) if c in keys), None)
|
|
872
|
+
self_refs = [i for i, c in enumerate(columns) if c in pointing_here]
|
|
873
|
+
return Job(entity, schema, table, src_table, mapped.get("query"),
|
|
874
|
+
pairs, converters, enums,
|
|
875
|
+
source[src_table]["rows"], unmapped, blocking, derived,
|
|
876
|
+
self_refs, key_at)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def jobs_for(pg, module, mapping, source, only):
|
|
880
|
+
target = target_tables(pg, module)
|
|
881
|
+
out = []
|
|
882
|
+
for entity, mapped in mapping.get(module, {}).items():
|
|
883
|
+
if only and entity not in only:
|
|
884
|
+
continue
|
|
885
|
+
if entity not in target:
|
|
886
|
+
continue
|
|
887
|
+
job = plan_entity(pg, entity, mapped, target[entity], source)
|
|
888
|
+
if job:
|
|
889
|
+
out.append(job)
|
|
890
|
+
return out
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# --------------------------------------------------------------------------- commands
|
|
894
|
+
|
|
895
|
+
_COUNTING = []
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def counted(job):
|
|
899
|
+
"""How many rows the source actually offers - the table's count, or the query's.
|
|
900
|
+
|
|
901
|
+
`plan` otherwise reads everything from `out/schema.json` and needs no server; a
|
|
902
|
+
declared query is the one thing only the server can count, so the connection is
|
|
903
|
+
opened when one turns up and not before.
|
|
904
|
+
"""
|
|
905
|
+
if not job.query:
|
|
906
|
+
return job.rows
|
|
907
|
+
if not _COUNTING:
|
|
908
|
+
_COUNTING.append(mssql_connect().cursor())
|
|
909
|
+
_COUNTING[0].execute("SELECT COUNT(*) FROM %s" % reading(job))
|
|
910
|
+
return _COUNTING[0].fetchone()[0]
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def cmd_plan(args):
|
|
914
|
+
mapping, source = column_map(), source_schema()
|
|
915
|
+
pg = pg_connect()
|
|
916
|
+
jobs = jobs_for(pg, args.module, mapping, source, args.entity)
|
|
917
|
+
print("%-34s %-16s %10s %6s %6s" % ("entity", "source", "rows", "cols", "gaps"))
|
|
918
|
+
print("-" * 78)
|
|
919
|
+
total = 0
|
|
920
|
+
for job in sorted(jobs, key=lambda j: -j.rows):
|
|
921
|
+
rows = counted(job)
|
|
922
|
+
total += rows
|
|
923
|
+
print("%-34s %-16s %10d %6d %6d"
|
|
924
|
+
% (job.entity, job.source, rows, len(job.pairs), len(job.unmapped)))
|
|
925
|
+
print("-" * 78)
|
|
926
|
+
print("%-34s %-16s %10d" % ("%d entities" % len(jobs), "", total))
|
|
927
|
+
|
|
928
|
+
blocked = [j for j in jobs if j.blocking]
|
|
929
|
+
if blocked:
|
|
930
|
+
print("\nblocked - a NOT NULL column with no source and no default:")
|
|
931
|
+
for job in blocked:
|
|
932
|
+
print(" %-30s %s" % (job.entity, ", ".join(job.blocking)))
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def cmd_run(args):
|
|
936
|
+
mapping, source = column_map(), source_schema()
|
|
937
|
+
pg = pg_connect()
|
|
938
|
+
jobs = jobs_for(pg, args.module, mapping, source, args.entity)
|
|
939
|
+
blocked = [j for j in jobs if j.blocking]
|
|
940
|
+
if blocked and not args.force:
|
|
941
|
+
print("refusing: %d entity(ies) have a NOT NULL column with no source."
|
|
942
|
+
% len(blocked))
|
|
943
|
+
print("run `plan` to see them, or --force to copy the rest.")
|
|
944
|
+
return 1
|
|
945
|
+
jobs = [j for j in jobs if not j.blocking]
|
|
946
|
+
|
|
947
|
+
cur = pg.cursor()
|
|
948
|
+
# The generated period-lock and posted-document guards would reject exactly the
|
|
949
|
+
# rows the migration has to write, and so would every foreign key while the
|
|
950
|
+
# catalogs are still half loaded. Replica mode suspends both for this session.
|
|
951
|
+
cur.execute("SET session_replication_role = 'replica'")
|
|
952
|
+
|
|
953
|
+
if args.truncate:
|
|
954
|
+
# TRUNCATE where the table allows it, DELETE where it does not. Postgres refuses
|
|
955
|
+
# to truncate a table another table's foreign key points at, and refuses it in
|
|
956
|
+
# replica mode too - that check is not one of the triggers replica mode suspends.
|
|
957
|
+
# So the referenced tables take a DELETE, as they always did.
|
|
958
|
+
#
|
|
959
|
+
# The rest take a TRUNCATE, and on a large table that is the difference between
|
|
960
|
+
# clearing it and doubling it. Measured: `--truncate` on `accounting.posting`
|
|
961
|
+
# left 24,377,440 dead rows in 15 GB that no later pass could reuse, because
|
|
962
|
+
# DELETE only marks a row dead and the space comes back at VACUUM. The reload
|
|
963
|
+
# then wrote on top of all of it, and the disk holding the database ran down to
|
|
964
|
+
# 4.5 GB. TRUNCATE on the same table returned 15 GB to 32 kB, instantly.
|
|
965
|
+
for job in reversed(jobs):
|
|
966
|
+
cur.execute("""
|
|
967
|
+
SELECT count(*) FROM pg_constraint k
|
|
968
|
+
JOIN pg_class t ON t.oid = k.confrelid
|
|
969
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
970
|
+
WHERE k.contype = 'f' AND n.nspname = %s AND t.relname = %s""",
|
|
971
|
+
(job.schema, job.table))
|
|
972
|
+
referenced = cur.fetchone()[0]
|
|
973
|
+
verb = "DELETE FROM" if referenced else "TRUNCATE"
|
|
974
|
+
cur.execute('%s "%s"."%s"' % (verb, job.schema, job.table))
|
|
975
|
+
pg.commit()
|
|
976
|
+
|
|
977
|
+
source_db = mssql_connect()
|
|
978
|
+
mssql, lookup = source_db.cursor(), source_db.cursor()
|
|
979
|
+
grand = 0
|
|
980
|
+
for job in jobs:
|
|
981
|
+
job = bind_enums(job, lookup)
|
|
982
|
+
columns = [p[0] for p in job.pairs] + ([job.derived] if job.derived else [])
|
|
983
|
+
select = ", ".join("[%s]" % p[1] for p in job.pairs)
|
|
984
|
+
# Taken BEFORE the read: everything 1C changes from here on has a _Version at or
|
|
985
|
+
# above it, so the delta loader picks up exactly what this copy may have missed.
|
|
986
|
+
horizon = delta.horizon(lookup)
|
|
987
|
+
mssql.execute("SELECT %s FROM %s" % (select, reading(job)))
|
|
988
|
+
copied = 0
|
|
989
|
+
while True:
|
|
990
|
+
batch = mssql.fetchmany(CHUNK)
|
|
991
|
+
if not batch:
|
|
992
|
+
break
|
|
993
|
+
rows = converted(job, batch)
|
|
994
|
+
copy_rows(pg, job.schema, job.table, columns, rows)
|
|
995
|
+
copied += len(rows)
|
|
996
|
+
if job.rows > CHUNK:
|
|
997
|
+
sys.stderr.write("\r %-30s %9d / %d" % (job.entity, copied, job.rows))
|
|
998
|
+
# In the copy's own transaction, so the mark cannot outlive or precede the rows.
|
|
999
|
+
# A pass that did not empty the table first - the ledger's quarters - only ever
|
|
1000
|
+
# lowers the mark: an earlier quarter may have changed since it was read.
|
|
1001
|
+
delta.record(pg, args.module, job, horizon,
|
|
1002
|
+
lower_only=not args.truncate or bool(os.environ.get("DF1C_FROM")))
|
|
1003
|
+
pg.commit()
|
|
1004
|
+
if job.rows > CHUNK:
|
|
1005
|
+
sys.stderr.write("\r" + " " * 60 + "\r")
|
|
1006
|
+
grand += copied
|
|
1007
|
+
print("%-34s %-16s %10d" % (job.entity, job.source, copied))
|
|
1008
|
+
print("-" * 62)
|
|
1009
|
+
print("%-34s %-16s %10d" % ("%d entities" % len(jobs), "", grand))
|
|
1010
|
+
|
|
1011
|
+
if not args.no_placeholders:
|
|
1012
|
+
fill_missing(pg, [j.schema for j in jobs])
|
|
1013
|
+
return 0
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def real_rows(pg, job):
|
|
1017
|
+
"""The WHERE that leaves the placeholders out of a comparison with 1C.
|
|
1018
|
+
|
|
1019
|
+
A stand-in row exists because the infobase lost the thing it points at, so counting
|
|
1020
|
+
it against the infobase would always disagree by exactly the number of things 1C
|
|
1021
|
+
lost.
|
|
1022
|
+
"""
|
|
1023
|
+
columns = dict((c[0], c) for c in table_columns(pg, job.schema, job.table))
|
|
1024
|
+
return " WHERE NOT is_missing" if "is_missing" in columns else ""
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def cmd_verify(args):
|
|
1028
|
+
"""Row counts, control sums and orphan references - the three cheap checks."""
|
|
1029
|
+
mapping, source = column_map(), source_schema()
|
|
1030
|
+
pg = pg_connect()
|
|
1031
|
+
mssql = mssql_connect().cursor()
|
|
1032
|
+
jobs = jobs_for(pg, args.module, mapping, source, args.entity)
|
|
1033
|
+
cur = pg.cursor()
|
|
1034
|
+
|
|
1035
|
+
print("%-34s %10s %10s %s" % ("entity", "1C", "dForge", ""))
|
|
1036
|
+
print("-" * 70)
|
|
1037
|
+
bad = 0
|
|
1038
|
+
for job in jobs:
|
|
1039
|
+
mssql.execute("SELECT COUNT(*) FROM %s" % reading(job))
|
|
1040
|
+
want = mssql.fetchone()[0]
|
|
1041
|
+
cur.execute('SELECT COUNT(*) FROM "%s"."%s"%s'
|
|
1042
|
+
% (job.schema, job.table, real_rows(pg, job)))
|
|
1043
|
+
got = cur.fetchone()[0]
|
|
1044
|
+
flag = "" if want == got else " <- differs"
|
|
1045
|
+
bad += 1 if want != got else 0
|
|
1046
|
+
print("%-34s %10d %10d%s" % (job.entity, want, got, flag))
|
|
1047
|
+
|
|
1048
|
+
print("\ncontrol sums")
|
|
1049
|
+
print("-" * 70)
|
|
1050
|
+
for job in jobs:
|
|
1051
|
+
numeric = [(c, s) for (c, s), f in zip(job.pairs, job.converters)
|
|
1052
|
+
if f is to_number]
|
|
1053
|
+
for column, src in numeric:
|
|
1054
|
+
mssql.execute("SELECT SUM(CAST([%s] AS decimal(38,8))) FROM %s"
|
|
1055
|
+
% (src, reading(job)))
|
|
1056
|
+
want = mssql.fetchone()[0] or 0
|
|
1057
|
+
cur.execute('SELECT COALESCE(SUM("%s"), 0) FROM "%s"."%s"%s'
|
|
1058
|
+
% (column, job.schema, job.table, real_rows(pg, job)))
|
|
1059
|
+
got = cur.fetchone()[0] or 0
|
|
1060
|
+
if decimal.Decimal(want) != decimal.Decimal(got):
|
|
1061
|
+
bad += 1
|
|
1062
|
+
print("%-22s %-22s 1C=%s dForge=%s"
|
|
1063
|
+
% (job.entity, column, want, got))
|
|
1064
|
+
print("(only differences are listed)")
|
|
1065
|
+
|
|
1066
|
+
print("\norphan references")
|
|
1067
|
+
print("-" * 70)
|
|
1068
|
+
for (name, schema, table, column,
|
|
1069
|
+
target_schema, target_table, target_column) in foreign_keys(
|
|
1070
|
+
pg, [j.schema for j in jobs]):
|
|
1071
|
+
cur.execute('SELECT COUNT(*) FROM "%s"."%s" a WHERE a."%s" IS NOT NULL'
|
|
1072
|
+
' AND NOT EXISTS (SELECT 1 FROM "%s"."%s" b WHERE b."%s" = a."%s")'
|
|
1073
|
+
% (schema, table, column,
|
|
1074
|
+
target_schema, target_table, target_column, column))
|
|
1075
|
+
orphans = cur.fetchone()[0]
|
|
1076
|
+
if orphans:
|
|
1077
|
+
bad += 1
|
|
1078
|
+
print("%-32s %-24s %d" % ("%s.%s" % (schema, table), name, orphans))
|
|
1079
|
+
print("(only violations are listed)")
|
|
1080
|
+
|
|
1081
|
+
cur.execute("""SELECT e.schema_name, e.db_object
|
|
1082
|
+
FROM "dForge".entity e
|
|
1083
|
+
JOIN "dForge".module m ON m.module_id = e.module_id
|
|
1084
|
+
JOIN "dForge".entity_column ec ON ec.entity_id = e.entity_id
|
|
1085
|
+
WHERE m.module_cd = %s AND ec.column_cd = 'is_missing'""",
|
|
1086
|
+
(args.module,))
|
|
1087
|
+
standing_in = 0
|
|
1088
|
+
for schema, table in cur.fetchall():
|
|
1089
|
+
cur2 = pg.cursor()
|
|
1090
|
+
cur2.execute('SELECT COUNT(*) FROM "%s"."%s" WHERE is_missing' % (schema, table))
|
|
1091
|
+
standing_in += cur2.fetchone()[0]
|
|
1092
|
+
print("\n%d placeholder row(s) standing in for references 1C had lost"
|
|
1093
|
+
% standing_in)
|
|
1094
|
+
|
|
1095
|
+
print("\n%s" % ("clean" if not bad else "%d problem(s)" % bad))
|
|
1096
|
+
return 1 if bad else 0
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
EMPTY_BY_TYPE = {
|
|
1100
|
+
"binary": "[%s] IS NULL OR [%s] = 0x00000000000000000000000000000000",
|
|
1101
|
+
"datetime": "[%s] IS NULL OR [%s] < '2001-01-02'",
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
TEXTUAL = ("char", "varchar", "nchar", "nvarchar", "text", "ntext")
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def incompatible(target_type, held, is_enum, is_tableref):
|
|
1109
|
+
"""Can this column hold what the infobase keeps in that one?
|
|
1110
|
+
|
|
1111
|
+
The three ways an authored spec gets this wrong, all seen: it declares a user picker
|
|
1112
|
+
where 1C keeps a typed-in name, it declares a lookup where 1C keeps text, and it
|
|
1113
|
+
declares text where 1C keeps a reference and nothing says how to read it.
|
|
1114
|
+
"""
|
|
1115
|
+
kind, width = held["type"], held.get("len")
|
|
1116
|
+
reference = kind == "binary" and width == 16
|
|
1117
|
+
if reference and target_type != "uuid" and not (is_enum or is_tableref):
|
|
1118
|
+
return "a reference, with nothing saying how to read it"
|
|
1119
|
+
if kind in TEXTUAL and target_type in NUMERIC_TYPES:
|
|
1120
|
+
return "%s(%s) - text, not a number" % (kind, width)
|
|
1121
|
+
if kind in TEXTUAL and target_type == "uuid":
|
|
1122
|
+
return "%s(%s) - text, not a key" % (kind, width)
|
|
1123
|
+
if not reference and target_type == "uuid" and kind != "binary":
|
|
1124
|
+
return "%s - not a key" % kind
|
|
1125
|
+
if kind in ("image", "varbinary") and target_type != "bytea":
|
|
1126
|
+
return "%s - a serialized value, not a %s" % (kind, target_type)
|
|
1127
|
+
return None
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def unique_constraints(pg, schema, table):
|
|
1131
|
+
"""[(name, [column, ...])] for the unique constraints and indexes on a table."""
|
|
1132
|
+
cur = pg.cursor()
|
|
1133
|
+
cur.execute("""SELECT c.conname, array_agg(a.attname ORDER BY k.ord)
|
|
1134
|
+
FROM pg_constraint c
|
|
1135
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
1136
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
1137
|
+
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
|
1138
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
|
|
1139
|
+
WHERE c.contype = 'u' AND n.nspname = %s AND t.relname = %s
|
|
1140
|
+
GROUP BY c.conname""", (schema, table))
|
|
1141
|
+
return cur.fetchall()
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def cmd_check(args):
|
|
1145
|
+
"""Where the port insists on a value, does the infobase actually hold one?
|
|
1146
|
+
|
|
1147
|
+
A mandatory column in an authored spec is a guess about the data until the data is
|
|
1148
|
+
asked - and the data disagrees more often than it looks. 264 of the 12,208 vehicles
|
|
1149
|
+
carry no kind at all, which the spec had declared required, and the load finds that
|
|
1150
|
+
out one failed COPY at a time. This asks every one of them up front.
|
|
1151
|
+
|
|
1152
|
+
`--types` asks only the questions the cached schema dump can answer - can this column
|
|
1153
|
+
hold what it copies, and is it wide enough - and skips every count against MS SQL. That
|
|
1154
|
+
half is seconds rather than an afternoon, which matters because the afternoon is why it
|
|
1155
|
+
gets skipped: a standard configuration's 623 entities were loaded without it, and an
|
|
1156
|
+
hour later `to_uuid` was handed a string this would have named at once. `all` in place of
|
|
1157
|
+
a module name checks every module in the map.
|
|
1158
|
+
"""
|
|
1159
|
+
mapping, source = column_map(), source_schema()
|
|
1160
|
+
pg = pg_connect()
|
|
1161
|
+
mssql = None if args.types else mssql_connect().cursor()
|
|
1162
|
+
modules = sorted(mapping) if args.module == "all" else [args.module]
|
|
1163
|
+
jobs = [j for m in modules for j in jobs_for(pg, m, mapping, source, args.entity)]
|
|
1164
|
+
|
|
1165
|
+
declared = dict((entity, found) for module in modules
|
|
1166
|
+
for entity, found in mapping.get(module, {}).items())
|
|
1167
|
+
declared_enums = dict((e, f.get("enums") or {}) for e, f in declared.items())
|
|
1168
|
+
declared_refs = dict((e, f.get("tablerefs") or {}) for e, f in declared.items())
|
|
1169
|
+
# A column the entity reads through a joined-in table is typed by that table,
|
|
1170
|
+
# not by the entity's own source - the same reading `plan_entity` does.
|
|
1171
|
+
declared_joins = dict((e, f.get("joined") or {}) for e, f in declared.items())
|
|
1172
|
+
|
|
1173
|
+
print("%-26s %-22s %10s %10s" % ("entity", "column", "rows", "empty"))
|
|
1174
|
+
print("-" * 72)
|
|
1175
|
+
wrong, narrow, mistyped = 0, [], []
|
|
1176
|
+
for job in jobs:
|
|
1177
|
+
columns = dict((c[0], c) for c in table_columns(pg, job.schema, job.table))
|
|
1178
|
+
src_columns = source[job.source]["columns"]
|
|
1179
|
+
for column, src in job.pairs:
|
|
1180
|
+
info = columns.get(column)
|
|
1181
|
+
if not info:
|
|
1182
|
+
continue
|
|
1183
|
+
# An authored width is a guess too. `vehicle.plate_no` was declared
|
|
1184
|
+
# varchar(20) against a column 1C gives 25 characters.
|
|
1185
|
+
joined = declared_joins.get(job.entity) or {}
|
|
1186
|
+
if src in joined:
|
|
1187
|
+
held = source[joined[src]]["columns"][src]
|
|
1188
|
+
elif src not in src_columns:
|
|
1189
|
+
# A column the declared query produces and the table has not got. The
|
|
1190
|
+
# schema dump cannot type it, so there is nothing here to check it
|
|
1191
|
+
# against - the target's type is the only claim being made.
|
|
1192
|
+
continue
|
|
1193
|
+
else:
|
|
1194
|
+
held = src_columns[src]
|
|
1195
|
+
reason = incompatible(info[1], held, column in (declared_enums.get(job.entity)
|
|
1196
|
+
or {}),
|
|
1197
|
+
column in (declared_refs.get(job.entity) or {}))
|
|
1198
|
+
if reason:
|
|
1199
|
+
mistyped.append((job.entity, column, info[1], reason))
|
|
1200
|
+
if info[1] in ("character varying", "character") and info[6]:
|
|
1201
|
+
width = held.get("len") or 0
|
|
1202
|
+
if held["type"].startswith("n"):
|
|
1203
|
+
width //= 2
|
|
1204
|
+
if width > info[6]:
|
|
1205
|
+
narrow.append((job.entity, column, info[6], width))
|
|
1206
|
+
if mssql is None or info[2] == "YES" or info[3] is not None:
|
|
1207
|
+
continue
|
|
1208
|
+
kind = held["type"]
|
|
1209
|
+
predicate = EMPTY_BY_TYPE.get(kind, "[%s] IS NULL")
|
|
1210
|
+
predicate = predicate.replace("%s", src)
|
|
1211
|
+
mssql.execute("SELECT COUNT(*), SUM(CASE WHEN %s THEN 1 ELSE 0 END)"
|
|
1212
|
+
" FROM %s" % (predicate, reading(job)))
|
|
1213
|
+
total, empty = mssql.fetchone()
|
|
1214
|
+
if empty:
|
|
1215
|
+
wrong += 1
|
|
1216
|
+
print("%-26s %-22s %10d %10d" % (job.entity, column, total, empty))
|
|
1217
|
+
print("-" * 72)
|
|
1218
|
+
if mssql is None:
|
|
1219
|
+
print("mandatory columns: not asked (--types)")
|
|
1220
|
+
else:
|
|
1221
|
+
print("%d mandatory column(s) the infobase leaves empty" % wrong)
|
|
1222
|
+
|
|
1223
|
+
# A uniqueness the port declares is a claim about the data too, and the same kind of
|
|
1224
|
+
# guess: 359 plate numbers are on more than one vehicle.
|
|
1225
|
+
clashes = []
|
|
1226
|
+
for job in (jobs if mssql is not None else []):
|
|
1227
|
+
for name, columns in unique_constraints(pg, job.schema, job.table):
|
|
1228
|
+
sources = [dict(job.pairs).get(c) for c in columns]
|
|
1229
|
+
if not all(sources):
|
|
1230
|
+
continue
|
|
1231
|
+
listed = ", ".join("[%s]" % c for c in sources)
|
|
1232
|
+
mssql.execute("SELECT COUNT(*) FROM (SELECT %s FROM %s"
|
|
1233
|
+
" GROUP BY %s HAVING COUNT(*) > 1) d"
|
|
1234
|
+
% (listed, reading(job), listed))
|
|
1235
|
+
repeated = mssql.fetchone()[0]
|
|
1236
|
+
if repeated:
|
|
1237
|
+
clashes.append((job.entity, name, repeated))
|
|
1238
|
+
if clashes:
|
|
1239
|
+
print("\n%-26s %-30s %10s" % ("entity", "constraint", "repeated"))
|
|
1240
|
+
print("-" * 70)
|
|
1241
|
+
for entity, name, repeated in clashes:
|
|
1242
|
+
print("%-26s %-30s %10d" % (entity, name, repeated))
|
|
1243
|
+
print("-" * 70)
|
|
1244
|
+
if mssql is None:
|
|
1245
|
+
print("unique constraints: not asked (--types)")
|
|
1246
|
+
else:
|
|
1247
|
+
print("%d unique constraint(s) the infobase does not honour" % len(clashes))
|
|
1248
|
+
|
|
1249
|
+
if narrow:
|
|
1250
|
+
print("\n%-26s %-22s %8s %8s" % ("entity", "column", "ours", "theirs"))
|
|
1251
|
+
print("-" * 68)
|
|
1252
|
+
for entity, column, ours, theirs in narrow:
|
|
1253
|
+
print("%-26s %-22s %8d %8d" % (entity, column, ours, theirs))
|
|
1254
|
+
print("-" * 68)
|
|
1255
|
+
print("%d column(s) narrower than the column they copy from" % len(narrow))
|
|
1256
|
+
|
|
1257
|
+
if mistyped:
|
|
1258
|
+
print("\n%-26s %-22s %-20s %s" % ("entity", "column", "ours", "theirs"))
|
|
1259
|
+
print("-" * 78)
|
|
1260
|
+
for entity, column, ours, reason in mistyped:
|
|
1261
|
+
print("%-26s %-22s %-20s %s" % (entity, column, ours, reason))
|
|
1262
|
+
print("-" * 78)
|
|
1263
|
+
print("%d column(s) that cannot hold what they copy" % len(mistyped))
|
|
1264
|
+
return 1 if (wrong or narrow or clashes or mistyped) else 0
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def cmd_fill(args):
|
|
1268
|
+
"""The placeholder pass on its own, for a load that ran with --no-placeholders."""
|
|
1269
|
+
mapping, source = column_map(), source_schema()
|
|
1270
|
+
pg = pg_connect()
|
|
1271
|
+
jobs = jobs_for(pg, args.module, mapping, source, args.entity)
|
|
1272
|
+
fill_missing(pg, [j.schema for j in jobs])
|
|
1273
|
+
return 0
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def main():
|
|
1277
|
+
load_env()
|
|
1278
|
+
ap = argparse.ArgumentParser(
|
|
1279
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
1280
|
+
ap.add_argument("command",
|
|
1281
|
+
choices=("plan", "run", "verify", "fill", "check"))
|
|
1282
|
+
ap.add_argument("module")
|
|
1283
|
+
ap.add_argument("entity", nargs="*", help="limit to these entities")
|
|
1284
|
+
ap.add_argument("--truncate", action="store_true",
|
|
1285
|
+
help="empty the target tables first")
|
|
1286
|
+
ap.add_argument("--force", action="store_true",
|
|
1287
|
+
help="copy what can be copied, skipping blocked entities")
|
|
1288
|
+
ap.add_argument("--types", action="store_true",
|
|
1289
|
+
help="check only what the schema dump can answer, over every module")
|
|
1290
|
+
ap.add_argument("--no-placeholders", action="store_true",
|
|
1291
|
+
help="leave dangling references dangling instead of standing a"
|
|
1292
|
+
" flagged row in for each")
|
|
1293
|
+
args = ap.parse_args()
|
|
1294
|
+
return {"plan": cmd_plan, "run": cmd_run, "verify": cmd_verify,
|
|
1295
|
+
"fill": cmd_fill, "check": cmd_check}[args.command](args)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
if __name__ == "__main__":
|
|
1299
|
+
sys.exit(main() or 0)
|