@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/dist/server.js +71 -3
  3. package/package.json +47 -48
  4. package/resources/docs/dsl-reference.md +1 -1
  5. package/resources/schemas/data-views.schema.json +50 -0
  6. package/skills/dforge-1c-import/SKILL.md +3 -2
  7. package/skills/dforge-1c-import/references/environment.md +2 -1
  8. package/skills/dforge-1c-import/references/loading.md +5 -2
  9. package/skills/dforge-1c-import/references/measurements.md +1 -1
  10. package/skills/dforge-1c-import/scripts/README.md +88 -7
  11. package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
  12. package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
  13. package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
  14. package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
  15. package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
  16. package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
  17. package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
  18. package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
  19. package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
  20. package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
  21. package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
  22. package/skills/dforge-1c-import/scripts/lib/attrs.py +2 -2
  23. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  24. package/skills/dforge-1c-import/scripts/lib/delta.py +109 -52
  25. package/skills/dforge-1c-import/scripts/lib/docusage.py +5 -4
  26. package/skills/dforge-1c-import/scripts/lib/dump1c.py +249 -92
  27. package/skills/dforge-1c-import/scripts/lib/external.py +4 -3
  28. package/skills/dforge-1c-import/scripts/lib/meta.py +12 -2
  29. package/skills/dforge-1c-import/scripts/lib/migrate.py +93 -74
  30. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  31. package/skills/dforge-1c-import/scripts/lib/paths.py +21 -0
  32. package/skills/dforge-1c-import/scripts/lib/peek.py +3 -5
  33. package/skills/dforge-1c-import/scripts/lib/recorded.py +11 -11
  34. package/skills/dforge-1c-import/scripts/lib/recorders.py +9 -12
  35. package/skills/dforge-1c-import/scripts/lib/seedstate.py +21 -16
  36. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  37. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  38. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  39. package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
  40. package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
  41. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  42. package/skills/dforge-1c-import/scripts/project-template/sources.py +10 -0
  43. package/skills/dforge-mcp-author/references/action-dsl.md +49 -46
  44. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  45. package/skills/dforge-mcp-author/references/formulas.md +38 -6
  46. package/skills/dforge-mcp-author/references/jobs.md +1 -1
  47. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  48. package/skills/dforge-mcp-author/references/queries.md +17 -2
  49. package/skills/dforge-mcp-author/references/registers.md +3 -0
  50. package/skills/dforge-mcp-author/references/reports.md +1 -1
  51. package/skills/dforge-mcp-author/references/security.md +2 -0
  52. 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 dump1c
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 = dump1c.connect(paths.required("DF1C_SERVER", "which MS SQL server holds it"),
56
- paths.required("DF1C_DB",
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-only, and `NOLOCK`: a count does not need to wait for anyone.
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(cur, table):
69
- cur.execute("SELECT name FROM sys.columns WHERE object_id = OBJECT_ID(?)", "dbo." + table)
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 TOP (20) _RecorderRRef FROM dbo.[%s] WITH (NOLOCK)" % table)
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 dbo.[%s] WHERE _IDRRef IN (%s)" % (physical, marks),
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.mssql_connect().cursor()
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(cur, table)
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, COUNT_BIG(*) FROM dbo.[%s] WITH (NOLOCK)"
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 COUNT_BIG(*) FROM dbo.[%s] WITH (NOLOCK)" % table)
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 dump1c
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 TOP (20) _RecorderRRef FROM [%s]" % table)
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 [%s] WHERE _IDRRef IN (%s)"
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 = dump1c.connect(paths.required("DF1C_SERVER", "which MS SQL server holds it"),
62
- paths.required("DF1C_DB",
63
- "which MS SQL infobase to read")).cursor()
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 [%s] GROUP BY _RecorderTRef "
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(mssql, pg, reg, mapping, source, period=None):
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
- mssql.execute("SELECT MAX(_Period) FROM [%s]" % table)
203
- period = mssql.fetchone()[0]
204
- mssql.execute("SELECT %s FROM [%s] WHERE _Period = ?"
205
- % (", ".join("[%s]" % c for c in columns), table), period)
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 mssql.fetchall():
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, mssql, month=None):
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 + migrate.YEAR_OFFSET, until.month, 1)
269
- table, period, theirs = one_c_balance(mssql, pg, reg, mapping, source, period)
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(mssql, pg, reg, mapping, source)
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 + migrate.YEAR_OFFSET, start.month, 1))
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, mssql, size, months):
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(mssql, pg, reg, mapping, source, period)[2]
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
- mssql = migrate.mssql_connect().cursor()
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, mssql, args.sample,
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, mssql, args.asof) and clean
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