@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
@@ -3,8 +3,8 @@
3
3
  """
4
4
  Copy the infobase's rows into a dForge tenant database.
5
5
 
6
- Straight from MS SQL into Postgres with `COPY`, no 1C software and no API: the
7
- column map (`out/columnmap.json`) says where every ported column comes from, and
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
- Source credentials come from DF1C_SERVER / DF1C_DB / DF1C_USER / DF1C_PASS (Windows
21
- auth when DF1C_USER is unset). The target from DF_PG_HOST / DF_PG_PORT / DF_PG_USER /
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 dump1c
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
- """Read `.env` into the environment without overriding what is already set."""
59
- path = os.path.join(os.path.dirname(attrs.OUT), ".env")
60
- if not os.path.isfile(path):
61
- return
62
- with codecs.open(path, "r", "utf-8") as f:
63
- for line in f:
64
- line = line.strip()
65
- if not line or line.startswith("#") or "=" not in line:
66
- continue
67
- key, value = line.split("=", 1)
68
- os.environ.setdefault(key.strip(), value.strip())
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 mssql_connect():
84
- return dump1c.connect(paths.required("DF1C_SERVER", "which MS SQL server holds it"),
85
- paths.required("DF1C_DB", "which MS SQL infobase to read"))
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
- out = {}
102
- for table in doc["tables"]:
103
- out[table["name"]] = {
104
- "rows": table.get("rows") or 0,
105
- "columns": dict((c["column"], c) for c in table["columns"]),
106
- }
107
- return out
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 - YEAR_OFFSET
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(mssql, spec):
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
- mssql.execute("SELECT _IDRRef, _EnumOrder FROM [%s]" % spec["table"])
285
- for raw, order in mssql.fetchall():
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, mssql):
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(mssql, spec) if spec else f
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 - YEAR_OFFSET
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 "[%s] AS src WHERE %s" % (job.source, restrict("src"))
748
- return "[%s]" % job.source
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` and needs no server; a
902
- declared query is the one thing only the server can count, so the connection is
903
- opened when one turns up and not before.
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(mssql_connect().cursor())
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 = mssql_connect()
978
- mssql, lookup = source_db.cursor(), source_db.cursor()
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("[%s]" % p[1] for p in job.pairs)
984
- # Taken BEFORE the read: everything 1C changes from here on has a _Version at or
985
- # above it, so the delta loader picks up exactly what this copy may have missed.
986
- horizon = delta.horizon(lookup)
987
- mssql.execute("SELECT %s FROM %s" % (select, reading(job)))
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 = mssql.fetchmany(CHUNK)
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
- delta.record(pg, args.module, job, horizon,
1002
- lower_only=not args.truncate or bool(os.environ.get("DF1C_FROM")))
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
- mssql = mssql_connect().cursor()
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
- mssql.execute("SELECT COUNT(*) FROM %s" % reading(job))
1040
- want = mssql.fetchone()[0]
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
- mssql.execute("SELECT SUM(CAST([%s] AS decimal(38,8))) FROM %s"
1055
- % (src, reading(job)))
1056
- want = mssql.fetchone()[0] or 0
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
- EMPTY_BY_TYPE = {
1100
- "binary": "[%s] IS NULL OR [%s] = 0x00000000000000000000000000000000",
1101
- "datetime": "[%s] IS NULL OR [%s] < '2001-01-02'",
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 MS SQL. That
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
- mssql = None if args.types else mssql_connect().cursor()
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 mssql is None or info[2] == "YES" or info[3] is not None:
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 = EMPTY_BY_TYPE.get(kind, "[%s] IS NULL")
1210
- predicate = predicate.replace("%s", src)
1211
- mssql.execute("SELECT COUNT(*), SUM(CASE WHEN %s THEN 1 ELSE 0 END)"
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 = mssql.fetchone()
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 mssql is None:
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 mssql is not None else []):
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("[%s]" % c for c in sources)
1232
- mssql.execute("SELECT COUNT(*) FROM (SELECT %s FROM %s"
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 = mssql.fetchone()[0]
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 mssql is None:
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"))