@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -1
  3. package/dist/server.js +71 -3
  4. package/package.json +3 -3
  5. package/resources/docs/dsl-reference.md +1 -1
  6. package/resources/schemas/data-views.schema.json +50 -0
  7. package/scripts/install-skills.mjs +1 -0
  8. package/skills/dforge-1c-import/SKILL.md +375 -0
  9. package/skills/dforge-1c-import/references/environment.md +98 -0
  10. package/skills/dforge-1c-import/references/loading.md +164 -0
  11. package/skills/dforge-1c-import/references/measurements.md +210 -0
  12. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  13. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  14. package/skills/dforge-1c-import/scripts/README.md +164 -0
  15. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  16. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  17. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  18. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  19. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  20. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  21. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  22. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  23. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  24. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  25. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  26. package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
  27. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  28. package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
  29. package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
  30. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  31. package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  33. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  34. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  35. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  36. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  37. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  38. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  39. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  40. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  41. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  42. package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
  43. package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
  44. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  45. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  46. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
  49. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  50. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  51. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  52. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  53. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  54. package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
  55. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  56. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  57. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  58. package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  61. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  62. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  63. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  64. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  65. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  66. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  67. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  68. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  69. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  70. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  71. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  72. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  73. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  74. package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
  75. package/skills/dforge-mcp-author/SKILL.md +5 -0
  76. package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
  77. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +32 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  80. package/skills/dforge-mcp-author/references/queries.md +17 -2
  81. package/skills/dforge-mcp-author/references/registers.md +1 -0
  82. package/skills/dforge-mcp-author/references/reports.md +1 -1
  83. 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())