vf-hive-dbparse 0.6.0__py3-none-any.whl
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.
- hivedbparse/__init__.py +0 -0
- hivedbparse/emit.py +304 -0
- hivedbparse/model.py +43 -0
- hivedbparse/parse_aux.py +335 -0
- hivedbparse/parse_plsql.py +331 -0
- hivedbparse/parse_tables.py +460 -0
- hivedbparse/reconcile.py +150 -0
- hivedbparse/run.py +592 -0
- vf_hive_dbparse-0.6.0.dist-info/METADATA +13 -0
- vf_hive_dbparse-0.6.0.dist-info/RECORD +14 -0
- vf_hive_dbparse-0.6.0.dist-info/WHEEL +4 -0
- vf_hive_dbparse-0.6.0.dist-info/entry_points.txt +2 -0
- vf_hive_dbparse-0.6.0.dist-info/licenses/LICENSE +177 -0
- vf_hive_dbparse-0.6.0.dist-info/licenses/NOTICE +20 -0
hivedbparse/__init__.py
ADDED
|
File without changes
|
hivedbparse/emit.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Render `Table`/`PlsqlObject` models into OKF `dbobject` markdown cards.
|
|
2
|
+
|
|
3
|
+
One card per object (design spec Sec. 4): a `table_card` (columns + PK/FK/
|
|
4
|
+
index/sequence/trigger sections) and a `plsql_card` (signature + full body).
|
|
5
|
+
Everything sourced from `COMMENT ON ...`/DDL text (comments, types, bodies) is
|
|
6
|
+
rendered **verbatim** -- this module never fabricates or normalizes it. The
|
|
7
|
+
two correctness hazards called out in the design are handled explicitly:
|
|
8
|
+
|
|
9
|
+
- A comment containing `|` would otherwise break a markdown table row, so
|
|
10
|
+
`|` is escaped to `\\|` when a comment is placed in the Columns table.
|
|
11
|
+
- Frontmatter is emitted with `yaml.safe_dump` (the same library hive-serve's
|
|
12
|
+
loader uses to parse it back with `yaml.safe_load`), so a comment containing
|
|
13
|
+
a `:` or other YAML-special character is always quoted/escaped correctly
|
|
14
|
+
instead of hand-rolled and risking invalid YAML.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from hivedbparse.model import Column, PlsqlObject, Table
|
|
22
|
+
|
|
23
|
+
# Canonical dialect order for `platform:` frontmatter and derived source paths.
|
|
24
|
+
_DIALECT_ORDER = ("oracle", "db2")
|
|
25
|
+
_DIALECT_DIR = {"oracle": "Oracle", "db2": "DB2"}
|
|
26
|
+
|
|
27
|
+
_MISSING_TYPE = "n/a" # column absent from that dialect
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_PRODUCT = "db"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def card_id(obj: Table | PlsqlObject, product: str = DEFAULT_PRODUCT) -> str:
|
|
34
|
+
"""`<product>/db/tables/<NAME>` for a `Table`, `<product>/db/plsql/<NAME>` otherwise.
|
|
35
|
+
|
|
36
|
+
The product names the corpus this schema belongs to. It is a run-level constant, so it
|
|
37
|
+
is passed rather than inferred: a schema does not know which product it implements.
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(obj, Table):
|
|
40
|
+
return f"{product}/db/tables/{obj.name}"
|
|
41
|
+
if isinstance(obj, PlsqlObject):
|
|
42
|
+
return f"{product}/db/plsql/{obj.name}"
|
|
43
|
+
raise TypeError(f"hivedbparse: card_id: unsupported object type {type(obj)!r}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _platform(dialects: set[str]) -> list[str]:
|
|
47
|
+
return [d for d in _DIALECT_ORDER if d in dialects]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _frontmatter(fields: dict) -> str:
|
|
51
|
+
dumped = yaml.safe_dump(fields, sort_keys=False, default_flow_style=False, allow_unicode=True)
|
|
52
|
+
return f"---\n{dumped}---\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _esc_cell(text: str) -> str:
|
|
56
|
+
"""Escape `|` so a comment can't be mistaken for a markdown table column break."""
|
|
57
|
+
return text.replace("|", "\\|")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Table cards
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _table_title(t: Table) -> str:
|
|
66
|
+
return f"{t.name}: {t.comment}" if t.comment else t.name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _table_tags(t: Table) -> list[str]:
|
|
70
|
+
return ["table", t.module]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _table_sources(t: Table) -> list[str]:
|
|
74
|
+
"""The real file(s) `t` was read from, when the runner set them; else a
|
|
75
|
+
synthesized `<module>.sql` path (correct for tables, since a module's
|
|
76
|
+
`.sql` file *is* named after the module -- see `_plsql_sources` for why
|
|
77
|
+
PL/SQL cards can't use the same synthesis)."""
|
|
78
|
+
if t.source_files:
|
|
79
|
+
return list(t.source_files)
|
|
80
|
+
return [
|
|
81
|
+
f"{_DIALECT_DIR[d]}/DBScripts/Product/{t.module}.sql"
|
|
82
|
+
for d in _DIALECT_ORDER
|
|
83
|
+
if d in t.dialects
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _table_related(t: Table, product: str) -> list[str]:
|
|
88
|
+
related: list[str] = []
|
|
89
|
+
for _col, ref_table, _ref_col in t.fks:
|
|
90
|
+
rid = f"{product}/db/tables/{ref_table}"
|
|
91
|
+
if rid not in related:
|
|
92
|
+
related.append(rid)
|
|
93
|
+
return related
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _table_frontmatter(t: Table, product: str) -> dict:
|
|
97
|
+
return {
|
|
98
|
+
"type": "dbobject",
|
|
99
|
+
"kind": "table",
|
|
100
|
+
"title": _table_title(t),
|
|
101
|
+
"description": t.comment,
|
|
102
|
+
"product": product,
|
|
103
|
+
"module": t.module,
|
|
104
|
+
"platform": _platform(t.dialects),
|
|
105
|
+
"tags": _table_tags(t),
|
|
106
|
+
"related": _table_related(t, product),
|
|
107
|
+
"sources": _table_sources(t),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _type_cell(type_text: str | None) -> str:
|
|
112
|
+
return type_text if type_text else _MISSING_TYPE
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _column_row(c: Column, pk: list[str]) -> str:
|
|
116
|
+
null = "NOT NULL" if c.not_null else ""
|
|
117
|
+
key = "PK" if c.name in pk else ""
|
|
118
|
+
return (
|
|
119
|
+
f"| {c.name} | {_type_cell(c.type_oracle)} | {_type_cell(c.type_db2)} | "
|
|
120
|
+
f"{null} | {key} | {_esc_cell(c.comment)} |"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _columns_table(t: Table) -> str:
|
|
125
|
+
lines = [
|
|
126
|
+
"| Column | Oracle type | DB2 type | Null | Key | Description |",
|
|
127
|
+
"|---|---|---|---|---|---|",
|
|
128
|
+
]
|
|
129
|
+
lines.extend(_column_row(c, t.pk) for c in t.columns)
|
|
130
|
+
return "\n".join(lines)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pk_section(t: Table) -> str:
|
|
134
|
+
return ", ".join(t.pk) if t.pk else "none"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _fks_section(t: Table, product: str) -> str:
|
|
138
|
+
if not t.fks:
|
|
139
|
+
return "none"
|
|
140
|
+
return "\n".join(
|
|
141
|
+
f"{col} → {ref_table}({ref_col}) → {product}/db/tables/{ref_table}"
|
|
142
|
+
for col, ref_table, ref_col in t.fks
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _indexes_section(t: Table) -> str:
|
|
147
|
+
if not t.indexes:
|
|
148
|
+
return "none"
|
|
149
|
+
lines = []
|
|
150
|
+
for name, cols, unique in t.indexes:
|
|
151
|
+
suffix = " [UNIQUE]" if unique else ""
|
|
152
|
+
lines.append(f"{name} ({', '.join(cols)}){suffix}")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _sequences_section(t: Table) -> str:
|
|
157
|
+
return "\n".join(t.sequences) if t.sequences else "none"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _triggers_section(t: Table, product: str) -> str:
|
|
161
|
+
if not t.triggers:
|
|
162
|
+
return "none"
|
|
163
|
+
return "\n".join(f"{name} → {product}/db/plsql/{name}" for name in t.triggers)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def table_card(t: Table, product: str = DEFAULT_PRODUCT) -> str:
|
|
167
|
+
"""Render `t` as an OKF `dbobject`/`table` markdown card (design Sec. 4.1)."""
|
|
168
|
+
body_lines = [f"# {t.name} (table · module {t.module})", ""]
|
|
169
|
+
if t.comment:
|
|
170
|
+
body_lines.append(t.comment)
|
|
171
|
+
body_lines.append("")
|
|
172
|
+
body_lines += [
|
|
173
|
+
"## Columns",
|
|
174
|
+
_columns_table(t),
|
|
175
|
+
"",
|
|
176
|
+
"## Primary key",
|
|
177
|
+
_pk_section(t),
|
|
178
|
+
"",
|
|
179
|
+
"## Foreign keys",
|
|
180
|
+
_fks_section(t, product),
|
|
181
|
+
"",
|
|
182
|
+
"## Indexes",
|
|
183
|
+
_indexes_section(t),
|
|
184
|
+
"",
|
|
185
|
+
"## Sequences",
|
|
186
|
+
_sequences_section(t),
|
|
187
|
+
"",
|
|
188
|
+
"## Triggers",
|
|
189
|
+
_triggers_section(t, product),
|
|
190
|
+
"",
|
|
191
|
+
]
|
|
192
|
+
return _frontmatter(_table_frontmatter(t, product)) + "\n" + "\n".join(body_lines)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# PL/SQL cards
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _plsql_header_text(o: PlsqlObject) -> str:
|
|
201
|
+
"""The unit's header comment verbatim, or `""` when it has none.
|
|
202
|
+
|
|
203
|
+
Never falls back to the raw signature/CREATE text: `title`/`description`
|
|
204
|
+
are `find_db_objects` search fields, so the DDL statement text would be
|
|
205
|
+
noise there (the full signature and body already live in the card body).
|
|
206
|
+
"""
|
|
207
|
+
return o.comment
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _plsql_title(o: PlsqlObject) -> str:
|
|
211
|
+
"""`NAME: <comment>` when a header comment exists, else `NAME (<kind>)`.
|
|
212
|
+
Never embeds the raw signature/DDL text (search-field hygiene)."""
|
|
213
|
+
if o.comment:
|
|
214
|
+
return f"{o.name}: {o.comment}"
|
|
215
|
+
return f"{o.name} ({o.kind})"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _plsql_tags(o: PlsqlObject) -> list[str]:
|
|
219
|
+
return ["plsql", o.kind, o.module]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _plsql_sources(o: PlsqlObject) -> list[str]:
|
|
223
|
+
"""The real file(s) `o` was read from, when the runner set them. A
|
|
224
|
+
PL/SQL unit's real file lives at `PLSQL_Objects/<file>.sql`, named per
|
|
225
|
+
*file* (not necessarily per object) -- unlike a table's module file, this
|
|
226
|
+
can't be reliably synthesized from `o.name`/`o.module` alone, so the
|
|
227
|
+
fallback below is a best-effort guess only used when `source_files` is
|
|
228
|
+
unset (e.g. in unit tests that build a `PlsqlObject` directly)."""
|
|
229
|
+
if o.source_files:
|
|
230
|
+
return list(o.source_files)
|
|
231
|
+
return [
|
|
232
|
+
f"{_DIALECT_DIR[d]}/DBScripts/Product/PLSQL_Objects/{o.name}.sql"
|
|
233
|
+
for d in _DIALECT_ORDER
|
|
234
|
+
if d in o.dialects
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _plsql_frontmatter(o: PlsqlObject, product: str) -> dict:
|
|
239
|
+
return {
|
|
240
|
+
"type": "dbobject",
|
|
241
|
+
"kind": o.kind,
|
|
242
|
+
"title": _plsql_title(o),
|
|
243
|
+
"description": _plsql_header_text(o),
|
|
244
|
+
"product": product,
|
|
245
|
+
"module": o.module,
|
|
246
|
+
"platform": _platform(o.dialects),
|
|
247
|
+
"tags": _plsql_tags(o),
|
|
248
|
+
"sources": _plsql_sources(o),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def plsql_card(o: PlsqlObject, product: str = DEFAULT_PRODUCT) -> str:
|
|
253
|
+
"""Render `o` as an OKF `dbobject`/`<kind>` markdown card (design Sec. 4.2)."""
|
|
254
|
+
body_lines = [
|
|
255
|
+
f"# {o.name} ({o.kind} · module {o.module})",
|
|
256
|
+
"",
|
|
257
|
+
"## Signature / spec",
|
|
258
|
+
"```sql",
|
|
259
|
+
o.signature,
|
|
260
|
+
"```",
|
|
261
|
+
"",
|
|
262
|
+
"## Source (Oracle)",
|
|
263
|
+
"```sql",
|
|
264
|
+
o.body_oracle,
|
|
265
|
+
"```",
|
|
266
|
+
"",
|
|
267
|
+
"## Source (DB2)",
|
|
268
|
+
]
|
|
269
|
+
if o.body_db2:
|
|
270
|
+
body_lines += ["```sql", o.body_db2, "```"]
|
|
271
|
+
else:
|
|
272
|
+
body_lines.append("Identical to Oracle.")
|
|
273
|
+
body_lines.append("")
|
|
274
|
+
return _frontmatter(_plsql_frontmatter(o, product)) + "\n" + "\n".join(body_lines)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Manifest
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def manifest_line(obj: Table | PlsqlObject, product: str = DEFAULT_PRODUCT) -> dict:
|
|
283
|
+
"""`{id, kind, module, product, title, description, tags}` for the manifest index."""
|
|
284
|
+
if isinstance(obj, Table):
|
|
285
|
+
return {
|
|
286
|
+
"id": card_id(obj, product),
|
|
287
|
+
"kind": "table",
|
|
288
|
+
"module": obj.module,
|
|
289
|
+
"product": product,
|
|
290
|
+
"title": _table_title(obj),
|
|
291
|
+
"description": obj.comment,
|
|
292
|
+
"tags": _table_tags(obj),
|
|
293
|
+
}
|
|
294
|
+
if isinstance(obj, PlsqlObject):
|
|
295
|
+
return {
|
|
296
|
+
"id": card_id(obj, product),
|
|
297
|
+
"kind": obj.kind,
|
|
298
|
+
"module": obj.module,
|
|
299
|
+
"product": product,
|
|
300
|
+
"title": _plsql_title(obj),
|
|
301
|
+
"description": _plsql_header_text(obj),
|
|
302
|
+
"tags": _plsql_tags(obj),
|
|
303
|
+
}
|
|
304
|
+
raise TypeError(f"hivedbparse: manifest_line: unsupported object type {type(obj)!r}")
|
hivedbparse/model.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class Column:
|
|
6
|
+
name: str
|
|
7
|
+
type_oracle: str | None = None
|
|
8
|
+
type_db2: str | None = None
|
|
9
|
+
not_null: bool = False
|
|
10
|
+
comment: str = "" # from COMMENT ON COLUMN, verbatim
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Table:
|
|
15
|
+
name: str
|
|
16
|
+
module: str
|
|
17
|
+
comment: str = "" # from COMMENT ON TABLE, verbatim
|
|
18
|
+
columns: list[Column] = field(default_factory=list)
|
|
19
|
+
pk: list[str] = field(default_factory=list)
|
|
20
|
+
fks: list[tuple[str, str, str]] = field(default_factory=list) # (col, ref_table, ref_col)
|
|
21
|
+
indexes: list[tuple[str, list[str], bool]] = field(
|
|
22
|
+
default_factory=list
|
|
23
|
+
) # (name, cols, unique)
|
|
24
|
+
sequences: list[str] = field(default_factory=list)
|
|
25
|
+
triggers: list[str] = field(default_factory=list)
|
|
26
|
+
dialects: set[str] = field(default_factory=set) # {"oracle","db2"}
|
|
27
|
+
# Repo-relative path(s) this object was actually read from (set by the
|
|
28
|
+
# runner, which knows the real file -- see hivedbparse.run). Empty by
|
|
29
|
+
# default so `emit` can fall back to its module-synthesized path.
|
|
30
|
+
source_files: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PlsqlObject:
|
|
35
|
+
name: str
|
|
36
|
+
module: str
|
|
37
|
+
kind: str # package|procedure|function|view|trigger
|
|
38
|
+
signature: str = ""
|
|
39
|
+
body_oracle: str = "" # full source, verbatim
|
|
40
|
+
body_db2: str = ""
|
|
41
|
+
comment: str = ""
|
|
42
|
+
dialects: set[str] = field(default_factory=set)
|
|
43
|
+
source_files: list[str] = field(default_factory=list)
|
hivedbparse/parse_aux.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Attach comments, FKs, indexes and sequences to `Table` objects built by `parse_tables`.
|
|
2
|
+
|
|
3
|
+
Uses sqlglot's AST exclusively (tokenizer + parser) -- never regex -- to walk
|
|
4
|
+
`COMMENT ON TABLE/COLUMN`, `ALTER TABLE ... ADD ... FOREIGN KEY`,
|
|
5
|
+
`CREATE [UNIQUE] INDEX` and `CREATE SEQUENCE` statements and mutate the
|
|
6
|
+
already-parsed `Table`/`Column` objects in place. Statements that reference a
|
|
7
|
+
table not present in `tables` (and statements sqlglot cannot parse at all) are
|
|
8
|
+
skipped and logged -- never allowed to crash the run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import sqlglot
|
|
17
|
+
from sqlglot import exp
|
|
18
|
+
|
|
19
|
+
from hivedbparse.model import Table
|
|
20
|
+
from hivedbparse.parse_tables import (
|
|
21
|
+
_extract_create_table_fragment,
|
|
22
|
+
_split_statements,
|
|
23
|
+
_sqlglot_dialect,
|
|
24
|
+
_strip_constraint_state,
|
|
25
|
+
_strip_using_index_clause,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Hard failures -- DDL we should have understood but couldn't. The runner
|
|
29
|
+
# captures this logger into the verification gate: an aux statement that
|
|
30
|
+
# reaches `_apply_node` carrying attachable content and fails to parse is a
|
|
31
|
+
# SILENT DROP (a lost index/sequence/FK/comment), so it must fail the run.
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Soft, non-fatal misses -- the statement parsed fine, but its target isn't in
|
|
35
|
+
# the carded corpus (a comment/FK/index on a table we don't walk) or no owning
|
|
36
|
+
# table could be resolved (an orphan sequence). These are a consequence of
|
|
37
|
+
# corpus SCOPE, not a parser gap, so they're reported (`unattached.log`) rather
|
|
38
|
+
# than gated. Deliberately a SIBLING logger of `logger`, not a child: a handler
|
|
39
|
+
# on `hivedbparse.parse_aux` would otherwise capture these too via propagation.
|
|
40
|
+
unattached_logger = logging.getLogger("hivedbparse.aux_unattached")
|
|
41
|
+
|
|
42
|
+
# The statement kinds `_apply_node` actually attaches. Narrow ON PURPOSE: every
|
|
43
|
+
# statement passing this filter carries content we attach, so a parse failure on
|
|
44
|
+
# one is a real drop the gate can fail honestly. Everything else (CREATE TABLE,
|
|
45
|
+
# PL/SQL bodies, INSERT seed data, `ALTER TABLE ... MOVE TABLESPACE`, ...) is
|
|
46
|
+
# skipped without a parse attempt -- crucial over the Seed catalogs, whose
|
|
47
|
+
# hundreds of thousands of PL/SQL-body fragments would otherwise each be parsed.
|
|
48
|
+
_AUX_HEAD = re.compile(
|
|
49
|
+
r"\s*(?:--[^\n]*\n\s*|/\*.*?\*/\s*)*"
|
|
50
|
+
r"(COMMENT\b|ALTER\s+TABLE\b|CREATE\s+(?:UNIQUE\s+|BITMAP\s+)?INDEX\b)",
|
|
51
|
+
re.IGNORECASE | re.DOTALL,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Every `CREATE [OR REPLACE] SEQUENCE <name>` in the text, with its name.
|
|
55
|
+
# Sequences are attached by name (owner lookup), never parsed or split into
|
|
56
|
+
# statements: Oracle `CREATE SEQUENCE` parses fine, but DB2 `CREATE OR REPLACE
|
|
57
|
+
# SEQUENCE x ... NO ORDER!` degrades to `Command`, and its files use `!`
|
|
58
|
+
# terminators that must NOT be split on (see `_split_statements`). Scanning the
|
|
59
|
+
# whole text with `findall` sidesteps both -- a sequence carries no columns/keys
|
|
60
|
+
# we model, so the name is all `_sequence_owner` needs.
|
|
61
|
+
_SEQUENCE_DECL = re.compile(
|
|
62
|
+
r"\bCREATE\s+(?:OR\s+REPLACE\s+)?SEQUENCE\s+\"?([A-Za-z0-9_$]+)\"?", re.IGNORECASE
|
|
63
|
+
)
|
|
64
|
+
# Only an ADD of a foreign key carries columns + references we attach. DB2's
|
|
65
|
+
# `ALTER TABLE t ALTER FOREIGN KEY fk NOT ENFORCED` merely toggles an existing
|
|
66
|
+
# FK's enforcement -- no definition to attach, so it must NOT reach the gate.
|
|
67
|
+
_ADD_FOREIGN_KEY = re.compile(r"\bADD\b[^;]*?\bFOREIGN\s+KEY\b", re.IGNORECASE | re.DOTALL)
|
|
68
|
+
|
|
69
|
+
# sqlglot's Oracle grammar rejects a schema-qualified INDEX NAME
|
|
70
|
+
# (`CREATE INDEX session.idx1 ON session.t (...)` degrades to Command), though
|
|
71
|
+
# an unqualified index name on a schema-qualified TABLE parses fine. The index's
|
|
72
|
+
# own schema is cosmetic here (we attach by table, and don't model index schema),
|
|
73
|
+
# so drop just the `<schema>.` before the index name, leaving the ON-table ref.
|
|
74
|
+
_INDEX_NAME_SCHEMA = re.compile(
|
|
75
|
+
r"(\bINDEX\s+)(?:\"?[\w$]+\"?\s*\.\s*)(\"?[\w$]+\"?\s+ON\b)", re.IGNORECASE
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_aux_statement(stmt_text: str) -> bool:
|
|
80
|
+
"""True only for statements `_apply_node` can attach (see `_AUX_HEAD`).
|
|
81
|
+
|
|
82
|
+
An `ALTER TABLE` only qualifies when it adds a FOREIGN KEY -- every other
|
|
83
|
+
alter (`MOVE TABLESPACE`, `ADD PARTITION`, `ENABLE CONSTRAINT`, ...) carries
|
|
84
|
+
nothing this module models, so admitting it would make the gate fail on
|
|
85
|
+
statements that were never a drop.
|
|
86
|
+
"""
|
|
87
|
+
m = _AUX_HEAD.match(stmt_text)
|
|
88
|
+
if m is None:
|
|
89
|
+
return False
|
|
90
|
+
if m.group(1).upper().startswith("ALTER"):
|
|
91
|
+
return _ADD_FOREIGN_KEY.search(stmt_text) is not None
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_aux_statement(stmt_text: str, sqlglot_dialect: str | None) -> exp.Expression | None:
|
|
96
|
+
"""Parse one aux statement, trimming a physical-storage tail if needed.
|
|
97
|
+
|
|
98
|
+
Real Oracle DDL trails `CREATE INDEX x ON t (cols)` with storage clauses
|
|
99
|
+
(`TABLESPACE ... PCTFREE ... INITRANS ...`) that sqlglot can't model, so it
|
|
100
|
+
degrades the WHOLE statement to `exp.Command` -- and `_apply_node` then
|
|
101
|
+
skips it, silently losing the index. This retries against the bare
|
|
102
|
+
`CREATE ... (...)` fragment (the same token-level paren-slice trick
|
|
103
|
+
`parse_tables` uses for `CREATE TABLE`). Returns `None` when the statement
|
|
104
|
+
is genuinely unparseable.
|
|
105
|
+
"""
|
|
106
|
+
# Same physical-noise cleaners `parse_tables` runs: a trailing constraint
|
|
107
|
+
# state (`... FOREIGN KEY (A) REFERENCES P (B) ENABLE NOVALIDATE;`) or a
|
|
108
|
+
# `USING INDEX TABLESPACE ...` clause degrades the whole ALTER/CREATE to an
|
|
109
|
+
# opaque `Command`; stripping them lets the FK/index parse cleanly.
|
|
110
|
+
stmt_text = _strip_using_index_clause(
|
|
111
|
+
_strip_constraint_state(stmt_text, sqlglot_dialect), sqlglot_dialect
|
|
112
|
+
)
|
|
113
|
+
stmt_text = _INDEX_NAME_SCHEMA.sub(r"\1\2", stmt_text)
|
|
114
|
+
try:
|
|
115
|
+
node = sqlglot.parse_one(stmt_text, read=sqlglot_dialect)
|
|
116
|
+
except Exception: # noqa: BLE001 -- fall through to the fragment retry
|
|
117
|
+
node = None
|
|
118
|
+
|
|
119
|
+
if node is not None and not isinstance(node, exp.Command):
|
|
120
|
+
return node
|
|
121
|
+
|
|
122
|
+
fragment = _extract_create_table_fragment(stmt_text, sqlglot_dialect)
|
|
123
|
+
if fragment is not None:
|
|
124
|
+
try:
|
|
125
|
+
retry = sqlglot.parse_one(fragment, read=sqlglot_dialect)
|
|
126
|
+
except Exception: # noqa: BLE001
|
|
127
|
+
retry = None
|
|
128
|
+
if retry is not None and not isinstance(retry, exp.Command):
|
|
129
|
+
return retry
|
|
130
|
+
return node
|
|
131
|
+
|
|
132
|
+
# Suffixes/prefixes DDL authors commonly hang off a sequence name that derives
|
|
133
|
+
# from its owning table's name (e.g. `ORDER_LINE_SEQ`, `SEQ_ORDER_LINE`).
|
|
134
|
+
_SEQUENCE_AFFIXES = ("_SEQUENCE", "_SEQ", "SEQUENCE_", "SEQ_")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _strip_sequence_affixes(name: str) -> str:
|
|
138
|
+
stem = name.upper()
|
|
139
|
+
for affix in _SEQUENCE_AFFIXES:
|
|
140
|
+
if affix.endswith("_") and stem.startswith(affix):
|
|
141
|
+
stem = stem[len(affix) :]
|
|
142
|
+
break
|
|
143
|
+
if affix.startswith("_") and stem.endswith(affix):
|
|
144
|
+
stem = stem[: -len(affix)]
|
|
145
|
+
break
|
|
146
|
+
return stem.strip("_")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _sequence_owner(tables: dict[str, Table], seq_name: str) -> Table | None:
|
|
150
|
+
"""Find the table that owns `seq_name` by a name-prefix heuristic.
|
|
151
|
+
|
|
152
|
+
Strips common sequence affixes (`_SEQ`, `SEQ_`, ...) from the sequence name,
|
|
153
|
+
then resolves the owner in strict precedence:
|
|
154
|
+
|
|
155
|
+
1. An **exact** table match (`table == stem`) is the unambiguous owner and
|
|
156
|
+
wins outright. This is the important part: a bare `longest match` let an
|
|
157
|
+
exact owner lose to any longer table that merely *starts* with the stem
|
|
158
|
+
(e.g. `ROUTE_SEQ` was grabbed by `ROUTE_PLAN_LEG_SET` instead of the
|
|
159
|
+
`ROUTE` table).
|
|
160
|
+
2. Otherwise, the longest table that is a prefix of the stem, or that the
|
|
161
|
+
stem is a prefix of -- a weak plural/abbreviation fallback.
|
|
162
|
+
|
|
163
|
+
Returns `None` (an orphan sequence) when no table matches.
|
|
164
|
+
"""
|
|
165
|
+
stem = _strip_sequence_affixes(seq_name)
|
|
166
|
+
if not stem:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
best_table: Table | None = None
|
|
170
|
+
best_len = -1
|
|
171
|
+
for table_name, table in tables.items():
|
|
172
|
+
upper = table_name.upper()
|
|
173
|
+
if upper == stem:
|
|
174
|
+
return table # exact match -> unambiguous owner, beats any prefix match
|
|
175
|
+
if (stem.startswith(upper + "_") or upper.startswith(stem)) and len(upper) > best_len:
|
|
176
|
+
best_table = table
|
|
177
|
+
best_len = len(upper)
|
|
178
|
+
return best_table
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _comment_text(node: exp.Comment) -> str:
|
|
182
|
+
expression = node.expression
|
|
183
|
+
return expression.this if isinstance(expression, exp.Literal) else str(expression)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _apply_comment(tables: dict[str, Table], node: exp.Comment) -> None:
|
|
187
|
+
kind = (node.args.get("kind") or "").lower()
|
|
188
|
+
text = _comment_text(node)
|
|
189
|
+
target = node.this
|
|
190
|
+
|
|
191
|
+
if kind == "table":
|
|
192
|
+
table_name = target.name
|
|
193
|
+
table = tables.get(table_name)
|
|
194
|
+
if table is None:
|
|
195
|
+
unattached_logger.warning("hivedbparse: COMMENT ON TABLE references unknown table %s", table_name)
|
|
196
|
+
return
|
|
197
|
+
table.comment = text
|
|
198
|
+
elif kind == "column":
|
|
199
|
+
table_name = target.table
|
|
200
|
+
column_name = target.name
|
|
201
|
+
table = tables.get(table_name)
|
|
202
|
+
if table is None:
|
|
203
|
+
unattached_logger.warning("hivedbparse: COMMENT ON COLUMN references unknown table %s", table_name)
|
|
204
|
+
return
|
|
205
|
+
column = next((c for c in table.columns if c.name == column_name), None)
|
|
206
|
+
if column is None:
|
|
207
|
+
unattached_logger.warning(
|
|
208
|
+
"hivedbparse: COMMENT ON COLUMN references unknown column %s.%s",
|
|
209
|
+
table_name,
|
|
210
|
+
column_name,
|
|
211
|
+
)
|
|
212
|
+
return
|
|
213
|
+
column.comment = text
|
|
214
|
+
else:
|
|
215
|
+
logger.warning("hivedbparse: unrecognized COMMENT kind %r", kind)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _apply_alter_table(tables: dict[str, Table], node: exp.Alter) -> None:
|
|
219
|
+
if (node.args.get("kind") or "").upper() != "TABLE":
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
table_name = node.this.name
|
|
223
|
+
table = tables.get(table_name)
|
|
224
|
+
|
|
225
|
+
for fk in node.find_all(exp.ForeignKey):
|
|
226
|
+
reference = fk.args.get("reference")
|
|
227
|
+
if reference is None:
|
|
228
|
+
continue
|
|
229
|
+
ref_table_name = reference.this.this.name
|
|
230
|
+
ref_columns = reference.this.expressions
|
|
231
|
+
|
|
232
|
+
if table is None:
|
|
233
|
+
unattached_logger.warning(
|
|
234
|
+
"hivedbparse: ALTER TABLE ADD FOREIGN KEY references unknown table %s", table_name
|
|
235
|
+
)
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
for col, ref_col in zip(fk.expressions, ref_columns):
|
|
239
|
+
table.fks.append((col.name, ref_table_name, ref_col.name))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _apply_create_index(tables: dict[str, Table], node: exp.Create) -> None:
|
|
243
|
+
index = node.this
|
|
244
|
+
table_name = index.args["table"].name
|
|
245
|
+
table = tables.get(table_name)
|
|
246
|
+
if table is None:
|
|
247
|
+
unattached_logger.warning("hivedbparse: CREATE INDEX references unknown table %s", table_name)
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
index_name = index.this.name
|
|
251
|
+
params = index.args.get("params")
|
|
252
|
+
columns: list[str] = []
|
|
253
|
+
if params is not None:
|
|
254
|
+
for col_expr in params.args.get("columns") or []:
|
|
255
|
+
column = col_expr.this if isinstance(col_expr, exp.Ordered) else col_expr
|
|
256
|
+
columns.append(column.name)
|
|
257
|
+
|
|
258
|
+
unique = bool(node.args.get("unique"))
|
|
259
|
+
table.indexes.append((index_name, columns, unique))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _attach_sequence(tables: dict[str, Table], seq_name: str) -> None:
|
|
263
|
+
"""Attach one sequence to its owning table by name (see `_sequence_owner`).
|
|
264
|
+
Sequences are resolved by name, not by parsing -- so this works for a DB2
|
|
265
|
+
`CREATE OR REPLACE SEQUENCE` that sqlglot can only degrade to `Command`."""
|
|
266
|
+
owner = _sequence_owner(tables, seq_name)
|
|
267
|
+
if owner is None:
|
|
268
|
+
unattached_logger.warning(
|
|
269
|
+
"hivedbparse: CREATE SEQUENCE %s has no clear owning table", seq_name
|
|
270
|
+
)
|
|
271
|
+
return
|
|
272
|
+
if seq_name not in owner.sequences:
|
|
273
|
+
owner.sequences.append(seq_name)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def attach_sequences(tables: dict[str, Table], sql: str) -> None:
|
|
277
|
+
"""Attach every `CREATE [OR REPLACE] SEQUENCE` in `sql` to its owning table.
|
|
278
|
+
|
|
279
|
+
Run by the runner AFTER reconcile, against the MERGED table set -- not
|
|
280
|
+
per-dialect inside `apply_aux`. A sequence's owner is found by name, and DB2
|
|
281
|
+
sequence owners exist only in the merged set: the DB2 dialect barely parses
|
|
282
|
+
(its files use `!` terminators sqlglot can't split on, so `db2_tables` is
|
|
283
|
+
nearly empty), while the merged set carries every table name from the Oracle
|
|
284
|
+
side. Scanning the whole text with `_SEQUENCE_DECL` also handles DB2 sequence
|
|
285
|
+
files that never split into statements.
|
|
286
|
+
"""
|
|
287
|
+
for seq_name in _SEQUENCE_DECL.findall(sql):
|
|
288
|
+
_attach_sequence(tables, seq_name)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _apply_node(tables: dict[str, Table], node: exp.Expression) -> None:
|
|
292
|
+
if isinstance(node, exp.Comment):
|
|
293
|
+
_apply_comment(tables, node)
|
|
294
|
+
elif isinstance(node, exp.Alter):
|
|
295
|
+
_apply_alter_table(tables, node)
|
|
296
|
+
elif isinstance(node, exp.Create):
|
|
297
|
+
# SEQUENCEs are handled before parsing (by name) in `apply_aux`, so a
|
|
298
|
+
# Create reaching here is only ever an INDEX.
|
|
299
|
+
if (node.args.get("kind") or "").upper() == "INDEX":
|
|
300
|
+
_apply_create_index(tables, node)
|
|
301
|
+
elif isinstance(node, exp.Command):
|
|
302
|
+
logger.warning("hivedbparse: aux statement degraded to Command, skipping: %.80s", node.sql())
|
|
303
|
+
# else: statement type we don't attach (e.g. GRANT, CREATE TABLE) -- ignore
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def apply_aux(tables: dict[str, Table], sql: str, dialect: str) -> None:
|
|
307
|
+
"""Attach comments/FKs/indexes/sequences parsed from `sql` onto `tables`, in place.
|
|
308
|
+
|
|
309
|
+
`dialect` is the logical `"oracle"`/`"db2"` label from `parse_tables`; it is
|
|
310
|
+
mapped to the sqlglot dialect via the shared `_sqlglot_dialect` helper
|
|
311
|
+
(sqlglot has no native "db2" dialect). Statements are split on top-level
|
|
312
|
+
semicolons with the dialect's own tokenizer (reusing `_split_statements`
|
|
313
|
+
from `parse_tables`) and parsed one at a time so that one unparseable or
|
|
314
|
+
unrelated statement never aborts the rest of the file.
|
|
315
|
+
"""
|
|
316
|
+
sqlglot_dialect = _sqlglot_dialect(dialect)
|
|
317
|
+
|
|
318
|
+
# NB: sequences are NOT handled here -- they're attached post-reconcile
|
|
319
|
+
# against the merged table set (see `attach_sequences`), because a DB2
|
|
320
|
+
# sequence's owning table exists only in the merged set. This loop handles
|
|
321
|
+
# comments, FKs and indexes, which attach to the dialect's own tables.
|
|
322
|
+
for stmt_text in _split_statements(sql, sqlglot_dialect):
|
|
323
|
+
if not _is_aux_statement(stmt_text):
|
|
324
|
+
continue
|
|
325
|
+
node = _parse_aux_statement(stmt_text, sqlglot_dialect)
|
|
326
|
+
# Everything reaching here carries content we attach (`_is_aux_statement`
|
|
327
|
+
# is narrow by design), so failing to parse it -- or only getting an
|
|
328
|
+
# opaque `Command` back even after the storage-tail retry -- means we are
|
|
329
|
+
# DROPPING a real index/sequence/FK/comment. That is exactly the silent
|
|
330
|
+
# loss the verification gate exists to catch, so warn on `logger` (which
|
|
331
|
+
# the runner routes into the gate) rather than skipping quietly.
|
|
332
|
+
if node is None or isinstance(node, exp.Command):
|
|
333
|
+
logger.warning("hivedbparse: could not parse aux statement: %.80s", stmt_text.strip())
|
|
334
|
+
continue
|
|
335
|
+
_apply_node(tables, node)
|