@dforge-core/dforge-mcp 0.2.45 → 0.2.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +2 -1
- package/dist/server.js +71 -3
- package/package.json +3 -3
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +50 -0
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +375 -0
- package/skills/dforge-1c-import/references/environment.md +98 -0
- package/skills/dforge-1c-import/references/loading.md +164 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +164 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- 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/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
- package/skills/dforge-mcp-author/references/data-views.md +38 -2
- package/skills/dforge-mcp-author/references/formulas.md +32 -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 +1 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
|
@@ -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
|