@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,273 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
u"""What shapes the movements are written in, weighted by the rows 1C holds for each.
|
|
4
|
+
|
|
5
|
+
The generator that turns a document's posting into declarative bindings will be written
|
|
6
|
+
one idiom at a time, and it is worth knowing before writing any of it how far each idiom
|
|
7
|
+
goes. So every movement 1C recorded - a (document, register) pair, with its row count from
|
|
8
|
+
`recorded.json` - is traced to the write sites the call graph finds behind it
|
|
9
|
+
(`callgraph.py`), and each site is classified by its shape:
|
|
10
|
+
|
|
11
|
+
row `Запись = Набор.Добавить()` and the columns assigned one by one
|
|
12
|
+
load `Набор.Загрузить(Таблица)` - a whole table at once
|
|
13
|
+
module `Набор.мТаблицаДвижений = Таблица; Набор.ВыполнитьПриход()` - the register's
|
|
14
|
+
own record-set module does the writing from a table the document prepared
|
|
15
|
+
method another method of the register's own record-set module
|
|
16
|
+
write only `Набор.Записать()` here; the set was filled some other way
|
|
17
|
+
|
|
18
|
+
and, for `load` and `module`, by where the table comes from:
|
|
19
|
+
|
|
20
|
+
query the result of a query (`Запрос.Выполнить().Выгрузить()`)
|
|
21
|
+
section a tabular section's `Выгрузить()` - the document's own lines
|
|
22
|
+
copy `Скопировать()` of another table
|
|
23
|
+
built `Новый ТаблицаЗначений`, filled by the code
|
|
24
|
+
param handed in by a caller
|
|
25
|
+
function returned by a call this does not follow
|
|
26
|
+
|
|
27
|
+
A pair written through sites of more than one shape is `mixed`. Its rows are counted once,
|
|
28
|
+
under `mixed`, so the percentages add up.
|
|
29
|
+
|
|
30
|
+
python tools/1c.py idioms # the table; writes out/idioms.json
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import print_function
|
|
33
|
+
|
|
34
|
+
import codecs
|
|
35
|
+
import collections
|
|
36
|
+
import io
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
|
|
41
|
+
import bsl
|
|
42
|
+
import callgraph
|
|
43
|
+
import paths
|
|
44
|
+
|
|
45
|
+
TARGET = os.path.join(paths.OUT, "idioms.json")
|
|
46
|
+
|
|
47
|
+
ROW_OPS = frozenset(bsl.norm(x) for x in (u"Добавить", u"ДобавитьПриход", u"ДобавитьРасход",
|
|
48
|
+
u"ДобавитьДвижение", u"Add"))
|
|
49
|
+
LOAD_OPS = frozenset(bsl.norm(x) for x in (u"Загрузить", u"Load"))
|
|
50
|
+
MODULE_OPS = frozenset(bsl.norm(x) for x in (u"ВыполнитьПриход", u"ВыполнитьРасход",
|
|
51
|
+
u"ВыполнитьДвижения", u"ВыполнитьДвижениеПриход",
|
|
52
|
+
u"ВыполнитьДвижениеРасход"))
|
|
53
|
+
WRITE_ONLY = frozenset(bsl.norm(x) for x in (u"Записать", u"Write"))
|
|
54
|
+
|
|
55
|
+
UNLOAD = bsl.norm(u"Выгрузить")
|
|
56
|
+
COPY = bsl.norm(u"Скопировать")
|
|
57
|
+
EXECUTE = bsl.norm(u"Выполнить")
|
|
58
|
+
FILL = bsl.norm(u"ЗаполнитьЗначения")
|
|
59
|
+
COLLAPSE = bsl.norm(u"Свернуть")
|
|
60
|
+
MOVEMENT_TABLE = bsl.norm(u"мТаблицаДвижений")
|
|
61
|
+
COLUMNS = bsl.norm(u"ВыгрузитьКолонки")
|
|
62
|
+
LOAD_BY_NAME = bsl.norm(u"ЗагрузитьВТаблицуЗначений")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def shape(op):
|
|
66
|
+
if op in ROW_OPS:
|
|
67
|
+
return "row"
|
|
68
|
+
if op in LOAD_OPS:
|
|
69
|
+
return "load"
|
|
70
|
+
if op in MODULE_OPS:
|
|
71
|
+
return "module"
|
|
72
|
+
if op in WRITE_ONLY:
|
|
73
|
+
return "write"
|
|
74
|
+
return "method"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Locator(object):
|
|
78
|
+
"""Finds the method around a (module path, line) and reads what feeds a table."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, modules):
|
|
81
|
+
self.by_path = dict((m.path, m) for m in modules)
|
|
82
|
+
|
|
83
|
+
def method_at(self, path, line):
|
|
84
|
+
module = self.by_path.get(path)
|
|
85
|
+
if module is None:
|
|
86
|
+
return None, None
|
|
87
|
+
for method in module.methods.values():
|
|
88
|
+
if method[6] <= line <= method[7]:
|
|
89
|
+
return module, method
|
|
90
|
+
return module, None
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def call_at(method, line, op):
|
|
94
|
+
for st in bsl.statements(method[5]):
|
|
95
|
+
if st[1] != line:
|
|
96
|
+
continue
|
|
97
|
+
for expr in bsl.expressions(st):
|
|
98
|
+
for sub in bsl.subexpressions(expr):
|
|
99
|
+
if sub[0] == "callx" and sub[1][0] == "member" and bsl.norm(sub[1][2]) == op:
|
|
100
|
+
return sub
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def origin(method, expr, depth=0):
|
|
105
|
+
"""Where a table expression comes from, read off the method's assignments."""
|
|
106
|
+
if expr is None or depth > 3:
|
|
107
|
+
return "function"
|
|
108
|
+
if expr[0] == "callx":
|
|
109
|
+
callee = expr[1]
|
|
110
|
+
if callee[0] == "member":
|
|
111
|
+
op = bsl.norm(callee[2])
|
|
112
|
+
if op == UNLOAD:
|
|
113
|
+
receiver = callee[1]
|
|
114
|
+
inner = [s for s in bsl.subexpressions(receiver) if s[0] == "callx"
|
|
115
|
+
and s[1][0] == "member" and bsl.norm(s[1][2]) == EXECUTE]
|
|
116
|
+
if inner:
|
|
117
|
+
return "query"
|
|
118
|
+
parts = bsl.dotted(receiver)
|
|
119
|
+
if parts and any(u"запрос" in bsl.norm(p) or u"результат" in bsl.norm(p)
|
|
120
|
+
or u"выборка" in bsl.norm(p) for p in parts):
|
|
121
|
+
return "query"
|
|
122
|
+
return "section"
|
|
123
|
+
if op == COPY:
|
|
124
|
+
return "copy"
|
|
125
|
+
if op == COLUMNS:
|
|
126
|
+
return "columns"
|
|
127
|
+
return "function"
|
|
128
|
+
if expr[0] == "new":
|
|
129
|
+
return "built"
|
|
130
|
+
if expr[0] == "name":
|
|
131
|
+
name = bsl.norm(expr[1])
|
|
132
|
+
if name in set(bsl.norm(p[0]) for p in method[3]):
|
|
133
|
+
return "param"
|
|
134
|
+
sources = [st[3] for st in bsl.statements(method[5])
|
|
135
|
+
if st[0] == "assign" and st[2][0] == "name" and bsl.norm(st[2][1]) == name]
|
|
136
|
+
if not sources:
|
|
137
|
+
return "function"
|
|
138
|
+
kinds = set(Locator.origin(method, s, depth + 1) for s in sources)
|
|
139
|
+
if "columns" in kinds:
|
|
140
|
+
# УТП's own step: an empty table of the register's columns, filled by
|
|
141
|
+
# column name from another table - which is the real source
|
|
142
|
+
kinds.discard("columns")
|
|
143
|
+
for st in bsl.statements(method[5]):
|
|
144
|
+
for e in bsl.expressions(st):
|
|
145
|
+
for sub in bsl.subexpressions(e):
|
|
146
|
+
if sub[0] == "callx" and len(sub[2]) >= 2 and sub[2][1] is not None \
|
|
147
|
+
and sub[2][1][0] == "name" and bsl.norm(sub[2][1][1]) == name:
|
|
148
|
+
callee = sub[1]
|
|
149
|
+
called = bsl.norm(callee[2] if callee[0] == "member"
|
|
150
|
+
else callee[1] if callee[0] == "name" else u"")
|
|
151
|
+
if called == LOAD_BY_NAME:
|
|
152
|
+
kinds.add("columns<-" + Locator.origin(method, sub[2][0],
|
|
153
|
+
depth + 1))
|
|
154
|
+
if not kinds:
|
|
155
|
+
kinds.add("columns")
|
|
156
|
+
return kinds.pop() if len(kinds) == 1 else "+".join(sorted(kinds))
|
|
157
|
+
return "function"
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def treated(method, expr):
|
|
161
|
+
"""Does the method fill a column from the header, or collapse, on this table?"""
|
|
162
|
+
if expr is None or expr[0] != "name":
|
|
163
|
+
return set()
|
|
164
|
+
name = bsl.norm(expr[1])
|
|
165
|
+
found = set()
|
|
166
|
+
for st in bsl.statements(method[5]):
|
|
167
|
+
for e in bsl.expressions(st):
|
|
168
|
+
for sub in bsl.subexpressions(e):
|
|
169
|
+
if sub[0] == "callx" and sub[1][0] == "member" and sub[1][1] == ("name", expr[1]) \
|
|
170
|
+
or (sub[0] == "callx" and sub[1][0] == "member"
|
|
171
|
+
and sub[1][1][0] == "name" and bsl.norm(sub[1][1][1]) == name):
|
|
172
|
+
op = bsl.norm(sub[1][2])
|
|
173
|
+
if op == FILL:
|
|
174
|
+
found.add("header-fill")
|
|
175
|
+
elif op == COLLAPSE:
|
|
176
|
+
found.add("collapse")
|
|
177
|
+
return found
|
|
178
|
+
|
|
179
|
+
def table_of(self, method, line, op):
|
|
180
|
+
"""The table a `load` or `module` site writes from."""
|
|
181
|
+
if op in LOAD_OPS:
|
|
182
|
+
call = self.call_at(method, line, op)
|
|
183
|
+
return call[2][0] if call is not None and call[2] else None
|
|
184
|
+
# module: the table was handed over as `<set>.мТаблицаДвижений = <table>` - the
|
|
185
|
+
# last such hand-over before the site; a method can write several sets in turn
|
|
186
|
+
found = None
|
|
187
|
+
for st in bsl.statements(method[5]):
|
|
188
|
+
if st[0] == "assign" and st[2][0] == "member" \
|
|
189
|
+
and bsl.norm(st[2][2]) == MOVEMENT_TABLE and st[1] <= line:
|
|
190
|
+
if found is None or st[1] >= found[1]:
|
|
191
|
+
found = st
|
|
192
|
+
return found[3] if found is not None else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def classify(graph, locator, sites):
|
|
196
|
+
"""(shape label, detail counter) for one movement's write sites."""
|
|
197
|
+
shapes = set()
|
|
198
|
+
details = collections.Counter()
|
|
199
|
+
for path, line, op in sites:
|
|
200
|
+
kind = shape(op)
|
|
201
|
+
shapes.add(kind)
|
|
202
|
+
if kind in ("load", "module"):
|
|
203
|
+
_, method = locator.method_at(path, line)
|
|
204
|
+
if method is None:
|
|
205
|
+
details[kind + ":unknown"] += 1
|
|
206
|
+
continue
|
|
207
|
+
table = locator.table_of(method, line, op)
|
|
208
|
+
details[kind + ":" + locator.origin(method, table)] += 1
|
|
209
|
+
for treatment in locator.treated(method, table):
|
|
210
|
+
details[kind + "+" + treatment] += 1
|
|
211
|
+
real = shapes - {"write"} or shapes
|
|
212
|
+
label = real.pop() if len(real) == 1 else "mixed"
|
|
213
|
+
return label, details
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main():
|
|
217
|
+
out = codecs.getwriter("utf-8")(sys.stdout.buffer)
|
|
218
|
+
modules, _ = callgraph.load()
|
|
219
|
+
graph = callgraph.Graph(modules, callgraph.register_names())
|
|
220
|
+
graph.run()
|
|
221
|
+
documents = graph.document_writes(callgraph.subscriptions(modules),
|
|
222
|
+
callgraph.declared_movements())
|
|
223
|
+
with io.open(os.path.join(paths.OUT, "recorded.json"), encoding="utf-8") as f:
|
|
224
|
+
recorded = json.load(f)["documents"]
|
|
225
|
+
locator = Locator(modules)
|
|
226
|
+
|
|
227
|
+
by_shape = collections.defaultdict(lambda: [0, 0]) # label -> [pairs, rows]
|
|
228
|
+
origins = collections.Counter() # detail -> rows
|
|
229
|
+
pairs = []
|
|
230
|
+
unreached = [0, 0]
|
|
231
|
+
for document, registers in sorted(recorded.items()):
|
|
232
|
+
for register, rows in sorted(registers.items()):
|
|
233
|
+
key = bsl.norm(register)
|
|
234
|
+
sites = set()
|
|
235
|
+
for event in ("posting", "on-write", "before-write"):
|
|
236
|
+
sites |= graph.document_sites.get((document, event, key), set())
|
|
237
|
+
if not sites:
|
|
238
|
+
unreached[0] += 1
|
|
239
|
+
unreached[1] += rows
|
|
240
|
+
pairs.append({"document": document, "register": register, "rows": rows,
|
|
241
|
+
"shape": "not reached"})
|
|
242
|
+
continue
|
|
243
|
+
label, details = classify(graph, locator, sites)
|
|
244
|
+
by_shape[label][0] += 1
|
|
245
|
+
by_shape[label][1] += rows
|
|
246
|
+
for detail, n in details.items():
|
|
247
|
+
origins[detail] += rows
|
|
248
|
+
pairs.append({"document": document, "register": register, "rows": rows,
|
|
249
|
+
"shape": label, "sites": len(sites),
|
|
250
|
+
"details": dict(details)})
|
|
251
|
+
total = sum(v[1] for v in by_shape.values()) + unreached[1]
|
|
252
|
+
|
|
253
|
+
with codecs.open(TARGET, "w", "utf-8") as f:
|
|
254
|
+
json.dump({"by_shape": by_shape, "origins": origins, "pairs": pairs}, f,
|
|
255
|
+
ensure_ascii=False, indent="\t")
|
|
256
|
+
|
|
257
|
+
out.write(u"%d movements (document, register) holding %s rows\n\n"
|
|
258
|
+
% (len(pairs), "{:,}".format(total)))
|
|
259
|
+
out.write(u"%-12s %6s %14s %6s\n" % (u"shape", u"pairs", u"rows", u"share"))
|
|
260
|
+
for label, (n, rows) in sorted(by_shape.items(), key=lambda kv: -kv[1][1]):
|
|
261
|
+
out.write(u"%-12s %6d %14s %5.1f%%\n" % (label, n, "{:,}".format(rows),
|
|
262
|
+
100.0 * rows / total))
|
|
263
|
+
out.write(u"%-12s %6d %14s %5.1f%%\n\n" % (u"not reached", unreached[0],
|
|
264
|
+
"{:,}".format(unreached[1]),
|
|
265
|
+
100.0 * unreached[1] / total))
|
|
266
|
+
out.write(u"table origins and treatments, by rows of the movements they feed:\n")
|
|
267
|
+
for detail, rows in origins.most_common():
|
|
268
|
+
out.write(u" %-28s %14s\n" % (detail, "{:,}".format(rows)))
|
|
269
|
+
out.write(u"\nwritten %s\n" % TARGET)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
main()
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Ask the database what the metadata cannot answer.
|
|
5
|
+
|
|
6
|
+
`migrate.py verify` reads the column map and `"dForge".entity_column`, so it can only
|
|
7
|
+
check a reference the metadata knows about - and it will agree with the metadata even
|
|
8
|
+
when the table disagrees with both. Four things it cannot see:
|
|
9
|
+
|
|
10
|
+
* a foreign key whose rows point at nothing. The loader runs with
|
|
11
|
+
`session_replication_role = 'replica'`, so Postgres checks not one of them on the
|
|
12
|
+
way in; this asks afterwards.
|
|
13
|
+
* a foreign key that points somewhere the metadata no longer does. A module upgrade
|
|
14
|
+
moves the reference column's `ref_entity_id` and leaves the constraint alone, so the
|
|
15
|
+
card offers one catalog and the table insists on another. That is how
|
|
16
|
+
`FK_OrganizationId` went on naming `masterdata.party` for 88,126 `shipping_order`
|
|
17
|
+
rows after the spec had re-pointed the column at `masterdata.organization`.
|
|
18
|
+
* a reference with no foreign key behind it. An extension column is exactly
|
|
19
|
+
that: the platform builds the 1:1 side table with a key back to the host and
|
|
20
|
+
leaves the reference itself unconstrained, so the first check cannot see it.
|
|
21
|
+
* a physical column the metadata no longer accounts for.
|
|
22
|
+
|
|
23
|
+
python tools/integrity.py # all four, every module schema
|
|
24
|
+
python tools/integrity.py --schema shipping
|
|
25
|
+
python tools/integrity.py --retarget # recreate the keys that disagree
|
|
26
|
+
|
|
27
|
+
`--retarget` only touches a key no row would break: if anything still dangles against
|
|
28
|
+
the target the metadata names, the constraint stays and the row is reported instead.
|
|
29
|
+
|
|
30
|
+
Connection details come from the same place as `migrate.py` - DF_PG_* and DF_TENANT.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import print_function
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
import migrate
|
|
38
|
+
|
|
39
|
+
# Their own tables pair columns by names that coincide, so the reference check reads
|
|
40
|
+
# them as retargeted when they are nothing of the kind.
|
|
41
|
+
PLATFORM = ("dForge", "audit", "public", "information_schema", "pg_catalog")
|
|
42
|
+
|
|
43
|
+
# R, S and F are virtual: the column they name is not a column of the table.
|
|
44
|
+
VIRTUAL = ("R", "S", "F")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def module_schemas(cur, only=None):
|
|
48
|
+
cur.execute("""SELECT DISTINCT e.schema_name FROM "dForge".entity e
|
|
49
|
+
WHERE e.schema_name <> ALL(%s) ORDER BY 1""", (list(PLATFORM),))
|
|
50
|
+
found = [row[0] for row in cur.fetchall()]
|
|
51
|
+
return [s for s in found if not only or s == only]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def foreign_keys(cur, schemas):
|
|
55
|
+
"""Every single-column foreign key out of these schemas, with what it points at."""
|
|
56
|
+
cur.execute("""
|
|
57
|
+
SELECT con.conname, src_ns.nspname, src.relname, src_col.attname,
|
|
58
|
+
tgt_ns.nspname, tgt.relname, tgt_col.attname
|
|
59
|
+
FROM pg_constraint con
|
|
60
|
+
JOIN pg_class src ON src.oid = con.conrelid
|
|
61
|
+
JOIN pg_namespace src_ns ON src_ns.oid = src.relnamespace
|
|
62
|
+
JOIN pg_class tgt ON tgt.oid = con.confrelid
|
|
63
|
+
JOIN pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
|
|
64
|
+
JOIN pg_attribute src_col ON src_col.attrelid = con.conrelid
|
|
65
|
+
AND src_col.attnum = con.conkey[1]
|
|
66
|
+
JOIN pg_attribute tgt_col ON tgt_col.attrelid = con.confrelid
|
|
67
|
+
AND tgt_col.attnum = con.confkey[1]
|
|
68
|
+
WHERE con.contype = 'f' AND array_length(con.conkey, 1) = 1
|
|
69
|
+
AND src_ns.nspname = ANY(%s)
|
|
70
|
+
ORDER BY src_ns.nspname, src.relname, src_col.attname
|
|
71
|
+
""", (list(schemas),))
|
|
72
|
+
return cur.fetchall()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dangling(cur, schema, table, column, target_schema, target_table, target_column):
|
|
76
|
+
cur.execute('SELECT count(*) FROM "%s"."%s" s WHERE s."%s" IS NOT NULL'
|
|
77
|
+
' AND NOT EXISTS (SELECT 1 FROM "%s"."%s" t WHERE t."%s" = s."%s")'
|
|
78
|
+
% (schema, table, column,
|
|
79
|
+
target_schema, target_table, target_column, column))
|
|
80
|
+
return cur.fetchone()[0]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_rows(cur, schemas):
|
|
84
|
+
"""Rows whose foreign key points at a row that is not there."""
|
|
85
|
+
print("\nforeign keys whose rows point at nothing")
|
|
86
|
+
print("-" * 78)
|
|
87
|
+
broken = 0
|
|
88
|
+
for name, sn, st, sc, tn, tt, tc in foreign_keys(cur, schemas):
|
|
89
|
+
count = dangling(cur, sn, st, sc, tn, tt, tc)
|
|
90
|
+
if count:
|
|
91
|
+
broken += 1
|
|
92
|
+
print("%-40s %-26s %8d" % ("%s.%s.%s" % (sn, st, sc), name, count))
|
|
93
|
+
print("-" * 78)
|
|
94
|
+
print("%d foreign key(s) with rows pointing at nothing" % broken)
|
|
95
|
+
return broken
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_targets(cur, schemas, retarget=False):
|
|
99
|
+
"""Foreign keys that point somewhere the metadata no longer does."""
|
|
100
|
+
cur.execute("""
|
|
101
|
+
SELECT con.conname, src_ns.nspname, src.relname, src_col.attname,
|
|
102
|
+
tgt_ns.nspname, tgt.relname,
|
|
103
|
+
ref.schema_name, lower(ref.db_object), lower(ec.other_key)
|
|
104
|
+
FROM pg_constraint con
|
|
105
|
+
JOIN pg_class src ON src.oid = con.conrelid
|
|
106
|
+
JOIN pg_namespace src_ns ON src_ns.oid = src.relnamespace
|
|
107
|
+
JOIN pg_class tgt ON tgt.oid = con.confrelid
|
|
108
|
+
JOIN pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace
|
|
109
|
+
JOIN pg_attribute src_col ON src_col.attrelid = con.conrelid
|
|
110
|
+
AND src_col.attnum = con.conkey[1]
|
|
111
|
+
JOIN "dForge".entity e ON e.schema_name = src_ns.nspname
|
|
112
|
+
AND lower(e.db_object) = src.relname
|
|
113
|
+
JOIN "dForge".entity_column ec ON ec.entity_id = e.entity_id
|
|
114
|
+
AND ec.column_type = 'R'
|
|
115
|
+
AND lower(ec.this_key) = src_col.attname
|
|
116
|
+
JOIN "dForge".entity ref ON ref.entity_id = ec.ref_entity_id
|
|
117
|
+
WHERE con.contype = 'f' AND array_length(con.conkey, 1) = 1
|
|
118
|
+
AND src_ns.nspname = ANY(%s)
|
|
119
|
+
AND (lower(ref.db_object) IS DISTINCT FROM tgt.relname
|
|
120
|
+
OR ref.schema_name IS DISTINCT FROM tgt_ns.nspname)
|
|
121
|
+
ORDER BY src_ns.nspname, src.relname, src_col.attname
|
|
122
|
+
""", (list(schemas),))
|
|
123
|
+
rows = cur.fetchall()
|
|
124
|
+
|
|
125
|
+
print("\nforeign keys that disagree with the metadata")
|
|
126
|
+
print("-" * 78)
|
|
127
|
+
for name, sn, st, sc, fkn, fkt, rn, rt, rpk in rows:
|
|
128
|
+
count = dangling(cur, sn, st, sc, rn, rt, rpk)
|
|
129
|
+
print("%-34s %-22s %s.%s -> %s.%s" % ("%s.%s" % (st, sc), name, fkn, fkt, rn, rt))
|
|
130
|
+
if count:
|
|
131
|
+
print(" %d row(s) dangle against the target the metadata names - left alone"
|
|
132
|
+
% count)
|
|
133
|
+
continue
|
|
134
|
+
if retarget:
|
|
135
|
+
cur.execute('ALTER TABLE "%s"."%s" DROP CONSTRAINT "%s"' % (sn, st, name))
|
|
136
|
+
cur.execute('ALTER TABLE "%s"."%s" ADD CONSTRAINT "%s" FOREIGN KEY ("%s")'
|
|
137
|
+
' REFERENCES "%s"."%s" ("%s")'
|
|
138
|
+
% (sn, st, name, sc, rn, rt, rpk))
|
|
139
|
+
print(" recreated")
|
|
140
|
+
print("-" * 78)
|
|
141
|
+
print("%d foreign key(s) pointing where the metadata does not" % len(rows))
|
|
142
|
+
return len(rows)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def check_unbacked(cur, schemas):
|
|
146
|
+
"""Reference columns the database is not enforcing.
|
|
147
|
+
|
|
148
|
+
`check_rows` walks `pg_constraint`, so a reference with no foreign key behind it is
|
|
149
|
+
invisible to it - and an extension column is exactly that: the platform builds the
|
|
150
|
+
1:1 side table with a key back to the host and leaves the reference itself
|
|
151
|
+
unconstrained. This asks the metadata instead of the catalogue, and counts.
|
|
152
|
+
"""
|
|
153
|
+
cur.execute("""
|
|
154
|
+
SELECT e.schema_name, coalesce(held.storage_table, e.db_object), ec.this_key,
|
|
155
|
+
ref.schema_name, lower(ref.db_object), lower(ec.other_key)
|
|
156
|
+
FROM "dForge".entity_column ec
|
|
157
|
+
JOIN "dForge".entity e ON e.entity_id = ec.entity_id
|
|
158
|
+
JOIN "dForge".entity ref ON ref.entity_id = ec.ref_entity_id
|
|
159
|
+
-- The physical column is the one named by `this_key`, and it is that row -
|
|
160
|
+
-- not the reference beside it - that says which table actually holds it.
|
|
161
|
+
LEFT JOIN "dForge".entity_column held ON held.entity_id = ec.entity_id
|
|
162
|
+
AND held.column_cd = ec.this_key
|
|
163
|
+
WHERE ec.column_type = 'R' AND ec.this_key IS NOT NULL
|
|
164
|
+
AND ec.other_key IS NOT NULL
|
|
165
|
+
AND e.schema_name = ANY(%s)
|
|
166
|
+
ORDER BY 1, 2, 3
|
|
167
|
+
""", (list(schemas),))
|
|
168
|
+
|
|
169
|
+
print("\nreferences with no foreign key behind them")
|
|
170
|
+
print("-" * 78)
|
|
171
|
+
loose = 0
|
|
172
|
+
for schema, storage, column, ref_schema, ref_table, ref_key in cur.fetchall():
|
|
173
|
+
if "." in (storage or ""):
|
|
174
|
+
schema, storage = [p.strip().strip('"') for p in storage.split(".")]
|
|
175
|
+
storage = storage.lower()
|
|
176
|
+
cur.execute("""SELECT 1 FROM pg_constraint con
|
|
177
|
+
JOIN pg_class src ON src.oid = con.conrelid
|
|
178
|
+
JOIN pg_namespace ns ON ns.oid = src.relnamespace
|
|
179
|
+
JOIN pg_attribute a ON a.attrelid = con.conrelid
|
|
180
|
+
AND a.attnum = con.conkey[1]
|
|
181
|
+
WHERE con.contype = 'f' AND ns.nspname = %s
|
|
182
|
+
AND src.relname = %s AND a.attname = %s""",
|
|
183
|
+
(schema, storage, column.lower()))
|
|
184
|
+
if cur.fetchone():
|
|
185
|
+
continue
|
|
186
|
+
cur.execute("""SELECT 1 FROM information_schema.columns
|
|
187
|
+
WHERE table_schema = %s AND table_name = %s
|
|
188
|
+
AND column_name = %s""", (schema, storage, column.lower()))
|
|
189
|
+
if not cur.fetchone():
|
|
190
|
+
continue
|
|
191
|
+
loose += 1
|
|
192
|
+
cur.execute('SELECT count(*) FROM "%s"."%s" s WHERE s."%s" IS NOT NULL'
|
|
193
|
+
' AND NOT EXISTS (SELECT 1 FROM "%s"."%s" t WHERE t."%s" = s."%s")'
|
|
194
|
+
% (schema, storage, column, ref_schema, ref_table, ref_key, column))
|
|
195
|
+
count = cur.fetchone()[0]
|
|
196
|
+
print("%-46s -> %-22s %s"
|
|
197
|
+
% ("%s.%s.%s" % (schema, storage, column),
|
|
198
|
+
"%s.%s" % (ref_schema, ref_table),
|
|
199
|
+
"%d dangling" % count if count else "ok"))
|
|
200
|
+
print("-" * 78)
|
|
201
|
+
print("%d reference(s) the database does not enforce" % loose)
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check_columns(cur, schemas):
|
|
206
|
+
"""Physical columns the metadata no longer accounts for."""
|
|
207
|
+
cur.execute("""
|
|
208
|
+
SELECT c.table_schema, c.table_name, c.column_name, c.data_type
|
|
209
|
+
FROM information_schema.columns c
|
|
210
|
+
JOIN "dForge".entity e ON e.schema_name = c.table_schema
|
|
211
|
+
AND lower(e.db_object) = c.table_name
|
|
212
|
+
WHERE c.table_schema = ANY(%s)
|
|
213
|
+
AND NOT EXISTS (
|
|
214
|
+
SELECT 1 FROM "dForge".entity_column ec
|
|
215
|
+
WHERE ec.entity_id = e.entity_id AND lower(ec.column_cd) = c.column_name
|
|
216
|
+
AND coalesce(ec.column_type, ' ') <> ALL(%s))
|
|
217
|
+
AND NOT EXISTS (
|
|
218
|
+
SELECT 1 FROM "dForge".entity_column ec
|
|
219
|
+
WHERE lower(coalesce(ec.storage_table, '')) LIKE '%%"' || c.table_name || '"'
|
|
220
|
+
AND lower(ec.column_cd) = c.column_name)
|
|
221
|
+
ORDER BY 1, 2, 3
|
|
222
|
+
""", (list(schemas), list(VIRTUAL)))
|
|
223
|
+
rows = cur.fetchall()
|
|
224
|
+
|
|
225
|
+
print("\ncolumns the metadata no longer accounts for")
|
|
226
|
+
print("-" * 78)
|
|
227
|
+
for schema, table, column, kind in rows:
|
|
228
|
+
cur.execute('SELECT count(*) FROM "%s"."%s"' % (schema, table))
|
|
229
|
+
held = cur.fetchone()[0]
|
|
230
|
+
print("%-40s %-22s %-10s %s"
|
|
231
|
+
% ("%s.%s" % (schema, table), column, kind,
|
|
232
|
+
"empty" if not held else "holds %d row(s)" % held))
|
|
233
|
+
print("-" * 78)
|
|
234
|
+
print("%d column(s) left without metadata" % len(rows))
|
|
235
|
+
return len(rows)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main():
|
|
239
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
240
|
+
parser.add_argument("--schema", help="one module schema instead of all of them")
|
|
241
|
+
parser.add_argument("--retarget", action="store_true",
|
|
242
|
+
help="recreate the foreign keys that disagree with the metadata")
|
|
243
|
+
args = parser.parse_args()
|
|
244
|
+
|
|
245
|
+
migrate.load_env()
|
|
246
|
+
pg = migrate.pg_connect()
|
|
247
|
+
cur = pg.cursor()
|
|
248
|
+
schemas = module_schemas(cur, args.schema)
|
|
249
|
+
if not schemas:
|
|
250
|
+
print("no module schema to look at")
|
|
251
|
+
return 1
|
|
252
|
+
print("%d schema(s): %s" % (len(schemas), ", ".join(schemas)))
|
|
253
|
+
|
|
254
|
+
problems = check_rows(cur, schemas)
|
|
255
|
+
problems += check_targets(cur, schemas, args.retarget)
|
|
256
|
+
problems += check_unbacked(cur, schemas)
|
|
257
|
+
problems += check_columns(cur, schemas)
|
|
258
|
+
if args.retarget:
|
|
259
|
+
pg.commit()
|
|
260
|
+
return 1 if problems else 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|