@dforge-core/dforge-mcp 0.2.46-next → 0.2.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -1
- package/dist/server.js +71 -3
- package/package.json +47 -48
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +50 -0
- package/skills/dforge-1c-import/SKILL.md +3 -2
- package/skills/dforge-1c-import/references/environment.md +2 -1
- package/skills/dforge-1c-import/references/loading.md +5 -2
- package/skills/dforge-1c-import/references/measurements.md +1 -1
- package/skills/dforge-1c-import/scripts/README.md +88 -7
- package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +2 -2
- package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +109 -52
- package/skills/dforge-1c-import/scripts/lib/docusage.py +5 -4
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +249 -92
- package/skills/dforge-1c-import/scripts/lib/external.py +4 -3
- package/skills/dforge-1c-import/scripts/lib/meta.py +12 -2
- package/skills/dforge-1c-import/scripts/lib/migrate.py +93 -74
- package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +21 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +3 -5
- package/skills/dforge-1c-import/scripts/lib/recorded.py +11 -11
- package/skills/dforge-1c-import/scripts/lib/recorders.py +9 -12
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +21 -16
- package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
- package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
- package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
- package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
- package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +10 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +49 -46
- package/skills/dforge-mcp-author/references/data-views.md +38 -2
- package/skills/dforge-mcp-author/references/formulas.md +38 -6
- package/skills/dforge-mcp-author/references/jobs.md +1 -1
- package/skills/dforge-mcp-author/references/print-templates.md +30 -5
- package/skills/dforge-mcp-author/references/queries.md +17 -2
- package/skills/dforge-mcp-author/references/registers.md +3 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- package/skills/dforge-mcp-author/references/security.md +2 -0
- package/skills/dforge-mcp-author/references/validation-checklist.md +7 -8
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"""
|
|
4
4
|
Copy the infobase's rows into a dForge tenant database.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
column map (`out/columnmap.json`) says where every ported column comes from, and
|
|
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
8
|
everything in between is the eight conversions described in docs/design-migration.md.
|
|
9
9
|
|
|
10
10
|
The target's physical shape is read from the tenant database itself - `"dForge".entity`
|
|
@@ -17,8 +17,7 @@ the loader cannot disagree with what the installer actually built.
|
|
|
17
17
|
python tools/migrate.py check masterdata # is anything mandatory empty?
|
|
18
18
|
python tools/migrate.py fill masterdata # placeholders on their own
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
auth when DF1C_USER is unset). The target from DF_PG_HOST / DF_PG_PORT / DF_PG_USER /
|
|
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 /
|
|
22
21
|
DF_PG_PASS plus DF_TENANT, which names the database through the platform's own
|
|
23
22
|
`dforge_{tenantCd}` pattern. Both sides are read from `.env` if one is present.
|
|
24
23
|
"""
|
|
@@ -40,11 +39,11 @@ import uuid
|
|
|
40
39
|
import attrs
|
|
41
40
|
import paths
|
|
42
41
|
import delta
|
|
43
|
-
import
|
|
42
|
+
import store
|
|
44
43
|
|
|
45
|
-
# 1C stores dates shifted by the infobase's _YearOffset: 4012 in the column is 2012
|
|
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
46
|
# The empty date is 0001-01-01, which shifted lands on 2001-01-01.
|
|
47
|
-
YEAR_OFFSET = 2000
|
|
48
47
|
ZERO_REF = b"\x00" * 16
|
|
49
48
|
|
|
50
49
|
CHUNK = 20000
|
|
@@ -55,17 +54,9 @@ TRUE_TEXT = set([u"1", u"true", u"t", u"yes", u"y", u"\u0414\u0430"])
|
|
|
55
54
|
# --------------------------------------------------------------------------- config
|
|
56
55
|
|
|
57
56
|
def load_env():
|
|
58
|
-
"""
|
|
59
|
-
|
|
60
|
-
|
|
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())
|
|
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"))
|
|
69
60
|
|
|
70
61
|
|
|
71
62
|
def pg_connect():
|
|
@@ -80,9 +71,8 @@ def pg_connect():
|
|
|
80
71
|
return psycopg2.connect(host=host, port=port, dbname=db, user=user, password=password)
|
|
81
72
|
|
|
82
73
|
|
|
83
|
-
def
|
|
84
|
-
return
|
|
85
|
-
paths.required("DF1C_DB", "which MS SQL infobase to read"))
|
|
74
|
+
def source_connect():
|
|
75
|
+
return store.connect()
|
|
86
76
|
|
|
87
77
|
|
|
88
78
|
# --------------------------------------------------------------------------- the map
|
|
@@ -93,18 +83,41 @@ def column_map():
|
|
|
93
83
|
return json.load(f, object_pairs_hook=collections.OrderedDict)
|
|
94
84
|
|
|
95
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
|
+
|
|
96
112
|
def source_schema():
|
|
97
113
|
"""Physical column types on the 1C side, from the schema dump."""
|
|
98
114
|
path = os.path.join(attrs.OUT, "schema.json")
|
|
99
115
|
with codecs.open(path, "r", "utf-8") as f:
|
|
100
116
|
doc = json.load(f)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"columns": dict((c["column"], c) for c in table["columns"]),
|
|
106
|
-
}
|
|
107
|
-
return out
|
|
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"])
|
|
108
121
|
|
|
109
122
|
|
|
110
123
|
def target_tables(pg, module):
|
|
@@ -197,7 +210,7 @@ def shift_date(value):
|
|
|
197
210
|
"""Undo the +2000 year offset; 1C's empty date becomes NULL."""
|
|
198
211
|
if value is None:
|
|
199
212
|
return None
|
|
200
|
-
year = value.year -
|
|
213
|
+
year = value.year - store.year_offset()
|
|
201
214
|
if year <= 1:
|
|
202
215
|
return None
|
|
203
216
|
return value.replace(year=year)
|
|
@@ -271,7 +284,7 @@ def to_text(value):
|
|
|
271
284
|
_ENUM_CACHE = {}
|
|
272
285
|
|
|
273
286
|
|
|
274
|
-
def enum_decoder(
|
|
287
|
+
def enum_decoder(export, spec):
|
|
275
288
|
"""An enum reference -> the option code the port gave that value.
|
|
276
289
|
|
|
277
290
|
1C keeps an enum value's position in `_EnumOrder`, and the port read the values in
|
|
@@ -281,8 +294,8 @@ def enum_decoder(mssql, spec):
|
|
|
281
294
|
if key not in _ENUM_CACHE:
|
|
282
295
|
options = spec["options"]
|
|
283
296
|
found = {}
|
|
284
|
-
|
|
285
|
-
for raw, order in
|
|
297
|
+
export.execute('SELECT "_IDRRef", "_EnumOrder" FROM %s' % store.quote(spec["table"]))
|
|
298
|
+
for raw, order in export.fetchall():
|
|
286
299
|
position = int(order)
|
|
287
300
|
if 0 <= position < len(options):
|
|
288
301
|
found[to_uuid(raw)] = options[position]
|
|
@@ -310,11 +323,11 @@ def tableref_decoder(table):
|
|
|
310
323
|
return decode
|
|
311
324
|
|
|
312
325
|
|
|
313
|
-
def bind_enums(job,
|
|
326
|
+
def bind_enums(job, export):
|
|
314
327
|
"""Swap in the enum decoders once there is a connection to read the tables with."""
|
|
315
328
|
if not any(job.enums):
|
|
316
329
|
return job
|
|
317
|
-
converters = [enum_decoder(
|
|
330
|
+
converters = [enum_decoder(export, spec) if spec else f
|
|
318
331
|
for f, spec in zip(job.converters, job.enums)]
|
|
319
332
|
return job._replace(converters=converters)
|
|
320
333
|
|
|
@@ -432,7 +445,7 @@ def since_beginning(value):
|
|
|
432
445
|
"""
|
|
433
446
|
if value is None:
|
|
434
447
|
return None
|
|
435
|
-
year = value.year -
|
|
448
|
+
year = value.year - store.year_offset()
|
|
436
449
|
return datetime.datetime(1, 1, 1) if year <= 1 else value.replace(year=year)
|
|
437
450
|
|
|
438
451
|
|
|
@@ -744,8 +757,8 @@ def reading(job, restrict=None):
|
|
|
744
757
|
return "(%s) AS src WHERE %s" % (period_window(job.query), restrict("src"))
|
|
745
758
|
return "(%s) AS src" % period_window(job.query, restrict)
|
|
746
759
|
if restrict:
|
|
747
|
-
return "
|
|
748
|
-
return
|
|
760
|
+
return "%s AS src WHERE %s" % (store.quote(job.source), restrict("src"))
|
|
761
|
+
return store.quote(job.source)
|
|
749
762
|
|
|
750
763
|
|
|
751
764
|
def converted(job, batch):
|
|
@@ -898,14 +911,14 @@ _COUNTING = []
|
|
|
898
911
|
def counted(job):
|
|
899
912
|
"""How many rows the source actually offers - the table's count, or the query's.
|
|
900
913
|
|
|
901
|
-
`plan` otherwise reads everything from `out/schema.json
|
|
902
|
-
|
|
903
|
-
|
|
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.
|
|
904
917
|
"""
|
|
905
918
|
if not job.query:
|
|
906
919
|
return job.rows
|
|
907
920
|
if not _COUNTING:
|
|
908
|
-
_COUNTING.append(
|
|
921
|
+
_COUNTING.append(source_connect().cursor())
|
|
909
922
|
_COUNTING[0].execute("SELECT COUNT(*) FROM %s" % reading(job))
|
|
910
923
|
return _COUNTING[0].fetchone()[0]
|
|
911
924
|
|
|
@@ -974,20 +987,21 @@ def cmd_run(args):
|
|
|
974
987
|
cur.execute('%s "%s"."%s"' % (verb, job.schema, job.table))
|
|
975
988
|
pg.commit()
|
|
976
989
|
|
|
977
|
-
source_db =
|
|
978
|
-
|
|
990
|
+
source_db = source_connect()
|
|
991
|
+
export, lookup = source_db.cursor(), source_db.cursor()
|
|
979
992
|
grand = 0
|
|
980
993
|
for job in jobs:
|
|
981
994
|
job = bind_enums(job, lookup)
|
|
982
995
|
columns = [p[0] for p in job.pairs] + ([job.derived] if job.derived else [])
|
|
983
|
-
select = ", ".join(
|
|
984
|
-
# Taken BEFORE the
|
|
985
|
-
# above it, so the delta loader picks up exactly what this copy
|
|
986
|
-
|
|
987
|
-
|
|
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)))
|
|
988
1002
|
copied = 0
|
|
989
1003
|
while True:
|
|
990
|
-
batch =
|
|
1004
|
+
batch = export.fetchmany(CHUNK)
|
|
991
1005
|
if not batch:
|
|
992
1006
|
break
|
|
993
1007
|
rows = converted(job, batch)
|
|
@@ -998,8 +1012,9 @@ def cmd_run(args):
|
|
|
998
1012
|
# In the copy's own transaction, so the mark cannot outlive or precede the rows.
|
|
999
1013
|
# A pass that did not empty the table first - the ledger's quarters - only ever
|
|
1000
1014
|
# lowers the mark: an earlier quarter may have changed since it was read.
|
|
1001
|
-
|
|
1002
|
-
|
|
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")))
|
|
1003
1018
|
pg.commit()
|
|
1004
1019
|
if job.rows > CHUNK:
|
|
1005
1020
|
sys.stderr.write("\r" + " " * 60 + "\r")
|
|
@@ -1028,7 +1043,7 @@ def cmd_verify(args):
|
|
|
1028
1043
|
"""Row counts, control sums and orphan references - the three cheap checks."""
|
|
1029
1044
|
mapping, source = column_map(), source_schema()
|
|
1030
1045
|
pg = pg_connect()
|
|
1031
|
-
|
|
1046
|
+
export = source_connect().cursor()
|
|
1032
1047
|
jobs = jobs_for(pg, args.module, mapping, source, args.entity)
|
|
1033
1048
|
cur = pg.cursor()
|
|
1034
1049
|
|
|
@@ -1036,8 +1051,8 @@ def cmd_verify(args):
|
|
|
1036
1051
|
print("-" * 70)
|
|
1037
1052
|
bad = 0
|
|
1038
1053
|
for job in jobs:
|
|
1039
|
-
|
|
1040
|
-
want =
|
|
1054
|
+
export.execute("SELECT COUNT(*) FROM %s" % reading(job))
|
|
1055
|
+
want = export.fetchone()[0]
|
|
1041
1056
|
cur.execute('SELECT COUNT(*) FROM "%s"."%s"%s'
|
|
1042
1057
|
% (job.schema, job.table, real_rows(pg, job)))
|
|
1043
1058
|
got = cur.fetchone()[0]
|
|
@@ -1051,9 +1066,9 @@ def cmd_verify(args):
|
|
|
1051
1066
|
numeric = [(c, s) for (c, s), f in zip(job.pairs, job.converters)
|
|
1052
1067
|
if f is to_number]
|
|
1053
1068
|
for column, src in numeric:
|
|
1054
|
-
|
|
1055
|
-
% (src, reading(job)))
|
|
1056
|
-
want =
|
|
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
|
|
1057
1072
|
cur.execute('SELECT COALESCE(SUM("%s"), 0) FROM "%s"."%s"%s'
|
|
1058
1073
|
% (column, job.schema, job.table, real_rows(pg, job)))
|
|
1059
1074
|
got = cur.fetchone()[0] or 0
|
|
@@ -1096,10 +1111,15 @@ def cmd_verify(args):
|
|
|
1096
1111
|
return 1 if bad else 0
|
|
1097
1112
|
|
|
1098
1113
|
|
|
1099
|
-
|
|
1100
|
-
"
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
1103
1123
|
|
|
1104
1124
|
|
|
1105
1125
|
TEXTUAL = ("char", "varchar", "nchar", "nvarchar", "text", "ntext")
|
|
@@ -1150,7 +1170,7 @@ def cmd_check(args):
|
|
|
1150
1170
|
out one failed COPY at a time. This asks every one of them up front.
|
|
1151
1171
|
|
|
1152
1172
|
`--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
|
|
1173
|
+
hold what it copies, and is it wide enough - and skips every count against the export. That
|
|
1154
1174
|
half is seconds rather than an afternoon, which matters because the afternoon is why it
|
|
1155
1175
|
gets skipped: a standard configuration's 623 entities were loaded without it, and an
|
|
1156
1176
|
hour later `to_uuid` was handed a string this would have named at once. `all` in place of
|
|
@@ -1158,7 +1178,7 @@ def cmd_check(args):
|
|
|
1158
1178
|
"""
|
|
1159
1179
|
mapping, source = column_map(), source_schema()
|
|
1160
1180
|
pg = pg_connect()
|
|
1161
|
-
|
|
1181
|
+
export = None if args.types else source_connect().cursor()
|
|
1162
1182
|
modules = sorted(mapping) if args.module == "all" else [args.module]
|
|
1163
1183
|
jobs = [j for m in modules for j in jobs_for(pg, m, mapping, source, args.entity)]
|
|
1164
1184
|
|
|
@@ -1203,19 +1223,18 @@ def cmd_check(args):
|
|
|
1203
1223
|
width //= 2
|
|
1204
1224
|
if width > info[6]:
|
|
1205
1225
|
narrow.append((job.entity, column, info[6], width))
|
|
1206
|
-
if
|
|
1226
|
+
if export is None or info[2] == "YES" or info[3] is not None:
|
|
1207
1227
|
continue
|
|
1208
1228
|
kind = held["type"]
|
|
1209
|
-
predicate =
|
|
1210
|
-
|
|
1211
|
-
mssql.execute("SELECT COUNT(*), SUM(CASE WHEN %s THEN 1 ELSE 0 END)"
|
|
1229
|
+
predicate = empty_condition(kind, src)
|
|
1230
|
+
export.execute("SELECT COUNT(*), SUM(CASE WHEN %s THEN 1 ELSE 0 END)"
|
|
1212
1231
|
" FROM %s" % (predicate, reading(job)))
|
|
1213
|
-
total, empty =
|
|
1232
|
+
total, empty = export.fetchone()
|
|
1214
1233
|
if empty:
|
|
1215
1234
|
wrong += 1
|
|
1216
1235
|
print("%-26s %-22s %10d %10d" % (job.entity, column, total, empty))
|
|
1217
1236
|
print("-" * 72)
|
|
1218
|
-
if
|
|
1237
|
+
if export is None:
|
|
1219
1238
|
print("mandatory columns: not asked (--types)")
|
|
1220
1239
|
else:
|
|
1221
1240
|
print("%d mandatory column(s) the infobase leaves empty" % wrong)
|
|
@@ -1223,16 +1242,16 @@ def cmd_check(args):
|
|
|
1223
1242
|
# A uniqueness the port declares is a claim about the data too, and the same kind of
|
|
1224
1243
|
# guess: 359 plate numbers are on more than one vehicle.
|
|
1225
1244
|
clashes = []
|
|
1226
|
-
for job in (jobs if
|
|
1245
|
+
for job in (jobs if export is not None else []):
|
|
1227
1246
|
for name, columns in unique_constraints(pg, job.schema, job.table):
|
|
1228
1247
|
sources = [dict(job.pairs).get(c) for c in columns]
|
|
1229
1248
|
if not all(sources):
|
|
1230
1249
|
continue
|
|
1231
|
-
listed = ", ".join(
|
|
1232
|
-
|
|
1250
|
+
listed = ", ".join(store.quote(c) for c in sources)
|
|
1251
|
+
export.execute("SELECT COUNT(*) FROM (SELECT %s FROM %s"
|
|
1233
1252
|
" GROUP BY %s HAVING COUNT(*) > 1) d"
|
|
1234
1253
|
% (listed, reading(job), listed))
|
|
1235
|
-
repeated =
|
|
1254
|
+
repeated = export.fetchone()[0]
|
|
1236
1255
|
if repeated:
|
|
1237
1256
|
clashes.append((job.entity, name, repeated))
|
|
1238
1257
|
if clashes:
|
|
@@ -1241,7 +1260,7 @@ def cmd_check(args):
|
|
|
1241
1260
|
for entity, name, repeated in clashes:
|
|
1242
1261
|
print("%-26s %-30s %10d" % (entity, name, repeated))
|
|
1243
1262
|
print("-" * 70)
|
|
1244
|
-
if
|
|
1263
|
+
if export is None:
|
|
1245
1264
|
print("unique constraints: not asked (--types)")
|
|
1246
1265
|
else:
|
|
1247
1266
|
print("%d unique constraint(s) the infobase does not honour" % len(clashes))
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Read a FILE infobase - the `1Cv8.1CD` 1C keeps when it runs without a server - with no 1C
|
|
5
|
+
software. It is the same tables a server infobase keeps in MS SQL (`Config`, `Params`,
|
|
6
|
+
`_Reference12`, `_Fld345`), in 1C's own page format, and this reads them row by row.
|
|
7
|
+
|
|
8
|
+
Formats 8.2.14 (4 KB pages) and 8.3.8 (4-64 KB pages) - every file infobase 8.2 and 8.3
|
|
9
|
+
write. The layout is not published by 1C; it is the one Tool_1CD and onec_dtools read,
|
|
10
|
+
checked against the Platform8Demo fixtures in all three formats.
|
|
11
|
+
|
|
12
|
+
file header page 0: "1CDBMSV8", the format version, the page count, the page size
|
|
13
|
+
an object a header page listing its data pages (directly, or through index pages);
|
|
14
|
+
a table is three of them - records, blobs, indexes
|
|
15
|
+
the root object 2: the locale and one text description per table
|
|
16
|
+
a record a fixed-width row; the first byte set marks a free slot
|
|
17
|
+
a blob 256-byte blocks inside the blob object, chained by the next block's number
|
|
18
|
+
|
|
19
|
+
Read it while 1C is not writing to it - a copy of the file, or with the infobase closed. A
|
|
20
|
+
file has no `MIN_ACTIVE_ROWVERSION`: what is in it is what was committed.
|
|
21
|
+
"""
|
|
22
|
+
import collections
|
|
23
|
+
import datetime
|
|
24
|
+
import decimal
|
|
25
|
+
import re
|
|
26
|
+
import struct
|
|
27
|
+
|
|
28
|
+
ROOT = 2
|
|
29
|
+
BLOCK = 256
|
|
30
|
+
FORMATS = ("8.2.14.0", "8.3.8.0")
|
|
31
|
+
|
|
32
|
+
Field = collections.namedtuple("Field", "name type nullable length precision offset size")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Database(object):
|
|
36
|
+
def __init__(self, path):
|
|
37
|
+
self.path = path
|
|
38
|
+
self.file = open(path, "rb")
|
|
39
|
+
head = self.file.read(24)
|
|
40
|
+
if head[:8] != b"1CDBMSV8":
|
|
41
|
+
raise ValueError("%s is not a 1C file infobase (no 1CDBMSV8 signature)" % path)
|
|
42
|
+
self.version = ".".join(str(b) for b in bytearray(head[8:12]))
|
|
43
|
+
if self.version not in FORMATS:
|
|
44
|
+
raise NotImplementedError("1CD format %s: only %s are read" % (self.version, ", ".join(FORMATS)))
|
|
45
|
+
self.page_size = 4096
|
|
46
|
+
if self.version == "8.3.8.0":
|
|
47
|
+
(self.page_size,) = struct.unpack_from("<I", head, 20)
|
|
48
|
+
root = Object(self, ROOT)
|
|
49
|
+
if self.version == "8.3.8.0":
|
|
50
|
+
# 8.3.8 keeps the root as a blob in its own object: block 1 is the header.
|
|
51
|
+
data = blob(root, 1)
|
|
52
|
+
describe = lambda offset: blob(root, offset).decode("utf-8")
|
|
53
|
+
else:
|
|
54
|
+
data = root.read(0, len(root))
|
|
55
|
+
describe = lambda offset: Object(self, offset).read_all().decode("utf-16-le")
|
|
56
|
+
self.locale = data[:32].rstrip(b"\x00").decode("utf-8")
|
|
57
|
+
(count,) = struct.unpack_from("<i", data, 32)
|
|
58
|
+
self.tables = collections.OrderedDict()
|
|
59
|
+
for offset in struct.unpack_from("<%di" % count, data, 36):
|
|
60
|
+
table = Table(self, describe(offset))
|
|
61
|
+
self.tables[table.name] = table
|
|
62
|
+
|
|
63
|
+
def page(self, number, size=None):
|
|
64
|
+
self.file.seek(number * self.page_size)
|
|
65
|
+
return self.file.read(size or self.page_size)
|
|
66
|
+
|
|
67
|
+
def close(self):
|
|
68
|
+
self.file.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Object(object):
|
|
72
|
+
"""One object of the file: its length and the pages that hold it, in order."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, db, number):
|
|
75
|
+
self.db = db
|
|
76
|
+
head = db.page(number)
|
|
77
|
+
size = db.page_size
|
|
78
|
+
if db.version == "8.3.8.0":
|
|
79
|
+
if head[:2] != b"\x1c\xfd":
|
|
80
|
+
raise ValueError("object at page %d: signature %r" % (number, head[:2]))
|
|
81
|
+
(fat,) = struct.unpack_from("<H", head, 2)
|
|
82
|
+
(self.length,) = struct.unpack_from("<Q", head, 16)
|
|
83
|
+
slots = struct.unpack_from("<%dI" % ((size - 24) // 4), head, 24)
|
|
84
|
+
if fat == 0:
|
|
85
|
+
self.pages = list(slots[:-(-self.length // size)])
|
|
86
|
+
elif fat == 1:
|
|
87
|
+
self.pages = []
|
|
88
|
+
for index in slots:
|
|
89
|
+
if not index:
|
|
90
|
+
break
|
|
91
|
+
for page in struct.unpack("<%dI" % (size // 4), db.page(index)):
|
|
92
|
+
if not page:
|
|
93
|
+
break
|
|
94
|
+
self.pages.append(page)
|
|
95
|
+
else:
|
|
96
|
+
raise NotImplementedError("object at page %d: allocation depth %d" % (number, fat))
|
|
97
|
+
else:
|
|
98
|
+
if head[:8] != b"1CDBOBV8":
|
|
99
|
+
raise ValueError("object at page %d: signature %r" % (number, head[:8]))
|
|
100
|
+
(self.length,) = struct.unpack_from("<i", head, 8)
|
|
101
|
+
indexes = (self.length - 1) // (1023 * size) + 1 if self.length else 0
|
|
102
|
+
self.pages = []
|
|
103
|
+
for index in struct.unpack_from("<%dI" % indexes, head, 24):
|
|
104
|
+
data = db.page(index)
|
|
105
|
+
(n,) = struct.unpack_from("<i", data, 0)
|
|
106
|
+
self.pages.extend(struct.unpack_from("<%dI" % n, data, 4))
|
|
107
|
+
|
|
108
|
+
def __len__(self):
|
|
109
|
+
return self.length
|
|
110
|
+
|
|
111
|
+
def read(self, at, count):
|
|
112
|
+
size, out = self.db.page_size, []
|
|
113
|
+
count = min(count, self.length - at)
|
|
114
|
+
while count > 0:
|
|
115
|
+
page, within = divmod(at, size)
|
|
116
|
+
take = min(size - within, count)
|
|
117
|
+
self.db.file.seek(self.pages[page] * size + within)
|
|
118
|
+
out.append(self.db.file.read(take))
|
|
119
|
+
at, count = at + take, count - take
|
|
120
|
+
return b"".join(out)
|
|
121
|
+
|
|
122
|
+
def read_all(self):
|
|
123
|
+
return self.read(0, self.length)
|
|
124
|
+
|
|
125
|
+
def chunks(self, width, per=None):
|
|
126
|
+
"""The object as consecutive `width`-byte slices, read a run of pages at a time."""
|
|
127
|
+
per = per or max(1, (1 << 20) // width)
|
|
128
|
+
at = 0
|
|
129
|
+
while at < self.length:
|
|
130
|
+
data = self.read(at, width * per)
|
|
131
|
+
for i in range(0, len(data) - width + 1, width):
|
|
132
|
+
yield data[i:i + width]
|
|
133
|
+
at += width * per
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def blob(obj, block):
|
|
137
|
+
"""A value kept in a blob object: the 256-byte blocks from `block` on, by their links."""
|
|
138
|
+
out = []
|
|
139
|
+
while block:
|
|
140
|
+
data = obj.read(block * BLOCK, BLOCK)
|
|
141
|
+
block, used = struct.unpack_from("<Ih", data, 0)
|
|
142
|
+
out.append(data[6:6 + used])
|
|
143
|
+
return b"".join(out)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --------------------------------------------------------------------------- tables
|
|
147
|
+
|
|
148
|
+
TABLE = re.compile(r'^\{"([^"]+)",\d+,?\s*\{"Fields",(.*?)\},\s*\{"Indexes".*'
|
|
149
|
+
r'\{"Recordlock","(\d)"\},\s*\{"Files",(\d+),(\d+),(\d+)\}\s*\}\s*$', re.S)
|
|
150
|
+
FIELD = re.compile(r'\{"(\w+)","(\w+)",(\d),(\d+),(\d+),"(\w+)"\}')
|
|
151
|
+
|
|
152
|
+
WIDTH = {
|
|
153
|
+
"B": lambda n: n, # binary(n)
|
|
154
|
+
"L": lambda n: 1, # boolean
|
|
155
|
+
"N": lambda n: n // 2 + 1, # BCD digits behind a sign nibble
|
|
156
|
+
"NC": lambda n: n * 2, # nchar(n), UTF-16
|
|
157
|
+
"NVC": lambda n: n * 2 + 2,# nvarchar(n): a u16 length, then the characters
|
|
158
|
+
"RV": lambda n: 16, # the row version
|
|
159
|
+
"NT": lambda n: 8, # ntext: a blob reference
|
|
160
|
+
"I": lambda n: 8, # image: a blob reference
|
|
161
|
+
"DT": lambda n: 7, # datetime, BCD YYYYMMDDhhmmss
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Table(object):
|
|
166
|
+
def __init__(self, db, description):
|
|
167
|
+
self.db = db
|
|
168
|
+
match = TABLE.match(description.lstrip(u"").strip())
|
|
169
|
+
if not match:
|
|
170
|
+
raise ValueError("cannot read the table description %r" % description[:120])
|
|
171
|
+
self.name = match.group(1)
|
|
172
|
+
self.records, self.blobs, self.indexes = (int(match.group(i)) for i in (4, 5, 6))
|
|
173
|
+
self.fields = collections.OrderedDict()
|
|
174
|
+
specs = FIELD.findall(match.group(2))
|
|
175
|
+
# The row version, when a table has one, sits first after the free-slot byte.
|
|
176
|
+
offset = 17 if any(s[1] == "RV" for s in specs) else 1
|
|
177
|
+
for name, kind, nullable, length, precision, _ in specs:
|
|
178
|
+
if kind not in WIDTH:
|
|
179
|
+
raise ValueError("%s.%s: field type %s" % (self.name, name, kind))
|
|
180
|
+
size = (nullable == "1") + WIDTH[kind](int(length))
|
|
181
|
+
if kind == "RV":
|
|
182
|
+
at = 1
|
|
183
|
+
else:
|
|
184
|
+
at, offset = offset, offset + size
|
|
185
|
+
self.fields[name] = Field(name, kind, nullable == "1", int(length), int(precision), at, size)
|
|
186
|
+
# A record is never shorter than a free-slot byte and the next free slot's number.
|
|
187
|
+
self.width = max(offset, 5)
|
|
188
|
+
self._records = self._blobs = None
|
|
189
|
+
|
|
190
|
+
def _object(self, which):
|
|
191
|
+
if which == "records":
|
|
192
|
+
if self._records is None:
|
|
193
|
+
self._records = Object(self.db, self.records) if self.records else None
|
|
194
|
+
return self._records
|
|
195
|
+
if self._blobs is None:
|
|
196
|
+
self._blobs = Object(self.db, self.blobs)
|
|
197
|
+
return self._blobs
|
|
198
|
+
|
|
199
|
+
def __len__(self):
|
|
200
|
+
"""Slots, the free ones included; `rows()` skips those."""
|
|
201
|
+
obj = self._object("records")
|
|
202
|
+
return len(obj) // self.width if obj else 0
|
|
203
|
+
|
|
204
|
+
def count(self):
|
|
205
|
+
"""Live rows: the slots whose free-slot byte is clear."""
|
|
206
|
+
obj = self._object("records")
|
|
207
|
+
return sum(1 for raw in obj.chunks(self.width) if raw[:1] != b"\x01") if obj else 0
|
|
208
|
+
|
|
209
|
+
def rows(self, columns=None):
|
|
210
|
+
"""Live rows as tuples of Python values, in `columns` order (all fields by default)."""
|
|
211
|
+
obj = self._object("records")
|
|
212
|
+
if obj is None:
|
|
213
|
+
return
|
|
214
|
+
fields = [self.fields[c] for c in (columns or list(self.fields))]
|
|
215
|
+
for raw in obj.chunks(self.width):
|
|
216
|
+
if raw[:1] == b"\x01":
|
|
217
|
+
continue
|
|
218
|
+
yield tuple(self.value(f, raw) for f in fields)
|
|
219
|
+
|
|
220
|
+
def value(self, field, raw):
|
|
221
|
+
data = raw[field.offset:field.offset + field.size]
|
|
222
|
+
if field.nullable:
|
|
223
|
+
if data[:1] == b"\x00":
|
|
224
|
+
return None
|
|
225
|
+
data = data[1:]
|
|
226
|
+
kind = field.type
|
|
227
|
+
if kind == "B":
|
|
228
|
+
return data
|
|
229
|
+
if kind == "L":
|
|
230
|
+
return data[:1] != b"\x00"
|
|
231
|
+
if kind == "N":
|
|
232
|
+
return numeric(data, field.length, field.precision)
|
|
233
|
+
if kind == "NC":
|
|
234
|
+
return data.decode("utf-16-le")
|
|
235
|
+
if kind == "NVC":
|
|
236
|
+
(n,) = struct.unpack_from("<H", data, 0)
|
|
237
|
+
return data[2:2 + n * 2].decode("utf-16-le")
|
|
238
|
+
if kind == "RV":
|
|
239
|
+
return struct.unpack("<4I", data)
|
|
240
|
+
if kind == "DT":
|
|
241
|
+
return moment(data)
|
|
242
|
+
block, size = struct.unpack("<2I", data)
|
|
243
|
+
if not block and not size:
|
|
244
|
+
return u"" if kind == "NT" else b""
|
|
245
|
+
value = blob(self._object("blobs"), block)[:size]
|
|
246
|
+
return value.decode("utf-16-le") if kind == "NT" else value
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def numeric(data, length, precision):
|
|
250
|
+
"""BCD: the high nibble of the first byte is the sign (1 positive), then `length` digits.
|
|
251
|
+
|
|
252
|
+
A Decimal, never a float: the kopeck has to survive the read.
|
|
253
|
+
"""
|
|
254
|
+
digits = "".join("%02x" % b for b in bytearray(data))
|
|
255
|
+
sign, body = digits[0], digits[1:1 + length]
|
|
256
|
+
if precision:
|
|
257
|
+
body = body[:length - precision] + "." + body[length - precision:]
|
|
258
|
+
value = decimal.Decimal(body)
|
|
259
|
+
return value if sign != "0" else -value
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def moment(data):
|
|
263
|
+
digits = "".join("%02x" % b for b in bytearray(data))
|
|
264
|
+
year = int(digits[0:4])
|
|
265
|
+
if year == 0:
|
|
266
|
+
# The empty date, which a server infobase holds as 0001-01-01 (shifted).
|
|
267
|
+
return datetime.datetime(1, 1, 1)
|
|
268
|
+
return datetime.datetime(year, int(digits[4:6]), int(digits[6:8]),
|
|
269
|
+
int(digits[8:10]), int(digits[10:12]), int(digits[12:14]))
|
|
@@ -11,6 +11,7 @@ holding `tools/` is used, which is what a project laid out like this one gets fo
|
|
|
11
11
|
"""
|
|
12
12
|
from __future__ import print_function
|
|
13
13
|
|
|
14
|
+
import io
|
|
14
15
|
import os
|
|
15
16
|
|
|
16
17
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -28,6 +29,26 @@ def _default_root():
|
|
|
28
29
|
|
|
29
30
|
ROOT = os.path.abspath(os.environ.get("DF1C_ROOT") or _default_root())
|
|
30
31
|
|
|
32
|
+
|
|
33
|
+
def load_env(path=os.path.join(ROOT, ".env")):
|
|
34
|
+
"""Read `<root>/.env` into the environment without overriding what is already set.
|
|
35
|
+
|
|
36
|
+
Here, before anything below is worked out, so that every tool sees it - the export
|
|
37
|
+
included - and a `DF1C_OUT` written there moves `out/` as one exported in the shell does.
|
|
38
|
+
"""
|
|
39
|
+
if not os.path.isfile(path):
|
|
40
|
+
return
|
|
41
|
+
with io.open(path, encoding="utf-8") as f:
|
|
42
|
+
for line in f:
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
45
|
+
continue
|
|
46
|
+
key, value = line.split("=", 1)
|
|
47
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
load_env()
|
|
51
|
+
|
|
31
52
|
# What the extraction writes and everything downstream reads. Kept separate from ROOT
|
|
32
53
|
# because a second infobase dumped beside the first only differs here.
|
|
33
54
|
OUT = os.path.abspath(os.environ.get("DF1C_OUT") or os.path.join(ROOT, "out"))
|