@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/package.json +48 -46
  4. package/scripts/install-skills.mjs +1 -0
  5. package/skills/dforge-1c-import/SKILL.md +374 -0
  6. package/skills/dforge-1c-import/references/environment.md +97 -0
  7. package/skills/dforge-1c-import/references/loading.md +161 -0
  8. package/skills/dforge-1c-import/references/measurements.md +210 -0
  9. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  10. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  11. package/skills/dforge-1c-import/scripts/README.md +110 -0
  12. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  13. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  14. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  15. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  16. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  17. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  18. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  19. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  20. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  21. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  22. package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
  23. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  24. package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
  25. package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
  26. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  27. package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
  28. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  29. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  30. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  31. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  33. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  34. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  35. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  36. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  37. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  38. package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
  39. package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
  40. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  41. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  42. package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
  43. package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
  44. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  45. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  46. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  47. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  48. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  49. package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
  50. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  51. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  52. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  53. package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
  54. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  55. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  56. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  57. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  58. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  59. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  60. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  61. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  62. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  63. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  64. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  65. package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
  66. 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)