@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
|
@@ -22,7 +22,7 @@ import paths
|
|
|
22
22
|
|
|
23
23
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
24
|
|
|
25
|
-
import
|
|
25
|
+
import store
|
|
26
26
|
|
|
27
27
|
OUT = paths.OUT
|
|
28
28
|
|
|
@@ -52,10 +52,8 @@ def main():
|
|
|
52
52
|
|
|
53
53
|
table = sys.argv[1]
|
|
54
54
|
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 5
|
|
55
|
-
cur =
|
|
56
|
-
|
|
57
|
-
"which MS SQL infobase to read")).cursor()
|
|
58
|
-
cur.execute("SELECT TOP (%d) * FROM [%s]" % (limit, table))
|
|
55
|
+
cur = store.connect().cursor()
|
|
56
|
+
cur.execute("SELECT * FROM %s LIMIT %d" % (store.quote(table), limit))
|
|
59
57
|
columns = [d[0] for d in cur.description]
|
|
60
58
|
rows = cur.fetchall()
|
|
61
59
|
# drop columns that are empty across every sampled row - they only add noise
|
|
@@ -17,7 +17,7 @@ the one document table whose keys its recorders match.
|
|
|
17
17
|
python tools/1c.py recorded # writes out/recorded.json
|
|
18
18
|
python tools/1c.py recorded --compare # and scores out/callgraph.json against it
|
|
19
19
|
|
|
20
|
-
Read
|
|
20
|
+
Read off the export (`store.py`), so it asks nothing of the running infobase.
|
|
21
21
|
"""
|
|
22
22
|
from __future__ import print_function
|
|
23
23
|
|
|
@@ -31,6 +31,7 @@ import sys
|
|
|
31
31
|
import meta
|
|
32
32
|
import migrate
|
|
33
33
|
import paths
|
|
34
|
+
import store
|
|
34
35
|
|
|
35
36
|
TARGET = os.path.join(paths.OUT, "recorded.json")
|
|
36
37
|
|
|
@@ -65,20 +66,19 @@ def documents():
|
|
|
65
66
|
return found
|
|
66
67
|
|
|
67
68
|
|
|
68
|
-
def columns(
|
|
69
|
-
|
|
70
|
-
return set(row[0] for row in cur.fetchall())
|
|
69
|
+
def columns(table):
|
|
70
|
+
return migrate.source_schema()[table]["columns"]
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def single_recorder(cur, table, docs):
|
|
74
74
|
"""The one document type a TRef-less register's recorders belong to."""
|
|
75
|
-
cur.execute("SELECT DISTINCT
|
|
75
|
+
cur.execute("SELECT DISTINCT _RecorderRRef FROM %s LIMIT 20" % store.quote(table))
|
|
76
76
|
refs = [bytes(r[0]) for r in cur.fetchall()]
|
|
77
77
|
if not refs:
|
|
78
78
|
return None
|
|
79
79
|
marks = ",".join("?" * len(refs))
|
|
80
80
|
for number, (name, physical) in sorted(docs.items()):
|
|
81
|
-
cur.execute("SELECT COUNT(*) FROM
|
|
81
|
+
cur.execute("SELECT COUNT(*) FROM %s WHERE _IDRRef IN (%s)" % (store.quote(physical), marks),
|
|
82
82
|
*refs)
|
|
83
83
|
if cur.fetchone()[0]:
|
|
84
84
|
return number
|
|
@@ -87,17 +87,17 @@ def single_recorder(cur, table, docs):
|
|
|
87
87
|
|
|
88
88
|
def measure():
|
|
89
89
|
migrate.load_env()
|
|
90
|
-
cur = migrate.
|
|
90
|
+
cur = migrate.source_connect().cursor()
|
|
91
91
|
docs = documents()
|
|
92
92
|
result = collections.defaultdict(dict) # document -> register -> rows
|
|
93
93
|
unmatched = []
|
|
94
94
|
for family, name, table in registers():
|
|
95
|
-
cols = columns(
|
|
95
|
+
cols = columns(table)
|
|
96
96
|
if "_RecorderRRef" not in cols:
|
|
97
97
|
continue # not subordinate to a recorder
|
|
98
98
|
if "_RecorderTRef" in cols:
|
|
99
|
-
cur.execute("SELECT _RecorderTRef,
|
|
100
|
-
" GROUP BY _RecorderTRef" % table)
|
|
99
|
+
cur.execute("SELECT _RecorderTRef, COUNT(*) FROM %s"
|
|
100
|
+
" GROUP BY _RecorderTRef" % store.quote(table))
|
|
101
101
|
for tref, rows in cur.fetchall():
|
|
102
102
|
number = int.from_bytes(bytes(tref), "big") & 0xFFFF
|
|
103
103
|
if number in docs:
|
|
@@ -105,7 +105,7 @@ def measure():
|
|
|
105
105
|
else:
|
|
106
106
|
unmatched.append((name, number, int(rows)))
|
|
107
107
|
else:
|
|
108
|
-
cur.execute("SELECT
|
|
108
|
+
cur.execute("SELECT COUNT(*) FROM %s" % store.quote(table))
|
|
109
109
|
rows = int(cur.fetchone()[0])
|
|
110
110
|
if not rows:
|
|
111
111
|
continue
|
|
@@ -22,7 +22,7 @@ import paths
|
|
|
22
22
|
|
|
23
23
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
24
|
|
|
25
|
-
import
|
|
25
|
+
import store
|
|
26
26
|
|
|
27
27
|
OUT = paths.OUT
|
|
28
28
|
|
|
@@ -35,7 +35,7 @@ def load_report():
|
|
|
35
35
|
def probe_single(cur, table):
|
|
36
36
|
"""Without a TRef the recorder's type is implicit, so find the one document table
|
|
37
37
|
whose PK matches the recorders we sampled."""
|
|
38
|
-
cur.execute("SELECT DISTINCT
|
|
38
|
+
cur.execute("SELECT DISTINCT _RecorderRRef FROM %s LIMIT 20" % store.quote(table))
|
|
39
39
|
refs = [bytes(r[0]) for r in cur.fetchall()]
|
|
40
40
|
if not refs:
|
|
41
41
|
print(" (register is empty)")
|
|
@@ -46,8 +46,8 @@ def probe_single(cur, table):
|
|
|
46
46
|
suffix = physical[len("_Document"):] if physical.startswith("_Document") else ""
|
|
47
47
|
if not suffix.isdigit() or not row.get("rows"):
|
|
48
48
|
continue
|
|
49
|
-
cur.execute("SELECT COUNT(*) FROM
|
|
50
|
-
% (physical, placeholders), *refs)
|
|
49
|
+
cur.execute("SELECT COUNT(*) FROM %s WHERE _IDRRef IN (%s)"
|
|
50
|
+
% (store.quote(physical), placeholders), *refs)
|
|
51
51
|
hits = cur.fetchone()[0]
|
|
52
52
|
if hits:
|
|
53
53
|
print(" %s - %s (%d/%d sampled recorders matched)"
|
|
@@ -58,19 +58,16 @@ def probe_single(cur, table):
|
|
|
58
58
|
|
|
59
59
|
def main():
|
|
60
60
|
table = sys.argv[1]
|
|
61
|
-
cur =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
cur.execute("""SELECT COUNT(*) FROM sys.columns
|
|
65
|
-
WHERE object_id = OBJECT_ID(?) AND name = '_RecorderTRef'""", table)
|
|
66
|
-
if not cur.fetchone()[0]:
|
|
61
|
+
cur = store.connect().cursor()
|
|
62
|
+
cur.execute("SELECT * FROM %s LIMIT 0" % store.quote(table))
|
|
63
|
+
if "_RecorderTRef" not in [d[0] for d in cur.description]:
|
|
67
64
|
print("%s has no _RecorderTRef - one document type writes it; probing for it"
|
|
68
65
|
% table)
|
|
69
66
|
probe_single(cur, table)
|
|
70
67
|
return
|
|
71
68
|
|
|
72
|
-
cur.execute("SELECT _RecorderTRef, COUNT(*) FROM
|
|
73
|
-
"ORDER BY 2 DESC" % table)
|
|
69
|
+
cur.execute("SELECT _RecorderTRef, COUNT(*) FROM %s GROUP BY _RecorderTRef "
|
|
70
|
+
"ORDER BY 2 DESC" % store.quote(table))
|
|
74
71
|
rows = cur.fetchall()
|
|
75
72
|
|
|
76
73
|
# a document's type id is its table number; _RecorderTRef stores it big-endian
|
|
@@ -47,6 +47,7 @@ import sys
|
|
|
47
47
|
|
|
48
48
|
import attrs
|
|
49
49
|
import migrate
|
|
50
|
+
import store
|
|
50
51
|
|
|
51
52
|
Register = collections.namedtuple("Register", [
|
|
52
53
|
"module", "code", "state", "journal", "journal_entity", "direction", "date",
|
|
@@ -170,7 +171,7 @@ def totals_table(table):
|
|
|
170
171
|
return found[0] if found else None
|
|
171
172
|
|
|
172
173
|
|
|
173
|
-
def one_c_balance(
|
|
174
|
+
def one_c_balance(export, pg, reg, mapping, source, period=None):
|
|
174
175
|
"""(totals table, period, {tuple: resources}) - 1C's totals at a period, non-zero only.
|
|
175
176
|
|
|
176
177
|
No period means the table's last one: 5999-11-01, where 1C keeps its CURRENT totals. A
|
|
@@ -184,6 +185,10 @@ def one_c_balance(mssql, pg, reg, mapping, source, period=None):
|
|
|
184
185
|
table = totals_table(mapped["table"])
|
|
185
186
|
if not table:
|
|
186
187
|
raise SystemExit("seedstate: %s has no totals table in DBNames" % mapped["table"])
|
|
188
|
+
if table not in store.manifest()["tables"]:
|
|
189
|
+
# Totals are derived, so a default export leaves them out (dump1c.DERIVED).
|
|
190
|
+
raise SystemExit("seedstate: %s's totals %s are not in the export - `1c.py dump1c data %s`"
|
|
191
|
+
% (mapped["table"], table, table))
|
|
187
192
|
schema, name = [p.strip('"') for p in reg.journal.split(".")]
|
|
188
193
|
types = dict((c[0], c[1]) for c in migrate.table_columns(pg, schema, name))
|
|
189
194
|
held = source[mapped["table"]]["columns"]
|
|
@@ -199,13 +204,13 @@ def one_c_balance(mssql, pg, reg, mapping, source, period=None):
|
|
|
199
204
|
converters.append(migrate.converter(types[ours], held[physical]["type"]))
|
|
200
205
|
|
|
201
206
|
if period is None:
|
|
202
|
-
|
|
203
|
-
period =
|
|
204
|
-
|
|
205
|
-
% (", ".join(
|
|
207
|
+
export.execute("SELECT MAX(_Period) FROM %s" % store.quote(table))
|
|
208
|
+
period = export.fetchone()[0]
|
|
209
|
+
export.execute("SELECT %s FROM %s WHERE _Period = ?"
|
|
210
|
+
% (", ".join(store.quote(c) for c in columns), store.quote(table)), period)
|
|
206
211
|
width = len(reg.dimensions)
|
|
207
212
|
balance = collections.defaultdict(lambda: [decimal.Decimal(0)] * len(reg.resources))
|
|
208
|
-
for row in
|
|
213
|
+
for row in export.fetchall():
|
|
209
214
|
values = [normal(f(v), types[c])
|
|
210
215
|
for f, v, c in zip(converters, row, reg.dimensions + reg.resources)]
|
|
211
216
|
held_now = balance[tuple(values[:width])]
|
|
@@ -259,17 +264,17 @@ def as_of_balance(pg, reg, until):
|
|
|
259
264
|
return out
|
|
260
265
|
|
|
261
266
|
|
|
262
|
-
def verify(pg, reg, mapping, source,
|
|
267
|
+
def verify(pg, reg, mapping, source, export, month=None):
|
|
263
268
|
"""The state against 1C's current totals, or - given a month - the as-of read at its
|
|
264
269
|
end against 1C's totals row for the month after."""
|
|
265
270
|
if month:
|
|
266
271
|
year, number = [int(p) for p in month.split("-")]
|
|
267
272
|
until = datetime.date(year + number // 12, number % 12 + 1, 1)
|
|
268
|
-
period = datetime.datetime(until.year +
|
|
269
|
-
table, period, theirs = one_c_balance(
|
|
273
|
+
period = datetime.datetime(until.year + store.year_offset(), until.month, 1)
|
|
274
|
+
table, period, theirs = one_c_balance(export, pg, reg, mapping, source, period)
|
|
270
275
|
ours = as_of_balance(pg, reg, until)
|
|
271
276
|
else:
|
|
272
|
-
table, period, theirs = one_c_balance(
|
|
277
|
+
table, period, theirs = one_c_balance(export, pg, reg, mapping, source)
|
|
273
278
|
ours = state_balance(pg, reg)
|
|
274
279
|
both = set(theirs) & set(ours)
|
|
275
280
|
differ = [k for k in both if theirs[k] != ours[k]]
|
|
@@ -303,7 +308,7 @@ def month_end(month):
|
|
|
303
308
|
year, number = [int(p) for p in month.split("-")]
|
|
304
309
|
start = datetime.date(year + number // 12, number % 12 + 1, 1)
|
|
305
310
|
return (start - datetime.timedelta(days=1),
|
|
306
|
-
datetime.datetime(start.year +
|
|
311
|
+
datetime.datetime(start.year + store.year_offset(), start.month, 1))
|
|
307
312
|
|
|
308
313
|
|
|
309
314
|
def sample(reg, tuples, size):
|
|
@@ -370,7 +375,7 @@ def expected(reg, theirs, dims):
|
|
|
370
375
|
return total
|
|
371
376
|
|
|
372
377
|
|
|
373
|
-
def probe(pg, reg, mapping, source,
|
|
378
|
+
def probe(pg, reg, mapping, source, export, size, months):
|
|
374
379
|
import posting
|
|
375
380
|
cur = pg.cursor()
|
|
376
381
|
cur.execute("""SELECT a.action_id FROM "dForge".action a
|
|
@@ -392,7 +397,7 @@ def probe(pg, reg, mapping, source, mssql, size, months):
|
|
|
392
397
|
dates = [(None, None)] + [month_end(m) for m in months]
|
|
393
398
|
theirs = {}
|
|
394
399
|
for as_of, period in dates:
|
|
395
|
-
theirs[as_of] = one_c_balance(
|
|
400
|
+
theirs[as_of] = one_c_balance(export, pg, reg, mapping, source, period)[2]
|
|
396
401
|
pool = {}
|
|
397
402
|
for got in theirs.values():
|
|
398
403
|
for t, v in got.items():
|
|
@@ -474,13 +479,13 @@ def main():
|
|
|
474
479
|
% ("%s.%s" % (reg.module, reg.code), rows, bad, undated, off))
|
|
475
480
|
else:
|
|
476
481
|
mapping, source = migrate.column_map(), migrate.source_schema()
|
|
477
|
-
|
|
482
|
+
export = migrate.source_connect().cursor()
|
|
478
483
|
for reg in found:
|
|
479
484
|
if args.command == "probe":
|
|
480
|
-
clean = probe(pg, reg, mapping, source,
|
|
485
|
+
clean = probe(pg, reg, mapping, source, export, args.sample,
|
|
481
486
|
[args.asof] if args.asof else PROBE_MONTHS) and clean
|
|
482
487
|
else:
|
|
483
|
-
clean = verify(pg, reg, mapping, source,
|
|
488
|
+
clean = verify(pg, reg, mapping, source, export, args.asof) and clean
|
|
484
489
|
return 0 if clean else 1
|
|
485
490
|
|
|
486
491
|
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
A file infobase (`1Cv8.1CD`) as an export source: `onecd` reads it, this makes what it
|
|
4
|
+
reads look like what a server infobase gives, so nothing downstream asks which one it was.
|
|
5
|
+
|
|
6
|
+
Three differences are folded here and nowhere else:
|
|
7
|
+
|
|
8
|
+
- NAMES. A file infobase keeps every name in capitals - `_REFERENCE12`, `_FLD345RREF` - where
|
|
9
|
+
MS SQL has `_Reference12`, `_Fld345RRef`, and the column map, the project's sources and
|
|
10
|
+
the library all spell them the MS SQL way. The spelling is put back from DBNames, which
|
|
11
|
+
carries every numbered kind in its own case (`AccumRgT`, `ReferenceChngR`, `VT`, `Fld`),
|
|
12
|
+
plus the fixed names 1C gives the standard columns. A name neither covers is a service
|
|
13
|
+
table's own business that nothing reads; it is capitalised and counted in the manifest.
|
|
14
|
+
- TYPES. The schema is written in MS SQL's vocabulary - `binary(16)`, `nvarchar`, `numeric`,
|
|
15
|
+
`datetime`, `len` in bytes as `sys.columns` gives it - because that is what the generator
|
|
16
|
+
and the checks read.
|
|
17
|
+
- VALUES. A boolean is `binary(1)` 0x01 / 0x00 as on MS SQL; the empty date is 0001-01-01.
|
|
18
|
+
A file infobase has no year offset: its dates are the real ones, and the manifest says so.
|
|
19
|
+
|
|
20
|
+
What does NOT carry over is the row version. In a file `_Version` is a per-table counter,
|
|
21
|
+
not MS SQL's database-wide rowversion, so a file export has no horizon and `delta` cannot
|
|
22
|
+
read changes off it. It is exported as `binary(16)`, for completeness only.
|
|
23
|
+
"""
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import struct
|
|
27
|
+
|
|
28
|
+
import dump1c
|
|
29
|
+
import onecd
|
|
30
|
+
|
|
31
|
+
SERVICE = dict((n.upper(), n) for n in (
|
|
32
|
+
"Config", "ConfigSave", "Params", "Files", "DBSchema", "IBVersion", "v8users", "_YearOffset",
|
|
33
|
+
"FileName", "Creation", "Modified", "Attributes", "DataSize", "BinaryData", "PartNo",
|
|
34
|
+
"SerializedData",
|
|
35
|
+
# v8users and IBVersion
|
|
36
|
+
"ID", "Name", "Descr", "OSName", "Changed", "RolesID", "Show", "Data", "EAuth", "AdmRole",
|
|
37
|
+
"UsSprH", "PlatformVersionReq"))
|
|
38
|
+
|
|
39
|
+
# The un-numbered segments of 1C's names as MS SQL spells them, between underscores:
|
|
40
|
+
# `_Fld12_TYPE` is `Fld12` and `TYPE`. Harvested from a server infobase's whole schema
|
|
41
|
+
# (2,668 tables, 28,715 columns) rather than remembered, so a list that misses one is
|
|
42
|
+
# fixed by harvesting again. Where two spellings fold to one - `UserId` and `UserID` both
|
|
43
|
+
# occur - the commoner one is here and `migrate` matches the other regardless of case.
|
|
44
|
+
WORDS = dict((w.upper(), w) for w in (
|
|
45
|
+
"APDateFrom", "APDateTill", "AccountCtRRef", "AccountDtRRef", "AccountRRef", "ActionPeriod",
|
|
46
|
+
"ActivationCondition", "Active", "ActualPeriod", "BPDateFrom", "BPDateTill", "BaseAsAct",
|
|
47
|
+
"BaseCK", "BaseCKBaseCK", "BaseCKBaseCKRRef", "BaseCKLineNo", "BusinessProcess", "CKRefRRef",
|
|
48
|
+
"CalcKindRRef", "Code", "Completed", "ConfigChngR", "ConnectPrms", "ConstID", "Correspond",
|
|
49
|
+
"Date", "Description", "DimHash", "DimIsMetadata", "DimKindRRef", "DisplNumber", "DisplacedCK",
|
|
50
|
+
"DisplacedCKDisplCKRRef", "DisplacedCKLineNo", "DocumentRRef", "DocumentTRef", "EDHashCt",
|
|
51
|
+
"EDHashDt", "EnumOrder", "Executed", "ExtDataSrcId", "ExtProps", "FileName", "FinishTime",
|
|
52
|
+
"Folder", "HeadTaskRRef", "ID", "IDRRef", "IsMetadata", "JobKey", "KeyField", "Kind",
|
|
53
|
+
"KindRRef", "LeadingCK", "LeadingCKLeadingCK", "LeadingCKLineNo", "LineNo", "MDObjID",
|
|
54
|
+
"Marked", "MessageNo", "MetadataID", "MinPeriod", "Name", "NodeRRef", "NodeTRef", "Number",
|
|
55
|
+
"NumberPrefix", "ObjectKey", "OffBalance", "OrderField", "OwnerID", "OwnerIDRRef",
|
|
56
|
+
"Parameters", "ParentIDRRef", "Period", "Periodicity", "Point", "Posted", "Predefined",
|
|
57
|
+
"PredefinedBaseCKTableLine", "PredefinedDisplCKTableLine", "PredefinedID",
|
|
58
|
+
"PredefinedLeadingCKTableLine", "PredefinedObj", "ReceivedNo", "RecordKey", "RecordKind",
|
|
59
|
+
"Recorder", "RecorderRRef", "RecorderTRef", "RegID", "RepetitionFactor",
|
|
60
|
+
"RestartAttemptNumber", "RestartCount", "RestartPeriod", "RoutePointOrder", "SentNo",
|
|
61
|
+
"SettingsData", "SettingsKey", "SettingsPresentation", "SimpleKey", "SliceUsing", "Splitter",
|
|
62
|
+
"StartTime", "Started", "State", "Storno", "TurnoverOnly", "URL", "URLHash", "Use",
|
|
63
|
+
"UseSplitter", "UseTotals", "UserId", "UserName", "Value", "Value1", "Value2", "Value3",
|
|
64
|
+
"Value4", "Version", "YearOffset",
|
|
65
|
+
# a composite reference's parts, and a column's own type tag
|
|
66
|
+
"RRRef", "RTRef", "RRef", "TRef", "L", "N", "T", "S", "B"))
|
|
67
|
+
# What follows a numbered kind inside one segment: `_Fld12RRef`, `_Fld12DtRRef`.
|
|
68
|
+
SUFFIXES = ("CtRRef", "DtRRef", "RRef", "TRef", "Ct", "Dt", "")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Names(object):
|
|
72
|
+
"""Upper-case 1CD names -> the MS SQL spelling, from DBNames' kinds."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, kinds):
|
|
75
|
+
self.kinds = sorted(set(kinds), key=len, reverse=True)
|
|
76
|
+
self.numbered = [(re.compile("^%s(\\d+)(%s)$" % (k.upper(), "|".join(s.upper() for s in SUFFIXES))), k)
|
|
77
|
+
for k in self.kinds]
|
|
78
|
+
self.guessed = set()
|
|
79
|
+
|
|
80
|
+
def __call__(self, name):
|
|
81
|
+
if name in SERVICE:
|
|
82
|
+
return SERVICE[name]
|
|
83
|
+
parts = name.replace("DATE_TIME", "DATE\x00TIME").split("_")
|
|
84
|
+
out = []
|
|
85
|
+
for at, part in enumerate(parts):
|
|
86
|
+
if part == "DATE\x00TIME":
|
|
87
|
+
out.append("Date_Time")
|
|
88
|
+
elif not part:
|
|
89
|
+
out.append(part)
|
|
90
|
+
elif part == "TYPE":
|
|
91
|
+
# A column of its own is `_Type` (a characteristic's value type); after
|
|
92
|
+
# another segment it is a composite reference's tag, `_Fld12_TYPE`.
|
|
93
|
+
out.append("Type" if at == 1 else "TYPE")
|
|
94
|
+
else:
|
|
95
|
+
out.append(self.segment(part, name))
|
|
96
|
+
return "_".join(out)
|
|
97
|
+
|
|
98
|
+
def segment(self, part, name):
|
|
99
|
+
for pattern, kind in self.numbered:
|
|
100
|
+
match = pattern.match(part)
|
|
101
|
+
if match:
|
|
102
|
+
suffix = next(s for s in SUFFIXES if s.upper() == match.group(2))
|
|
103
|
+
return kind + match.group(1) + suffix
|
|
104
|
+
if part in WORDS:
|
|
105
|
+
return WORDS[part]
|
|
106
|
+
# A kind with no number of its own: a service table such as `_AccOpt`.
|
|
107
|
+
for kind in self.kinds:
|
|
108
|
+
if part == kind.upper():
|
|
109
|
+
return kind
|
|
110
|
+
self.guessed.add(name)
|
|
111
|
+
match = re.match(r"^([A-Z]+)(\d*)(.*)$", part)
|
|
112
|
+
return (match.group(1).capitalize() + match.group(2) + match.group(3)) if match else part
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def numeric_bytes(precision):
|
|
116
|
+
"""What `sys.columns.max_length` says for numeric(p): the schema dump carries it as-is."""
|
|
117
|
+
return 5 if precision <= 9 else 9 if precision <= 19 else 13 if precision <= 28 else 17
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def column_type(field):
|
|
121
|
+
"""A 1CD field -> the schema dump's (type, len, precision, scale) in MS SQL terms."""
|
|
122
|
+
kind, n = field.type, field.length
|
|
123
|
+
if kind == "B":
|
|
124
|
+
return "binary", n, 0, 0
|
|
125
|
+
if kind == "L":
|
|
126
|
+
return "binary", 1, 0, 0
|
|
127
|
+
if kind == "N":
|
|
128
|
+
return "numeric", numeric_bytes(n), n, field.precision
|
|
129
|
+
if kind == "NC":
|
|
130
|
+
return "nchar", n * 2, 0, 0
|
|
131
|
+
if kind == "NVC":
|
|
132
|
+
return "nvarchar", n * 2, 0, 0
|
|
133
|
+
if kind == "NT":
|
|
134
|
+
return "nvarchar", -1, 0, 0
|
|
135
|
+
if kind == "I":
|
|
136
|
+
return "varbinary", -1, 0, 0
|
|
137
|
+
if kind == "DT":
|
|
138
|
+
return "datetime", 8, 23, 3
|
|
139
|
+
if kind == "RV":
|
|
140
|
+
return "binary", 16, 0, 0
|
|
141
|
+
raise ValueError("1CD field type %s" % kind)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def canonical(field):
|
|
145
|
+
"""A function turning one of `onecd`'s values for this field into MS SQL's."""
|
|
146
|
+
if field.type == "L":
|
|
147
|
+
return lambda v: None if v is None else (b"\x01" if v else b"\x00")
|
|
148
|
+
if field.type == "RV":
|
|
149
|
+
return lambda v: None if v is None else struct.pack(">4I", *v)
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class FileSource(object):
|
|
154
|
+
kind = "file"
|
|
155
|
+
year_offset = 0
|
|
156
|
+
consistent = "the file as it was read; copy it, or close the infobase, before exporting"
|
|
157
|
+
|
|
158
|
+
def __init__(self, path):
|
|
159
|
+
self.path = os.path.abspath(path)
|
|
160
|
+
self.db = onecd.Database(self.path)
|
|
161
|
+
self._files = {}
|
|
162
|
+
self._tables = None
|
|
163
|
+
self.names = Names(e["type"] for e in dump1c.parse_dbnames(self.read_file("Params", "DBNames")))
|
|
164
|
+
self.by_name = dict((self.names(t), t) for t in self.db.tables)
|
|
165
|
+
|
|
166
|
+
def describe(self):
|
|
167
|
+
return {"kind": self.kind, "path": self.path, "format": self.db.version,
|
|
168
|
+
"page_size": self.db.page_size, "locale": self.db.locale}
|
|
169
|
+
|
|
170
|
+
def _table(self, name):
|
|
171
|
+
return self.db.tables[self.by_name[name] if name in self.by_name else name.upper()]
|
|
172
|
+
|
|
173
|
+
def _service(self, table):
|
|
174
|
+
"""A service table's files, {name: [(part, compressed body)]}, read in one pass: a
|
|
175
|
+
blob is reached only through its row, so a read per file would read them all again."""
|
|
176
|
+
table = table.upper()
|
|
177
|
+
if table not in self._files:
|
|
178
|
+
found = {}
|
|
179
|
+
for name, part, body in self.db.tables[table].rows(["FILENAME", "PARTNO", "BINARYDATA"]):
|
|
180
|
+
found.setdefault(name, []).append((part or 0, body or b""))
|
|
181
|
+
self._files[table] = found
|
|
182
|
+
return self._files[table]
|
|
183
|
+
|
|
184
|
+
def files(self, table):
|
|
185
|
+
return sorted(self._service(table))
|
|
186
|
+
|
|
187
|
+
def read_file(self, table, name):
|
|
188
|
+
parts = sorted(self._service(table).get(name, ()))
|
|
189
|
+
return dump1c.inflate(b"".join(body for _, body in parts))
|
|
190
|
+
|
|
191
|
+
def tables(self):
|
|
192
|
+
"""[(name, columns, rows)] - the schema dump's shape, every table in the file.
|
|
193
|
+
|
|
194
|
+
Counting a table's rows reads all of it - a file keeps no row count - so this is
|
|
195
|
+
worked out once per run, however many commands of `dump1c all` ask for it."""
|
|
196
|
+
if self._tables is None:
|
|
197
|
+
self._tables = self._read_tables()
|
|
198
|
+
return self._tables
|
|
199
|
+
|
|
200
|
+
def _read_tables(self):
|
|
201
|
+
out = []
|
|
202
|
+
for raw, table in self.db.tables.items():
|
|
203
|
+
columns = []
|
|
204
|
+
for field in table.fields.values():
|
|
205
|
+
kind, length, precision, scale = column_type(field)
|
|
206
|
+
columns.append({"column": self.names(field.name), "type": kind, "len": length,
|
|
207
|
+
"precision": precision, "scale": scale, "nullable": field.nullable})
|
|
208
|
+
out.append((self.names(raw), columns, table.count()))
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
def rows(self, table, columns, chunk):
|
|
212
|
+
"""Batches of tuples, in `columns` order (the MS SQL spelling)."""
|
|
213
|
+
t = self._table(table)
|
|
214
|
+
by_name = dict((self.names(f), f) for f in t.fields)
|
|
215
|
+
raw = [by_name[c] for c in columns]
|
|
216
|
+
fix = [canonical(t.fields[r]) for r in raw]
|
|
217
|
+
batch = []
|
|
218
|
+
for row in t.rows(raw):
|
|
219
|
+
batch.append(tuple(f(v) if f else v for f, v in zip(fix, row)))
|
|
220
|
+
if len(batch) >= chunk:
|
|
221
|
+
yield batch
|
|
222
|
+
batch = []
|
|
223
|
+
if batch:
|
|
224
|
+
yield batch
|
|
225
|
+
|
|
226
|
+
def horizon(self):
|
|
227
|
+
return None
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
A server infobase on MS SQL as an export source. The only module in the library that
|
|
4
|
+
speaks T-SQL to 1C; everything past the export reads `out/` (see `store.py`).
|
|
5
|
+
|
|
6
|
+
Credentials come from env DF1C_USER / DF1C_PASS; without them, Windows auth is used.
|
|
7
|
+
|
|
8
|
+
The export takes 1C's horizon - `MIN_ACTIVE_ROWVERSION()` - before it reads a table, so a
|
|
9
|
+
delta read off the next export picks up everything this one may have missed.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import dump1c
|
|
15
|
+
|
|
16
|
+
PREFERRED_DRIVERS = (
|
|
17
|
+
"ODBC Driver 18 for SQL Server",
|
|
18
|
+
"ODBC Driver 17 for SQL Server",
|
|
19
|
+
"ODBC Driver 13 for SQL Server",
|
|
20
|
+
"SQL Server Native Client 11.0",
|
|
21
|
+
"SQL Server",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# A server infobase created without the offset option still has the table; one that has
|
|
25
|
+
# lost it is old enough to have been created with the offset.
|
|
26
|
+
DEFAULT_YEAR_OFFSET = 2000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def connect(server, db):
|
|
30
|
+
import pyodbc
|
|
31
|
+
available = set(pyodbc.drivers())
|
|
32
|
+
driver = next((d for d in PREFERRED_DRIVERS if d in available), None)
|
|
33
|
+
if driver is None:
|
|
34
|
+
raise RuntimeError("no SQL Server ODBC driver found: %r" % sorted(available))
|
|
35
|
+
parts = ["DRIVER={%s}" % driver, "SERVER=%s" % server, "DATABASE=%s" % db,
|
|
36
|
+
"TrustServerCertificate=yes"]
|
|
37
|
+
user, pwd = os.environ.get("DF1C_USER"), os.environ.get("DF1C_PASS")
|
|
38
|
+
if user:
|
|
39
|
+
parts += ["UID=%s" % user, "PWD=%s" % pwd]
|
|
40
|
+
else:
|
|
41
|
+
parts += ["Trusted_Connection=yes"]
|
|
42
|
+
sys.stderr.write("[i] %s / %s via %s\n" % (server, db, driver))
|
|
43
|
+
return pyodbc.connect(";".join(parts), autocommit=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MssqlSource(object):
|
|
47
|
+
kind = "mssql"
|
|
48
|
+
consistent = "per table: each is read after its horizon is taken"
|
|
49
|
+
|
|
50
|
+
def __init__(self, server, db):
|
|
51
|
+
self.server, self.db = server, db
|
|
52
|
+
self.conn = connect(server, db)
|
|
53
|
+
self.cur = self.conn.cursor()
|
|
54
|
+
self._partno = {}
|
|
55
|
+
self._offset = None
|
|
56
|
+
|
|
57
|
+
def describe(self):
|
|
58
|
+
return {"kind": self.kind, "server": self.server, "database": self.db}
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def year_offset(self):
|
|
62
|
+
if self._offset is None:
|
|
63
|
+
try:
|
|
64
|
+
self.cur.execute("SELECT Offset FROM _YearOffset")
|
|
65
|
+
row = self.cur.fetchone()
|
|
66
|
+
self._offset = int(row[0]) if row else DEFAULT_YEAR_OFFSET
|
|
67
|
+
except Exception:
|
|
68
|
+
sys.stderr.write("[!] no _YearOffset; assuming %d\n" % DEFAULT_YEAR_OFFSET)
|
|
69
|
+
self._offset = DEFAULT_YEAR_OFFSET
|
|
70
|
+
return self._offset
|
|
71
|
+
|
|
72
|
+
def has_partno(self, table):
|
|
73
|
+
"""8.3 splits large files across rows by PartNo; 8.2 stores one row per file."""
|
|
74
|
+
if table not in self._partno:
|
|
75
|
+
self.cur.execute("SELECT COUNT(*) FROM sys.columns WHERE object_id = OBJECT_ID(?)"
|
|
76
|
+
" AND name = 'PartNo'", table)
|
|
77
|
+
self._partno[table] = self.cur.fetchone()[0] > 0
|
|
78
|
+
return self._partno[table]
|
|
79
|
+
|
|
80
|
+
def files(self, table):
|
|
81
|
+
self.cur.execute("SELECT DISTINCT FileName FROM %s" % table)
|
|
82
|
+
return sorted(r[0] for r in self.cur.fetchall())
|
|
83
|
+
|
|
84
|
+
def read_file(self, table, name):
|
|
85
|
+
"""Concatenate a file's parts in order, then inflate."""
|
|
86
|
+
order = " ORDER BY PartNo" if self.has_partno(table) else ""
|
|
87
|
+
self.cur.execute("SELECT BinaryData FROM %s WHERE FileName = ?%s" % (table, order), name)
|
|
88
|
+
blob = b"".join(bytes(r[0]) for r in self.cur.fetchall() if r[0] is not None)
|
|
89
|
+
return dump1c.inflate(blob)
|
|
90
|
+
|
|
91
|
+
def tables(self):
|
|
92
|
+
self.cur.execute("""SELECT t.name, c.name, ty.name, c.max_length, c.precision, c.scale, c.is_nullable
|
|
93
|
+
FROM sys.tables t
|
|
94
|
+
JOIN sys.columns c ON c.object_id = t.object_id
|
|
95
|
+
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
|
|
96
|
+
ORDER BY t.name, c.column_id""")
|
|
97
|
+
tables = {}
|
|
98
|
+
for tn, cn, ty, ml, pr, sc, nullable in self.cur.fetchall():
|
|
99
|
+
tables.setdefault(tn, []).append({
|
|
100
|
+
"column": cn, "type": ty, "len": ml,
|
|
101
|
+
"precision": pr, "scale": sc, "nullable": bool(nullable),
|
|
102
|
+
})
|
|
103
|
+
self.cur.execute("""SELECT t.name, SUM(p.rows) FROM sys.tables t
|
|
104
|
+
JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
|
|
105
|
+
GROUP BY t.name""")
|
|
106
|
+
rows = dict(self.cur.fetchall())
|
|
107
|
+
return [(name, cols, rows.get(name)) for name, cols in sorted(tables.items())]
|
|
108
|
+
|
|
109
|
+
def rows(self, table, columns, chunk, where=None):
|
|
110
|
+
"""Batches of rows; `where` is a T-SQL condition on the table's own columns."""
|
|
111
|
+
cur = self.conn.cursor()
|
|
112
|
+
cur.execute("SELECT %s FROM [%s]%s" % (", ".join("[%s]" % c for c in columns), table,
|
|
113
|
+
" WHERE " + where if where else ""))
|
|
114
|
+
while True:
|
|
115
|
+
batch = cur.fetchmany(chunk)
|
|
116
|
+
if not batch:
|
|
117
|
+
break
|
|
118
|
+
yield [tuple(r) for r in batch]
|
|
119
|
+
|
|
120
|
+
def column(self, sql):
|
|
121
|
+
"""The first column of a query, every row."""
|
|
122
|
+
cur = self.conn.cursor()
|
|
123
|
+
cur.execute(sql)
|
|
124
|
+
out = []
|
|
125
|
+
while True:
|
|
126
|
+
batch = cur.fetchmany(100000)
|
|
127
|
+
if not batch:
|
|
128
|
+
return out
|
|
129
|
+
out.extend(r[0] for r in batch)
|
|
130
|
+
|
|
131
|
+
def triples(self, sql):
|
|
132
|
+
"""The first three columns of a query, every row."""
|
|
133
|
+
cur = self.conn.cursor()
|
|
134
|
+
cur.execute(sql)
|
|
135
|
+
return [(r[0], r[1], r[2]) for r in cur.fetchall()]
|
|
136
|
+
|
|
137
|
+
def horizon(self):
|
|
138
|
+
"""Below it every transaction had committed: a read after it misses nothing older."""
|
|
139
|
+
self.cur.execute("SELECT MIN_ACTIVE_ROWVERSION()")
|
|
140
|
+
return bytes(self.cur.fetchone()[0])
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------------- diagnostics
|
|
143
|
+
|
|
144
|
+
def databases(self):
|
|
145
|
+
"""Which databases does this login actually see? Separates a bad password from missing rights."""
|
|
146
|
+
self.cur.execute("SELECT name, state_desc, HAS_DBACCESS(name) FROM sys.databases ORDER BY name")
|
|
147
|
+
return self.cur.fetchall()
|
|
148
|
+
|
|
149
|
+
def probe(self):
|
|
150
|
+
self.cur.execute("SELECT @@VERSION")
|
|
151
|
+
lines = [self.cur.fetchone()[0].splitlines()[0]]
|
|
152
|
+
for table in ("Config", "ConfigSave", "Params", "DBSchema", "_YearOffset"):
|
|
153
|
+
try:
|
|
154
|
+
self.cur.execute("SELECT COUNT(*) FROM [%s]" % table)
|
|
155
|
+
lines.append(" %-12s rows=%s" % (table, self.cur.fetchone()[0]))
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
lines.append(" %-12s -- %s" % (table, str(exc).split("]")[-1].strip()[:70]))
|
|
158
|
+
return lines
|