@dforge-core/dforge-mcp 0.2.45 → 0.2.47

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