baucli 1.0.1__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.
- baucli/__init__.py +8 -0
- baucli/apex_rebuild.py +1458 -0
- baucli/apexlang.py +317 -0
- baucli/api.py +914 -0
- baucli/cli.py +7754 -0
- baucli/config.py +244 -0
- baucli/db/__init__.py +117 -0
- baucli/db/mssql.py +443 -0
- baucli/db/mysql.py +388 -0
- baucli/db/oracle.py +1294 -0
- baucli/db/postgresql.py +482 -0
- baucli/docmig.py +149 -0
- baucli/git.py +214 -0
- baucli/legacy/__init__.py +9 -0
- baucli/legacy/delphi.py +365 -0
- baucli/mcp_server.py +657 -0
- baucli/project.py +360 -0
- baucli/rag_embed.py +26 -0
- baucli/sample_scan.py +105 -0
- baucli/scope_scan.py +272 -0
- baucli/screenshot.py +316 -0
- baucli/seed_check.py +190 -0
- baucli/stress.py +409 -0
- baucli/sync.py +846 -0
- baucli-1.0.1.dist-info/METADATA +179 -0
- baucli-1.0.1.dist-info/RECORD +29 -0
- baucli-1.0.1.dist-info/WHEEL +5 -0
- baucli-1.0.1.dist-info/entry_points.txt +2 -0
- baucli-1.0.1.dist-info/top_level.txt +1 -0
baucli/db/oracle.py
ADDED
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
"""BAUCLI — Oracle database extractor.
|
|
2
|
+
|
|
3
|
+
Uses oracledb (thin mode, no Oracle Client needed).
|
|
4
|
+
Extracts PL/SQL source from DBA_SOURCE/ALL_SOURCE,
|
|
5
|
+
table metadata from DBA_TAB_COLUMNS, DBA_CONSTRAINTS, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from baucli.db import BaseExtractor, BauConnectionError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ORA-codes mapped to (short friendly message, actionable hint).
|
|
13
|
+
# Keep messages in PT-BR — BAUCLI shipped to BR ops teams.
|
|
14
|
+
_ORA_HINTS = {
|
|
15
|
+
'ORA-12170': ('Banco de dados inacessível (timeout na conexão).',
|
|
16
|
+
'Verifique se o serviço Oracle está no ar e se o host/porta são alcançáveis (firewall, VPN).'),
|
|
17
|
+
'ORA-12541': ('Listener Oracle não está ativo no host informado.',
|
|
18
|
+
'Suba o listener (lsnrctl start) ou confira host/porta.'),
|
|
19
|
+
'ORA-12514': ('Listener não conhece o serviço solicitado.',
|
|
20
|
+
'Confira `service` no perfil baucli — talvez o nome do serviço ou SID esteja errado.'),
|
|
21
|
+
'ORA-12545': ('Host de destino não pôde ser resolvido.',
|
|
22
|
+
'Confira o DNS / hosts ou troque para IP literal.'),
|
|
23
|
+
'ORA-01017': ('Usuário ou senha inválidos.',
|
|
24
|
+
'Revise as credenciais (baucli db edit / config.json).'),
|
|
25
|
+
'ORA-28000': ('Conta Oracle está bloqueada.',
|
|
26
|
+
'DBA precisa rodar: ALTER USER <user> ACCOUNT UNLOCK;'),
|
|
27
|
+
'ORA-28001': ('Senha Oracle expirou.',
|
|
28
|
+
'Renove a senha no banco ou peça ao DBA para redefinir.'),
|
|
29
|
+
'ORA-12504': ('Listener não recebeu SERVICE_NAME na conexão.',
|
|
30
|
+
'Inclua `service` no perfil baucli.'),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _friendly_oracle_error(exc: Exception, host: str, port: int, service: str) -> BauConnectionError:
|
|
35
|
+
"""Map a raw oracledb error to a `BauConnectionError` with friendly text.
|
|
36
|
+
|
|
37
|
+
Falls back to the raw message for anything not in `_ORA_HINTS` so unknown
|
|
38
|
+
failures still surface enough info to debug.
|
|
39
|
+
"""
|
|
40
|
+
raw = str(exc)
|
|
41
|
+
target = f"{host}:{port}/{service}"
|
|
42
|
+
for code, (msg, hint) in _ORA_HINTS.items():
|
|
43
|
+
if code in raw:
|
|
44
|
+
return BauConnectionError(
|
|
45
|
+
f"{code}: {msg} (alvo: {target})", hint=hint, cause=exc)
|
|
46
|
+
# Generic network / DNS errors not covered by ORA-codes
|
|
47
|
+
raw_low = raw.lower()
|
|
48
|
+
if 'name or service not known' in raw_low or 'nodename nor servname' in raw_low:
|
|
49
|
+
return BauConnectionError(
|
|
50
|
+
f"Host não resolvível: {host}",
|
|
51
|
+
hint='Confira DNS, /etc/hosts ou troque para IP literal.',
|
|
52
|
+
cause=exc)
|
|
53
|
+
if 'connection refused' in raw_low:
|
|
54
|
+
return BauConnectionError(
|
|
55
|
+
f"Conexão recusada por {target}",
|
|
56
|
+
hint='Banco está no ar? Confira porta e firewall.',
|
|
57
|
+
cause=exc)
|
|
58
|
+
return BauConnectionError(
|
|
59
|
+
f"Falha ao conectar em {target}: {raw.splitlines()[0][:200]}",
|
|
60
|
+
hint='Rode com --verbose para ver o stack trace completo.',
|
|
61
|
+
cause=exc)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OracleExtractor(BaseExtractor):
|
|
65
|
+
"""Oracle database extractor using oracledb."""
|
|
66
|
+
|
|
67
|
+
engine = "ORACLE"
|
|
68
|
+
|
|
69
|
+
def connect(self):
|
|
70
|
+
import os
|
|
71
|
+
import oracledb
|
|
72
|
+
from baucli import __version__
|
|
73
|
+
|
|
74
|
+
# ── Oracle Wallet (Autonomous Database over mTLS) ────────────────
|
|
75
|
+
# When a wallet dir is configured, the DSN is the TNS alias from the
|
|
76
|
+
# wallet's tnsnames.ora (e.g. mydb_high) and the endpoint/TLS come from
|
|
77
|
+
# the wallet — host/port are not used.
|
|
78
|
+
if getattr(self, "wallet_path", None):
|
|
79
|
+
wdir = os.path.expanduser(self.wallet_path)
|
|
80
|
+
if not os.path.isdir(wdir):
|
|
81
|
+
raise BauConnectionError(
|
|
82
|
+
f"Wallet não encontrada: {wdir}",
|
|
83
|
+
hint="Aponte o wallet para o diretório DESCOMPACTADO do Oracle Wallet "
|
|
84
|
+
"(com tnsnames.ora, sqlnet.ora e ewallet.pem).")
|
|
85
|
+
if not self.service:
|
|
86
|
+
raise BauConnectionError(
|
|
87
|
+
"Conexão por wallet exige o alias TNS em 'service'.",
|
|
88
|
+
hint="Use o alias do tnsnames.ora do wallet (ex.: mydb_high) como --service.")
|
|
89
|
+
kwargs = dict(
|
|
90
|
+
user=self.user, password=self.password,
|
|
91
|
+
dsn=self.service, # TNS alias resolved from tnsnames.ora
|
|
92
|
+
config_dir=wdir, # tnsnames.ora / sqlnet.ora
|
|
93
|
+
wallet_location=wdir, # ewallet.pem (thin mode mTLS)
|
|
94
|
+
)
|
|
95
|
+
if getattr(self, "wallet_password", None):
|
|
96
|
+
kwargs["wallet_password"] = self.wallet_password
|
|
97
|
+
try:
|
|
98
|
+
self._conn = oracledb.connect(**kwargs)
|
|
99
|
+
except oracledb.Error as e:
|
|
100
|
+
emsg = str(e)
|
|
101
|
+
# Thin mode needs ewallet.pem; an SSO-only (auto-login) wallet
|
|
102
|
+
# requires thick mode + Instant Client. Try that fallback.
|
|
103
|
+
if ('thick' in emsg.lower() or 'DPY-3001' in emsg or 'DPY-6005' in emsg
|
|
104
|
+
or 'ewallet.pem' in emsg.lower()):
|
|
105
|
+
try:
|
|
106
|
+
oracledb.init_oracle_client(
|
|
107
|
+
lib_dir=os.environ.get("ORACLE_INSTANT_CLIENT_DIR") or None,
|
|
108
|
+
config_dir=wdir)
|
|
109
|
+
except oracledb.Error as init_err:
|
|
110
|
+
if 'DPI-1072' not in str(init_err):
|
|
111
|
+
raise BauConnectionError(
|
|
112
|
+
"Wallet requer modo thick (SSO/auto-login) mas o Instant "
|
|
113
|
+
f"Client não iniciou: {init_err}",
|
|
114
|
+
hint="Baixe o wallet COM 'ewallet.pem' e informe a senha do "
|
|
115
|
+
"wallet (--wallet-password) para o modo thin, ou defina "
|
|
116
|
+
"ORACLE_INSTANT_CLIENT_DIR para o modo thick.",
|
|
117
|
+
cause=init_err) from init_err
|
|
118
|
+
try:
|
|
119
|
+
self._conn = oracledb.connect(
|
|
120
|
+
user=self.user, password=self.password, dsn=self.service)
|
|
121
|
+
except oracledb.Error as e2:
|
|
122
|
+
raise BauConnectionError(
|
|
123
|
+
f"Falha ao conectar via wallet (alias {self.service}): {e2}",
|
|
124
|
+
hint="Confira o alias TNS, a senha do wallet e o usuário/senha do banco.",
|
|
125
|
+
cause=e2) from e2
|
|
126
|
+
else:
|
|
127
|
+
raise BauConnectionError(
|
|
128
|
+
f"Falha ao conectar via wallet (alias {self.service}): {e}",
|
|
129
|
+
hint="Confira o alias TNS no tnsnames.ora, a senha do wallet "
|
|
130
|
+
"(--wallet-password) e o usuário/senha do banco.",
|
|
131
|
+
cause=e) from e
|
|
132
|
+
except OSError as e:
|
|
133
|
+
raise BauConnectionError(
|
|
134
|
+
f"Erro de rede ao alcançar o endpoint do wallet: {e}",
|
|
135
|
+
hint="Verifique conectividade/porta 1522 (ADB) e o sqlnet.ora do wallet.",
|
|
136
|
+
cause=e) from e
|
|
137
|
+
|
|
138
|
+
cur = self._conn.cursor()
|
|
139
|
+
cur.callproc("DBMS_APPLICATION_INFO.SET_MODULE", [f"BAUCLI v{__version__}", "IDLE"])
|
|
140
|
+
cur.callproc("DBMS_APPLICATION_INFO.SET_CLIENT_INFO", ["BAUCLI"])
|
|
141
|
+
cur.close()
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
dsn = f"{self.host}:{self.port}/{self.service}"
|
|
145
|
+
|
|
146
|
+
# Try thin mode first, fall back to thick mode for NNE (Native Network Encryption).
|
|
147
|
+
# Thick mode needs Oracle Instant Client; pass its path via ORACLE_INSTANT_CLIENT_DIR
|
|
148
|
+
# (or rely on the system-default LD_LIBRARY_PATH).
|
|
149
|
+
try:
|
|
150
|
+
self._conn = oracledb.connect(user=self.user, password=self.password, dsn=dsn)
|
|
151
|
+
except oracledb.Error as e:
|
|
152
|
+
err_msg = str(e)
|
|
153
|
+
if 'thick mode' in err_msg.lower() or 'DPY-3001' in err_msg or 'DPY-6005' in err_msg:
|
|
154
|
+
ic_dir = os.environ.get("ORACLE_INSTANT_CLIENT_DIR") or None
|
|
155
|
+
try:
|
|
156
|
+
oracledb.init_oracle_client(lib_dir=ic_dir)
|
|
157
|
+
except oracledb.Error as init_err:
|
|
158
|
+
if 'DPI-1072' not in str(init_err): # already initialized
|
|
159
|
+
raise BauConnectionError(
|
|
160
|
+
f"Banco exige modo thick mas Instant Client não iniciou: {init_err}",
|
|
161
|
+
hint='Defina ORACLE_INSTANT_CLIENT_DIR apontando para o Instant Client.',
|
|
162
|
+
cause=init_err) from init_err
|
|
163
|
+
try:
|
|
164
|
+
self._conn = oracledb.connect(
|
|
165
|
+
user=self.user, password=self.password, dsn=dsn)
|
|
166
|
+
except oracledb.Error as e2:
|
|
167
|
+
raise _friendly_oracle_error(e2, self.host, self.port, self.service) from e2
|
|
168
|
+
else:
|
|
169
|
+
raise _friendly_oracle_error(e, self.host, self.port, self.service) from e
|
|
170
|
+
except OSError as e:
|
|
171
|
+
# Socket-level errors (gaierror, ConnectionRefusedError, etc.) before oracledb
|
|
172
|
+
# even gets to send a TNS packet. These come from create_connection().
|
|
173
|
+
raise _friendly_oracle_error(e, self.host, self.port, self.service) from e
|
|
174
|
+
|
|
175
|
+
# Identify BAUCLI in Oracle sessions
|
|
176
|
+
cur = self._conn.cursor()
|
|
177
|
+
cur.callproc("DBMS_APPLICATION_INFO.SET_MODULE", [f"BAUCLI v{__version__}", "IDLE"])
|
|
178
|
+
cur.callproc("DBMS_APPLICATION_INFO.SET_CLIENT_INFO", [f"BAUCLI"])
|
|
179
|
+
cur.close()
|
|
180
|
+
|
|
181
|
+
def disconnect(self):
|
|
182
|
+
if self._conn:
|
|
183
|
+
try:
|
|
184
|
+
self._conn.close()
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
self._conn = None
|
|
188
|
+
|
|
189
|
+
def run_readonly(self, sql: str, max_rows: int = 5) -> dict:
|
|
190
|
+
"""Execute a SELECT read-only and return {rowcount, sample, ms, error}.
|
|
191
|
+
|
|
192
|
+
Used by `baucli semantic verify` to prove a library query runs against
|
|
193
|
+
the real DB. Refuses anything that is not a single SELECT/WITH, and runs
|
|
194
|
+
inside a READ ONLY transaction so it can never mutate. Never raises —
|
|
195
|
+
failures come back as {error: ...} so the caller can post them back.
|
|
196
|
+
"""
|
|
197
|
+
import time
|
|
198
|
+
text = (sql or "").strip().rstrip(";")
|
|
199
|
+
head = text.lstrip("(").lstrip().upper()
|
|
200
|
+
if not (head.startswith("SELECT") or head.startswith("WITH")):
|
|
201
|
+
return {"rowcount": None, "sample": None, "ms": None,
|
|
202
|
+
"error": "recusado: apenas SELECT/WITH é permitido"}
|
|
203
|
+
cur = self._conn.cursor()
|
|
204
|
+
t0 = time.time()
|
|
205
|
+
try:
|
|
206
|
+
try:
|
|
207
|
+
cur.execute("SET TRANSACTION READ ONLY")
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
cur.execute(text)
|
|
211
|
+
rows = cur.fetchmany(max_rows + 1) or []
|
|
212
|
+
cols = [d[0] for d in (cur.description or [])]
|
|
213
|
+
ms = int((time.time() - t0) * 1000)
|
|
214
|
+
shown = rows[:max_rows]
|
|
215
|
+
def _fmt(v):
|
|
216
|
+
s = "NULL" if v is None else str(v)
|
|
217
|
+
return s if len(s) <= 60 else s[:57] + "…"
|
|
218
|
+
sample = " | ".join(cols) + "\n" + "\n".join(
|
|
219
|
+
", ".join(_fmt(v) for v in r) for r in shown) if shown else "(0 rows)"
|
|
220
|
+
return {"rowcount": len(rows) - 1 if len(rows) > max_rows else len(rows),
|
|
221
|
+
"sample": sample[:2000], "ms": ms, "error": None}
|
|
222
|
+
except Exception as e:
|
|
223
|
+
return {"rowcount": None, "sample": None,
|
|
224
|
+
"ms": int((time.time() - t0) * 1000),
|
|
225
|
+
"error": str(e)[:2000]}
|
|
226
|
+
finally:
|
|
227
|
+
try:
|
|
228
|
+
self._conn.rollback() # end the read-only transaction
|
|
229
|
+
cur.close()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
def test_connection(self) -> dict:
|
|
234
|
+
self._set_action("TEST_CONNECTION")
|
|
235
|
+
cur = self._conn.cursor()
|
|
236
|
+
cur.execute("SELECT BANNER_FULL FROM V$VERSION WHERE ROWNUM=1")
|
|
237
|
+
banner = cur.fetchone()
|
|
238
|
+
cur.execute("SELECT SYS_CONTEXT('USERENV','DB_NAME') FROM DUAL")
|
|
239
|
+
db_name = cur.fetchone()
|
|
240
|
+
cur.execute("SELECT SYS_CONTEXT('USERENV','CURRENT_SCHEMA') FROM DUAL")
|
|
241
|
+
current_schema = cur.fetchone()
|
|
242
|
+
cur.close()
|
|
243
|
+
return {
|
|
244
|
+
"status": "ok",
|
|
245
|
+
"engine": "ORACLE",
|
|
246
|
+
"version": banner[0] if banner else "Unknown",
|
|
247
|
+
"db_name": db_name[0] if db_name else "Unknown",
|
|
248
|
+
"current_schema": current_schema[0] if current_schema else "Unknown",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
def get_native_identifier(self) -> Optional[str]:
|
|
252
|
+
try:
|
|
253
|
+
cur = self._conn.cursor()
|
|
254
|
+
cur.execute("SELECT DBID FROM V$DATABASE")
|
|
255
|
+
row = cur.fetchone()
|
|
256
|
+
cur.close()
|
|
257
|
+
return str(row[0]) if row else None
|
|
258
|
+
except Exception:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
def _get_source_view(self, schema: str) -> str:
|
|
262
|
+
"""Determine if we can use DBA_SOURCE or fall back to ALL_SOURCE."""
|
|
263
|
+
cur = self._conn.cursor()
|
|
264
|
+
try:
|
|
265
|
+
cur.execute("SELECT 1 FROM DBA_SOURCE WHERE ROWNUM=1")
|
|
266
|
+
cur.close()
|
|
267
|
+
return "DBA_SOURCE"
|
|
268
|
+
except Exception:
|
|
269
|
+
cur.close()
|
|
270
|
+
return "ALL_SOURCE"
|
|
271
|
+
|
|
272
|
+
def _get_tab_view(self, schema: str) -> str:
|
|
273
|
+
"""Determine if we can use DBA_* or ALL_* views."""
|
|
274
|
+
cur = self._conn.cursor()
|
|
275
|
+
try:
|
|
276
|
+
cur.execute("SELECT 1 FROM DBA_TABLES WHERE ROWNUM=1")
|
|
277
|
+
cur.close()
|
|
278
|
+
return "DBA"
|
|
279
|
+
except Exception:
|
|
280
|
+
cur.close()
|
|
281
|
+
return "ALL"
|
|
282
|
+
|
|
283
|
+
def _set_action(self, action: str):
|
|
284
|
+
"""Set DBMS_APPLICATION_INFO.SET_ACTION for V$SESSION monitoring."""
|
|
285
|
+
try:
|
|
286
|
+
cur = self._conn.cursor()
|
|
287
|
+
cur.callproc("DBMS_APPLICATION_INFO.SET_ACTION", [action])
|
|
288
|
+
cur.close()
|
|
289
|
+
except Exception:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
def count_objects(self, schema: str) -> int:
|
|
293
|
+
cur = self._conn.cursor()
|
|
294
|
+
try:
|
|
295
|
+
cur.execute("SELECT 1 FROM DBA_OBJECTS WHERE ROWNUM=1")
|
|
296
|
+
prefix = "DBA"
|
|
297
|
+
except Exception:
|
|
298
|
+
prefix = "ALL"
|
|
299
|
+
cur.execute(f"""
|
|
300
|
+
SELECT COUNT(DISTINCT OBJECT_NAME || '.' || OBJECT_TYPE) FROM {prefix}_OBJECTS
|
|
301
|
+
WHERE OWNER = :schema AND OBJECT_TYPE IN ('PACKAGE','PACKAGE BODY','FUNCTION','PROCEDURE','TRIGGER')
|
|
302
|
+
AND OBJECT_NAME NOT LIKE 'BIN$%' AND OBJECT_NAME NOT LIKE 'SYS_%'
|
|
303
|
+
-- match extract_objects: it syncs only VALID objects (invalids are skipped),
|
|
304
|
+
-- so the scope count must exclude INVALID too (else scope > synced).
|
|
305
|
+
AND STATUS = 'VALID'
|
|
306
|
+
-- and BAU's own generated lookup functions (FNC_BAU_*) are never documented.
|
|
307
|
+
AND OBJECT_NAME NOT LIKE 'FNC_BAU_%'
|
|
308
|
+
""", {"schema": schema.upper()})
|
|
309
|
+
cnt = cur.fetchone()[0]
|
|
310
|
+
cur.close()
|
|
311
|
+
return cnt
|
|
312
|
+
|
|
313
|
+
def count_tables(self, schema: str) -> int:
|
|
314
|
+
cur = self._conn.cursor()
|
|
315
|
+
try:
|
|
316
|
+
cur.execute("SELECT 1 FROM DBA_OBJECTS WHERE ROWNUM=1")
|
|
317
|
+
prefix = "DBA"
|
|
318
|
+
except Exception:
|
|
319
|
+
prefix = "ALL"
|
|
320
|
+
cur.execute(f"""
|
|
321
|
+
SELECT COUNT(*) FROM {prefix}_OBJECTS
|
|
322
|
+
WHERE OWNER = :schema AND OBJECT_TYPE IN ('TABLE','VIEW','MATERIALIZED VIEW')
|
|
323
|
+
AND OBJECT_NAME NOT LIKE 'BIN$%' AND OBJECT_NAME NOT LIKE 'SYS_%'
|
|
324
|
+
AND OBJECT_NAME NOT LIKE 'MLOG$%' AND OBJECT_NAME NOT LIKE 'RUPD$%'
|
|
325
|
+
-- match extract_tables' exclusion set (else scope > synced):
|
|
326
|
+
-- 23ai annotation helper tables + BAU's own generated helper tables
|
|
327
|
+
-- (BAU_DOMAINS/BAU_DOMAIN_VALUES/BAU_DOMAIN_BINDINGS/BAU_ERROR_MESSAGES).
|
|
328
|
+
AND OBJECT_NAME NOT LIKE 'ANNOTATIONS_%'
|
|
329
|
+
-- 23ai/APEX annotation helper objects: METADATA_ANNOTATIONS* AND
|
|
330
|
+
-- METADATA_PREBUILT_ANNOTATIONS (a view). METADATA%ANNOTATIONS% cobre
|
|
331
|
+
-- toda a família (METADATA_<x>_ANNOTATIONS*).
|
|
332
|
+
AND OBJECT_NAME NOT LIKE 'METADATA%ANNOTATIONS%'
|
|
333
|
+
AND OBJECT_NAME NOT LIKE 'BAU_%'
|
|
334
|
+
""", {"schema": schema.upper()})
|
|
335
|
+
cnt = cur.fetchone()[0]
|
|
336
|
+
cur.close()
|
|
337
|
+
return cnt
|
|
338
|
+
|
|
339
|
+
def extract_dependencies(self, schema: str) -> list:
|
|
340
|
+
"""Extract intra-schema object dependencies from {DBA|ALL}_DEPENDENCIES.
|
|
341
|
+
|
|
342
|
+
Roda no CLIENTE, que enxerga 100% das proprias dependencias (o dono do
|
|
343
|
+
schema ve PACKAGE BODY -> TABLE etc). Enviado no payload do /sync/batch;
|
|
344
|
+
o servidor BAU NUNCA le o dicionario local do cliente. Fase 2 = escopo
|
|
345
|
+
intra-schema (owner = referenced_owner); cross-schema/dblink fica p/ depois.
|
|
346
|
+
"""
|
|
347
|
+
cur = self._conn.cursor()
|
|
348
|
+
try:
|
|
349
|
+
cur.execute("SELECT 1 FROM DBA_DEPENDENCIES WHERE ROWNUM=1")
|
|
350
|
+
prefix = "DBA"
|
|
351
|
+
except Exception:
|
|
352
|
+
prefix = "ALL"
|
|
353
|
+
s = schema.upper()
|
|
354
|
+
cur.execute(f"""
|
|
355
|
+
SELECT owner, name, type, referenced_owner, referenced_name,
|
|
356
|
+
referenced_type, dependency_type
|
|
357
|
+
FROM {prefix}_DEPENDENCIES
|
|
358
|
+
WHERE owner = :s AND referenced_owner = :s
|
|
359
|
+
AND name <> referenced_name
|
|
360
|
+
AND referenced_type IN ('TABLE','VIEW','MATERIALIZED VIEW',
|
|
361
|
+
'PACKAGE','PACKAGE BODY','PROCEDURE','FUNCTION','TRIGGER','TYPE')
|
|
362
|
+
AND name NOT LIKE 'BIN$%' AND referenced_name NOT LIKE 'BIN$%'
|
|
363
|
+
""", {"s": s})
|
|
364
|
+
rows = cur.fetchall()
|
|
365
|
+
cur.close()
|
|
366
|
+
return [
|
|
367
|
+
{"owner": r[0], "name": r[1], "type": r[2],
|
|
368
|
+
"ref_owner": r[3], "ref_name": r[4],
|
|
369
|
+
"ref_type": r[5], "dep_type": r[6]}
|
|
370
|
+
for r in rows
|
|
371
|
+
]
|
|
372
|
+
|
|
373
|
+
def extract_sequences(self, schema: str) -> list:
|
|
374
|
+
"""Extract named sequences from {DBA|ALL}_SEQUENCES for DOCSEQ.
|
|
375
|
+
|
|
376
|
+
Exclui identity system sequences (ISEQ$$) e recycle bin (BIN$) — o
|
|
377
|
+
vinculo de identity columns e descoberto no servidor via DATA_DEFAULT
|
|
378
|
+
(ISEQ$$...nextval). Enviado numa chamada dedicada do /sync/batch
|
|
379
|
+
(objects=[]); o servidor persiste os metadados fisicos (SYNC_FROM_JSON)
|
|
380
|
+
e roda PKG_BAU_DOCSEQ.DISCOVER.
|
|
381
|
+
"""
|
|
382
|
+
cur = self._conn.cursor()
|
|
383
|
+
try:
|
|
384
|
+
cur.execute("SELECT 1 FROM DBA_SEQUENCES WHERE ROWNUM=1")
|
|
385
|
+
prefix = "DBA"
|
|
386
|
+
except Exception:
|
|
387
|
+
prefix = "ALL"
|
|
388
|
+
s = schema.upper()
|
|
389
|
+
cur.execute(f"""
|
|
390
|
+
SELECT SEQUENCE_OWNER, SEQUENCE_NAME, MIN_VALUE, MAX_VALUE, INCREMENT_BY,
|
|
391
|
+
CYCLE_FLAG, ORDER_FLAG, CACHE_SIZE, LAST_NUMBER
|
|
392
|
+
FROM {prefix}_SEQUENCES
|
|
393
|
+
WHERE SEQUENCE_OWNER = :s
|
|
394
|
+
AND SEQUENCE_NAME NOT LIKE 'ISEQ$$%'
|
|
395
|
+
AND SEQUENCE_NAME NOT LIKE 'BIN$%'
|
|
396
|
+
""", {"s": s})
|
|
397
|
+
rows = cur.fetchall()
|
|
398
|
+
cur.close()
|
|
399
|
+
|
|
400
|
+
def _n(v):
|
|
401
|
+
return int(v) if v is not None else None
|
|
402
|
+
|
|
403
|
+
return [
|
|
404
|
+
{"owner": r[0], "sequence_name": r[1],
|
|
405
|
+
"min_value": _n(r[2]), "max_value": _n(r[3]),
|
|
406
|
+
"increment_by": _n(r[4]),
|
|
407
|
+
"cycle_flag": (r[5] or "N"), "order_flag": (r[6] or "N"),
|
|
408
|
+
"cache_size": _n(r[7]), "last_number": _n(r[8])}
|
|
409
|
+
for r in rows
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
def extract_row_counts(self, schema: str, name_filter=None) -> dict:
|
|
413
|
+
"""Lightweight num_rows snapshot (one dictionary query) — the baseline
|
|
414
|
+
row-count 'idea' captured on every sync (source=DEV, since baucli
|
|
415
|
+
normally runs against the dev environment). Read-only, no data scan.
|
|
416
|
+
|
|
417
|
+
name_filter: optional collection of table names (scope sets) — the
|
|
418
|
+
snapshot covers ONLY those tables; a scoped project must not push a
|
|
419
|
+
whole-ERP profile (thousands of rows) to /profile/ingest."""
|
|
420
|
+
s = schema.upper()
|
|
421
|
+
cur = self._conn.cursor()
|
|
422
|
+
try:
|
|
423
|
+
cur.execute("SELECT 1 FROM DBA_TABLES WHERE ROWNUM=1")
|
|
424
|
+
prefix = "DBA"
|
|
425
|
+
except Exception:
|
|
426
|
+
prefix = "ALL"
|
|
427
|
+
params = {"s": s}
|
|
428
|
+
name_clause = ""
|
|
429
|
+
if isinstance(name_filter, (list, set, tuple)):
|
|
430
|
+
names = sorted({str(n).upper() for n in name_filter})
|
|
431
|
+
if not names:
|
|
432
|
+
cur.close()
|
|
433
|
+
return {"schema": s, "source": "DEV", "tables": []}
|
|
434
|
+
name_clause = "AND TABLE_NAME IN (%s)" % ",".join(
|
|
435
|
+
f":rc{i}" for i in range(len(names)))
|
|
436
|
+
params.update({f"rc{i}": n for i, n in enumerate(names)})
|
|
437
|
+
cur.execute(f"SELECT TABLE_NAME, NUM_ROWS FROM {prefix}_TABLES WHERE OWNER = :s "
|
|
438
|
+
f"{name_clause}", params)
|
|
439
|
+
tables = [{"name": r[0], "num_rows": r[1]} for r in cur.fetchall()]
|
|
440
|
+
cur.close()
|
|
441
|
+
return {"schema": s, "source": "DEV", "tables": tables}
|
|
442
|
+
|
|
443
|
+
def extract_profile(self, schema: str) -> dict:
|
|
444
|
+
"""Data profile from the client DB optimizer stats (Phase 4).
|
|
445
|
+
|
|
446
|
+
Per table: NUM_ROWS; per column: NUM_DISTINCT, NUM_NULLS, NULL_PCT,
|
|
447
|
+
SAMPLE_SIZE, HISTOGRAM and the top frequent values (from
|
|
448
|
+
{DBA|ALL}_TAB_HISTOGRAMS, decoded endpoint_actual_value only). Read-only
|
|
449
|
+
on the dictionary; sent to /profile/ingest. Never scans table data."""
|
|
450
|
+
s = schema.upper()
|
|
451
|
+
cur = self._conn.cursor()
|
|
452
|
+
try:
|
|
453
|
+
cur.execute("SELECT 1 FROM DBA_TABLES WHERE ROWNUM=1")
|
|
454
|
+
prefix = "DBA"
|
|
455
|
+
except Exception:
|
|
456
|
+
prefix = "ALL"
|
|
457
|
+
|
|
458
|
+
cur.execute(f"SELECT TABLE_NAME, NUM_ROWS FROM {prefix}_TABLES WHERE OWNER = :s",
|
|
459
|
+
{"s": s})
|
|
460
|
+
num_rows = {r[0]: r[1] for r in cur.fetchall()}
|
|
461
|
+
|
|
462
|
+
cols = {}
|
|
463
|
+
cur.execute(f"""
|
|
464
|
+
SELECT TABLE_NAME, COLUMN_NAME, NUM_DISTINCT, NUM_NULLS, SAMPLE_SIZE, HISTOGRAM
|
|
465
|
+
FROM {prefix}_TAB_COLUMNS WHERE OWNER = :s
|
|
466
|
+
""", {"s": s})
|
|
467
|
+
for tn, cn, nd, nn, ss, hist in cur.fetchall():
|
|
468
|
+
nr = num_rows.get(tn)
|
|
469
|
+
null_pct = round(nn / nr * 100, 2) if nr and nn is not None and nr > 0 else None
|
|
470
|
+
cols.setdefault(tn, []).append({
|
|
471
|
+
"name": cn, "num_distinct": nd, "num_nulls": nn, "null_pct": null_pct,
|
|
472
|
+
"sample_size": ss, "histogram": hist, "top_values": None,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
# top frequent values from frequency-style histograms (decoded only)
|
|
476
|
+
from collections import defaultdict
|
|
477
|
+
buckets = defaultdict(list)
|
|
478
|
+
prev = {}
|
|
479
|
+
cur.execute(f"""
|
|
480
|
+
SELECT TABLE_NAME, COLUMN_NAME, endpoint_actual_value, endpoint_number
|
|
481
|
+
FROM {prefix}_TAB_HISTOGRAMS
|
|
482
|
+
WHERE OWNER = :s AND endpoint_actual_value IS NOT NULL
|
|
483
|
+
ORDER BY TABLE_NAME, COLUMN_NAME, endpoint_number
|
|
484
|
+
""", {"s": s})
|
|
485
|
+
for tn, cn, val, epn in cur.fetchall():
|
|
486
|
+
key = (tn, cn)
|
|
487
|
+
cnt = (epn or 0) - prev.get(key, 0)
|
|
488
|
+
prev[key] = epn or 0
|
|
489
|
+
buckets[key].append((val, cnt))
|
|
490
|
+
for (tn, cn), vals in buckets.items():
|
|
491
|
+
vals.sort(key=lambda x: x[1], reverse=True)
|
|
492
|
+
top = " | ".join(f"{v}:{c}" for v, c in vals[:6])[:2000]
|
|
493
|
+
for cd in cols.get(tn, []):
|
|
494
|
+
if cd["name"] == cn:
|
|
495
|
+
cd["top_values"] = top
|
|
496
|
+
break
|
|
497
|
+
cur.close()
|
|
498
|
+
|
|
499
|
+
tables = [{"name": tn, "num_rows": num_rows.get(tn), "columns": cols[tn]}
|
|
500
|
+
for tn in sorted(cols)]
|
|
501
|
+
return {"schema": s, "tables": tables}
|
|
502
|
+
|
|
503
|
+
def extract_objects(self, schema: str, since_date: str = None, on_progress=None,
|
|
504
|
+
name_filter: str = None, include_invalid: bool = False) -> list:
|
|
505
|
+
"""Extract PL/SQL objects from DBA_SOURCE/ALL_SOURCE.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
schema: Schema/owner name
|
|
509
|
+
since_date: If provided, only extract objects modified since this date (ISO format)
|
|
510
|
+
name_filter: If provided, restrict to OBJECT_NAME = UPPER(name_filter).
|
|
511
|
+
A collection (list/set/tuple) restricts to OBJECT_NAME IN (...) —
|
|
512
|
+
used by scope sets; empty collection returns [].
|
|
513
|
+
include_invalid: If True, include INVALID objects too (with status='INVALID'
|
|
514
|
+
and without source body). Used by `baucli compare` to surface broken
|
|
515
|
+
objects instead of silently skipping them; default False keeps
|
|
516
|
+
normal sync behavior — sync never uploads invalid source.
|
|
517
|
+
"""
|
|
518
|
+
self._set_action(f"EXTRACT_OBJECTS:{schema}")
|
|
519
|
+
src_view = self._get_source_view(schema)
|
|
520
|
+
prefix = "DBA" if "DBA" in src_view else "ALL"
|
|
521
|
+
schema_upper = schema.upper()
|
|
522
|
+
|
|
523
|
+
cur = self._conn.cursor()
|
|
524
|
+
|
|
525
|
+
# Build WHERE clause
|
|
526
|
+
params = {"schema": schema_upper}
|
|
527
|
+
date_filter = ""
|
|
528
|
+
if since_date:
|
|
529
|
+
date_filter = "AND o.LAST_DDL_TIME >= TO_DATE(:since_date, 'YYYY-MM-DD\"T\"HH24:MI:SS')"
|
|
530
|
+
params["since_date"] = since_date
|
|
531
|
+
name_clause = ""
|
|
532
|
+
if isinstance(name_filter, (list, set, tuple)):
|
|
533
|
+
# Scope sets pass a collection: extract ONLY these objects — the
|
|
534
|
+
# catalog query filters server-side, so a scoped sync never walks
|
|
535
|
+
# the rest of a huge ERP schema.
|
|
536
|
+
names = sorted({str(n).upper() for n in name_filter})
|
|
537
|
+
if not names:
|
|
538
|
+
return []
|
|
539
|
+
name_clause = "AND o.OBJECT_NAME IN (%s)" % ",".join(
|
|
540
|
+
f":nf{i}" for i in range(len(names)))
|
|
541
|
+
params.update({f"nf{i}": n for i, n in enumerate(names)})
|
|
542
|
+
elif name_filter:
|
|
543
|
+
name_clause = "AND o.OBJECT_NAME = :obj_name"
|
|
544
|
+
params["obj_name"] = name_filter.upper()
|
|
545
|
+
|
|
546
|
+
# Get distinct objects (include INVALID when asked, e.g. for compare drift)
|
|
547
|
+
status_clause = "" if include_invalid else "AND o.STATUS = 'VALID'"
|
|
548
|
+
cur.execute(f"""
|
|
549
|
+
SELECT DISTINCT o.OBJECT_TYPE, o.OBJECT_NAME, o.STATUS,
|
|
550
|
+
TO_CHAR(o.LAST_DDL_TIME, 'YYYY-MM-DD"T"HH24:MI:SS') AS LAST_DDL
|
|
551
|
+
FROM {prefix}_OBJECTS o
|
|
552
|
+
WHERE o.OWNER = :schema
|
|
553
|
+
AND o.OBJECT_TYPE IN ('PACKAGE','PACKAGE BODY','FUNCTION','PROCEDURE','TRIGGER')
|
|
554
|
+
AND o.OBJECT_NAME NOT LIKE 'BIN$%%'
|
|
555
|
+
AND o.OBJECT_NAME NOT LIKE 'SYS_%%'
|
|
556
|
+
-- BAU never documents its OWN generated lookup functions
|
|
557
|
+
-- (FNC_BAU_ERROR_MSG, FNC_BAU_DOMAIN_LABEL) emitted into the client
|
|
558
|
+
-- schema by the baucli provision/apply scripts.
|
|
559
|
+
AND o.OBJECT_NAME NOT LIKE 'FNC_BAU_%%'
|
|
560
|
+
{status_clause}
|
|
561
|
+
{name_clause}
|
|
562
|
+
{date_filter}
|
|
563
|
+
ORDER BY o.OBJECT_TYPE, o.OBJECT_NAME
|
|
564
|
+
""", params)
|
|
565
|
+
|
|
566
|
+
obj_list = cur.fetchall()
|
|
567
|
+
total_obj = len(obj_list)
|
|
568
|
+
result = []
|
|
569
|
+
|
|
570
|
+
for idx, (obj_type, obj_name, obj_status, last_ddl) in enumerate(obj_list):
|
|
571
|
+
if on_progress:
|
|
572
|
+
on_progress(idx + 1, total_obj, obj_name)
|
|
573
|
+
|
|
574
|
+
# For INVALID objects we still emit a stub entry (no source) so
|
|
575
|
+
# drift can flag them; only the source body is skipped.
|
|
576
|
+
if obj_status != "VALID":
|
|
577
|
+
result.append({
|
|
578
|
+
"name": obj_name,
|
|
579
|
+
"type": obj_type,
|
|
580
|
+
"source": None,
|
|
581
|
+
"last_ddl": last_ddl,
|
|
582
|
+
"status": obj_status,
|
|
583
|
+
})
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
# Get source lines
|
|
587
|
+
cur.execute(f"""
|
|
588
|
+
SELECT TEXT FROM {src_view}
|
|
589
|
+
WHERE OWNER = :schema AND NAME = :name AND TYPE = :otype
|
|
590
|
+
ORDER BY LINE
|
|
591
|
+
""", {"schema": schema_upper, "name": obj_name, "otype": obj_type})
|
|
592
|
+
|
|
593
|
+
lines = [row[0] for row in cur.fetchall()]
|
|
594
|
+
if not lines:
|
|
595
|
+
continue
|
|
596
|
+
|
|
597
|
+
source = "".join(lines)
|
|
598
|
+
|
|
599
|
+
# DBA_SOURCE.text already starts with "<TYPE> name ..." for every
|
|
600
|
+
# supported object — only the CREATE OR REPLACE wrapper is missing.
|
|
601
|
+
# Earlier versions prepended the full "<TYPE> {schema}.{name}\n" on
|
|
602
|
+
# top, duplicating the type+name; Oracle tolerated it for PACKAGE
|
|
603
|
+
# but rejected for TRIGGER (ORA-04071) and FUNCTION/PROCEDURE.
|
|
604
|
+
# Matches the server-side PKG_BAU_DOCPL_CORE.SYNC_OBJECTS behavior.
|
|
605
|
+
if obj_type in ("PACKAGE", "PACKAGE BODY", "FUNCTION", "PROCEDURE", "TRIGGER"):
|
|
606
|
+
source = f"CREATE OR REPLACE {source}"
|
|
607
|
+
|
|
608
|
+
result.append({
|
|
609
|
+
"name": obj_name,
|
|
610
|
+
"type": obj_type,
|
|
611
|
+
"source": source,
|
|
612
|
+
"last_ddl": last_ddl,
|
|
613
|
+
"status": "VALID",
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
# Bulk-fetch subprograms (PROCEDURE/FUNCTION inside packages) — one query for
|
|
617
|
+
# the whole schema, then attach to the matching parent. Type is detected via
|
|
618
|
+
# DBA_ARGUMENTS (POSITION=0 exists ⇒ FUNCTION; else PROCEDURE).
|
|
619
|
+
# With a small extracted set (scoped sync / --object), BOTH dictionary
|
|
620
|
+
# scans are restricted to the extracted names — ALL_PROCEDURES/
|
|
621
|
+
# ALL_ARGUMENTS schema-wide on a big ERP cost minutes for 4 objects.
|
|
622
|
+
proc_view = "DBA_PROCEDURES" if prefix == "DBA" else "ALL_PROCEDURES"
|
|
623
|
+
arg_view = "DBA_ARGUMENTS" if prefix == "DBA" else "ALL_ARGUMENTS"
|
|
624
|
+
pkg_names = sorted({o["name"] for o in result
|
|
625
|
+
if o["type"] in ("PACKAGE", "PACKAGE BODY")})
|
|
626
|
+
sa_names = sorted({o["name"] for o in result
|
|
627
|
+
if o["type"] in ("FUNCTION", "PROCEDURE")})
|
|
628
|
+
_small = 0 < (len(pkg_names) + len(sa_names)) <= 500
|
|
629
|
+
|
|
630
|
+
sub_clause = ""
|
|
631
|
+
sub_params = {"schema": schema_upper}
|
|
632
|
+
if _small and pkg_names:
|
|
633
|
+
sub_clause = "AND p.OBJECT_NAME IN (%s)" % ",".join(
|
|
634
|
+
f":sp{i}" for i in range(len(pkg_names)))
|
|
635
|
+
sub_params.update({f"sp{i}": n for i, n in enumerate(pkg_names)})
|
|
636
|
+
|
|
637
|
+
arg_clause = ""
|
|
638
|
+
arg_params = {"schema": schema_upper}
|
|
639
|
+
if _small:
|
|
640
|
+
ors = []
|
|
641
|
+
if pkg_names:
|
|
642
|
+
ors.append("a.PACKAGE_NAME IN (%s)" % ",".join(
|
|
643
|
+
f":ap{i}" for i in range(len(pkg_names))))
|
|
644
|
+
arg_params.update({f"ap{i}": n for i, n in enumerate(pkg_names)})
|
|
645
|
+
if sa_names:
|
|
646
|
+
ors.append("(a.PACKAGE_NAME IS NULL AND a.OBJECT_NAME IN (%s))"
|
|
647
|
+
% ",".join(f":as{i}" for i in range(len(sa_names))))
|
|
648
|
+
arg_params.update({f"as{i}": n for i, n in enumerate(sa_names)})
|
|
649
|
+
arg_clause = "AND (%s)" % " OR ".join(ors) if ors else "AND 1=0"
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
subs_by_pkg = {}
|
|
653
|
+
if not _small or pkg_names:
|
|
654
|
+
cur2 = self._conn.cursor()
|
|
655
|
+
cur2.execute(f"""
|
|
656
|
+
SELECT p.OBJECT_NAME, p.PROCEDURE_NAME,
|
|
657
|
+
CASE WHEN EXISTS (
|
|
658
|
+
SELECT 1 FROM {arg_view} a
|
|
659
|
+
WHERE a.OWNER = p.OWNER
|
|
660
|
+
AND a.PACKAGE_NAME = p.OBJECT_NAME
|
|
661
|
+
AND a.OBJECT_NAME = p.PROCEDURE_NAME
|
|
662
|
+
AND a.POSITION = 0
|
|
663
|
+
) THEN 'FUNCTION' ELSE 'PROCEDURE' END AS SUBPROG_TYPE
|
|
664
|
+
FROM {proc_view} p
|
|
665
|
+
WHERE p.OWNER = :schema
|
|
666
|
+
AND p.PROCEDURE_NAME IS NOT NULL
|
|
667
|
+
AND p.OBJECT_TYPE IN ('PACKAGE','PACKAGE BODY')
|
|
668
|
+
{sub_clause}
|
|
669
|
+
ORDER BY p.OBJECT_NAME, p.PROCEDURE_NAME
|
|
670
|
+
""", sub_params)
|
|
671
|
+
seen = set()
|
|
672
|
+
for pkg_name, sub_name, sub_type in cur2.fetchall():
|
|
673
|
+
key = (pkg_name, sub_name)
|
|
674
|
+
if key in seen:
|
|
675
|
+
continue # collapse overloads to first occurrence
|
|
676
|
+
seen.add(key)
|
|
677
|
+
subs_by_pkg.setdefault(pkg_name, []).append({
|
|
678
|
+
"name": sub_name, "type": sub_type,
|
|
679
|
+
})
|
|
680
|
+
cur2.close()
|
|
681
|
+
|
|
682
|
+
# Bulk-fetch parameters (restricted to the extracted names when
|
|
683
|
+
# the set is small). Bucket by (package_name, object_name) so we
|
|
684
|
+
# can attach to top-level FUNCTION/PROCEDURE and to package
|
|
685
|
+
# subprograms.
|
|
686
|
+
cur3 = self._conn.cursor()
|
|
687
|
+
cur3.execute(f"""
|
|
688
|
+
SELECT a.PACKAGE_NAME, a.OBJECT_NAME, a.POSITION,
|
|
689
|
+
a.ARGUMENT_NAME, a.IN_OUT, a.DATA_TYPE, a.DEFAULTED
|
|
690
|
+
FROM {arg_view} a
|
|
691
|
+
WHERE a.OWNER = :schema
|
|
692
|
+
AND a.DATA_LEVEL = 0
|
|
693
|
+
{arg_clause}
|
|
694
|
+
ORDER BY a.PACKAGE_NAME NULLS FIRST,
|
|
695
|
+
a.OBJECT_NAME, a.POSITION
|
|
696
|
+
""", arg_params)
|
|
697
|
+
params_by_key = {}
|
|
698
|
+
for pkg, obj, pos, arg_name, in_out, data_type, defaulted in cur3.fetchall():
|
|
699
|
+
key = (pkg, obj) # pkg may be None for standalone proc/func
|
|
700
|
+
if pos == 0 and arg_name is None:
|
|
701
|
+
param_type = "RETURN"
|
|
702
|
+
param_name = "(return)"
|
|
703
|
+
else:
|
|
704
|
+
param_type = in_out or "IN"
|
|
705
|
+
param_name = arg_name
|
|
706
|
+
params_by_key.setdefault(key, []).append({
|
|
707
|
+
"name": param_name,
|
|
708
|
+
"type": param_type,
|
|
709
|
+
"data_type": data_type,
|
|
710
|
+
"position": pos,
|
|
711
|
+
"defaulted": defaulted or "N",
|
|
712
|
+
})
|
|
713
|
+
cur3.close()
|
|
714
|
+
|
|
715
|
+
# Attach params to top-level FUNCTION/PROCEDURE
|
|
716
|
+
for obj in result:
|
|
717
|
+
if obj["type"] in ("FUNCTION", "PROCEDURE"):
|
|
718
|
+
obj["params"] = params_by_key.get((None, obj["name"]), [])
|
|
719
|
+
|
|
720
|
+
# Attach subprograms (with their params) to PACKAGE/PACKAGE BODY
|
|
721
|
+
for obj in result:
|
|
722
|
+
if obj["type"] in ("PACKAGE", "PACKAGE BODY"):
|
|
723
|
+
subs = subs_by_pkg.get(obj["name"])
|
|
724
|
+
if subs:
|
|
725
|
+
for sp in subs:
|
|
726
|
+
sp["params"] = params_by_key.get(
|
|
727
|
+
(obj["name"], sp["name"]), []
|
|
728
|
+
)
|
|
729
|
+
obj["subprograms"] = subs
|
|
730
|
+
except Exception:
|
|
731
|
+
# Subprogram/params extraction failure must never block the sync — best-effort.
|
|
732
|
+
pass
|
|
733
|
+
|
|
734
|
+
cur.close()
|
|
735
|
+
return result
|
|
736
|
+
|
|
737
|
+
def extract_tables(self, schema: str, since_date: str = None, on_progress=None,
|
|
738
|
+
name_filter: str = None) -> list:
|
|
739
|
+
"""Extract table metadata from DBA/ALL views.
|
|
740
|
+
|
|
741
|
+
Args:
|
|
742
|
+
schema: Schema/owner name
|
|
743
|
+
since_date: If provided, only extract tables modified since this date (ISO format)
|
|
744
|
+
name_filter: If provided, restrict to TABLE_NAME = UPPER(name_filter).
|
|
745
|
+
A collection (list/set/tuple) restricts to TABLE_NAME IN (...) —
|
|
746
|
+
used by scope sets; empty collection returns [].
|
|
747
|
+
"""
|
|
748
|
+
self._set_action(f"EXTRACT_TABLES:{schema}")
|
|
749
|
+
prefix = self._get_tab_view(schema)
|
|
750
|
+
schema_upper = schema.upper()
|
|
751
|
+
|
|
752
|
+
cur = self._conn.cursor()
|
|
753
|
+
|
|
754
|
+
# Build WHERE clause
|
|
755
|
+
params = {"schema": schema_upper}
|
|
756
|
+
date_filter = ""
|
|
757
|
+
if since_date:
|
|
758
|
+
date_filter = "AND o.LAST_DDL_TIME >= TO_DATE(:since_date, 'YYYY-MM-DD\"T\"HH24:MI:SS')"
|
|
759
|
+
params["since_date"] = since_date
|
|
760
|
+
name_clause = ""
|
|
761
|
+
if isinstance(name_filter, (list, set, tuple)):
|
|
762
|
+
# Scope sets pass a collection: extract ONLY these tables/views.
|
|
763
|
+
names = sorted({str(n).upper() for n in name_filter})
|
|
764
|
+
if not names:
|
|
765
|
+
return []
|
|
766
|
+
name_clause = "AND o.OBJECT_NAME IN (%s)" % ",".join(
|
|
767
|
+
f":tf{i}" for i in range(len(names)))
|
|
768
|
+
params.update({f"tf{i}": n for i, n in enumerate(names)})
|
|
769
|
+
elif name_filter:
|
|
770
|
+
name_clause = "AND o.OBJECT_NAME = :tbl_name"
|
|
771
|
+
params["tbl_name"] = name_filter.upper()
|
|
772
|
+
|
|
773
|
+
# Get tables AND views with DDL time.
|
|
774
|
+
# NOTE: use o.OBJECT_NAME as the name (always populated). Using
|
|
775
|
+
# t.TABLE_NAME dropped every VIEW: the _TABLES join only matches
|
|
776
|
+
# OBJECT_TYPE='TABLE', so views came back with a NULL name and were
|
|
777
|
+
# silently discarded downstream (tables synced, views did not).
|
|
778
|
+
cur.execute(f"""
|
|
779
|
+
SELECT o.OBJECT_NAME AS TABLE_NAME,
|
|
780
|
+
TO_CHAR(o.LAST_DDL_TIME, 'YYYY-MM-DD"T"HH24:MI:SS') AS LAST_DDL,
|
|
781
|
+
tc.COMMENTS AS TABLE_COMMENT,
|
|
782
|
+
o.OBJECT_TYPE
|
|
783
|
+
FROM {prefix}_OBJECTS o
|
|
784
|
+
LEFT JOIN {prefix}_TABLES t ON t.OWNER = o.OWNER AND t.TABLE_NAME = o.OBJECT_NAME AND o.OBJECT_TYPE = 'TABLE'
|
|
785
|
+
LEFT JOIN {prefix}_VIEWS v ON v.OWNER = o.OWNER AND v.VIEW_NAME = o.OBJECT_NAME AND o.OBJECT_TYPE = 'VIEW'
|
|
786
|
+
LEFT JOIN {prefix}_TAB_COMMENTS tc ON tc.OWNER = o.OWNER AND tc.TABLE_NAME = o.OBJECT_NAME
|
|
787
|
+
WHERE o.OWNER = :schema
|
|
788
|
+
AND o.OBJECT_TYPE IN ('TABLE', 'VIEW', 'MATERIALIZED VIEW')
|
|
789
|
+
AND o.OBJECT_NAME NOT LIKE 'BIN$%%'
|
|
790
|
+
AND o.OBJECT_NAME NOT LIKE 'SYS_%%'
|
|
791
|
+
AND o.OBJECT_NAME NOT LIKE 'MLOG$%%'
|
|
792
|
+
AND o.OBJECT_NAME NOT LIKE 'RUPD$%%'
|
|
793
|
+
AND o.OBJECT_NAME NOT LIKE 'ANNOTATIONS_%%'
|
|
794
|
+
-- 23ai/APEX annotation helper objects: METADATA_ANNOTATIONS* +
|
|
795
|
+
-- METADATA_PREBUILT_ANNOTATIONS (view). METADATA%ANNOTATIONS% cobre a família.
|
|
796
|
+
AND o.OBJECT_NAME NOT LIKE 'METADATA%%ANNOTATIONS%%'
|
|
797
|
+
-- BAU never documents its OWN generated helper tables (domain values +
|
|
798
|
+
-- error-message lookup): BAU_DOMAINS/BAU_DOMAIN_VALUES/BAU_DOMAIN_BINDINGS/
|
|
799
|
+
-- BAU_ERROR_MESSAGES, emitted into the client schema by the baucli
|
|
800
|
+
-- provision/apply scripts. BAU_ is the platform's reserved prefix.
|
|
801
|
+
AND o.OBJECT_NAME NOT LIKE 'BAU_%%'
|
|
802
|
+
{name_clause}
|
|
803
|
+
{date_filter}
|
|
804
|
+
ORDER BY o.OBJECT_TYPE, o.OBJECT_NAME
|
|
805
|
+
""", params)
|
|
806
|
+
|
|
807
|
+
tables = cur.fetchall()
|
|
808
|
+
total_tbl = len(tables)
|
|
809
|
+
result = []
|
|
810
|
+
|
|
811
|
+
if total_tbl == 0:
|
|
812
|
+
cur.close()
|
|
813
|
+
return result
|
|
814
|
+
|
|
815
|
+
CHUNK = 500
|
|
816
|
+
|
|
817
|
+
# ═══ Pre-load columns in chunks ═══
|
|
818
|
+
all_columns = {}
|
|
819
|
+
table_names = [t[0] for t in tables if t[0]]
|
|
820
|
+
col_count = 0
|
|
821
|
+
|
|
822
|
+
# For small table sets (scoped sync / --table) the shared pre-loads
|
|
823
|
+
# below (comments/PKs/constraints/indexes) must NOT scan the whole
|
|
824
|
+
# schema: on a 6000-table ERP those dictionary queries cost minutes
|
|
825
|
+
# for a 10-table recorte. Restrict them by TABLE_NAME IN (...).
|
|
826
|
+
_pre_names = table_names if 0 < len(table_names) <= CHUNK else []
|
|
827
|
+
_pre_params = {f"pt{i}": tn for i, tn in enumerate(_pre_names)}
|
|
828
|
+
|
|
829
|
+
def _pre_clause(alias: str = "") -> str:
|
|
830
|
+
if not _pre_names:
|
|
831
|
+
return ""
|
|
832
|
+
col = (alias + "." if alias else "") + "TABLE_NAME"
|
|
833
|
+
return "AND %s IN (%s)" % (
|
|
834
|
+
col, ",".join(f":pt{i}" for i in range(len(_pre_names))))
|
|
835
|
+
|
|
836
|
+
import time as _time
|
|
837
|
+
total_chunks = (len(table_names) + CHUNK - 1) // CHUNK
|
|
838
|
+
chunk_times = []
|
|
839
|
+
|
|
840
|
+
for ci, cs in enumerate(range(0, len(table_names), CHUNK)):
|
|
841
|
+
chunk = table_names[cs:cs + CHUNK]
|
|
842
|
+
t0 = _time.time()
|
|
843
|
+
|
|
844
|
+
eta = ""
|
|
845
|
+
if len(chunk_times) >= 2:
|
|
846
|
+
avg = sum(chunk_times) / len(chunk_times)
|
|
847
|
+
remaining = total_chunks - ci
|
|
848
|
+
eta_s = int(remaining * avg)
|
|
849
|
+
eta = f" ETA {eta_s//60}m{eta_s%60:02d}s" if eta_s >= 60 else f" ETA {eta_s}s"
|
|
850
|
+
|
|
851
|
+
if on_progress:
|
|
852
|
+
on_progress(0, total_tbl, f"loading columns {cs}/{total_tbl}{eta}")
|
|
853
|
+
|
|
854
|
+
bind_list = ','.join([f":t{i}" for i in range(len(chunk))])
|
|
855
|
+
bind_params = {"schema": schema_upper}
|
|
856
|
+
for i, tn in enumerate(chunk):
|
|
857
|
+
bind_params[f"t{i}"] = tn
|
|
858
|
+
|
|
859
|
+
cur.execute(f"""
|
|
860
|
+
SELECT c.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE,
|
|
861
|
+
CASE WHEN c.DATA_TYPE IN ('VARCHAR2','CHAR','NVARCHAR2') THEN c.DATA_TYPE || '(' || c.DATA_LENGTH || ')'
|
|
862
|
+
WHEN c.DATA_TYPE = 'NUMBER' AND c.DATA_PRECISION IS NOT NULL THEN c.DATA_TYPE || '(' || c.DATA_PRECISION || CASE WHEN c.DATA_SCALE > 0 THEN ',' || c.DATA_SCALE ELSE '' END || ')'
|
|
863
|
+
ELSE c.DATA_TYPE END AS FULL_TYPE,
|
|
864
|
+
c.NULLABLE, c.DATA_DEFAULT, c.COLUMN_ID,
|
|
865
|
+
c.DATA_LENGTH, c.DATA_PRECISION, c.DATA_SCALE
|
|
866
|
+
FROM {prefix}_TAB_COLUMNS c
|
|
867
|
+
WHERE c.OWNER = :schema AND c.TABLE_NAME IN ({bind_list})
|
|
868
|
+
ORDER BY c.TABLE_NAME, c.COLUMN_ID
|
|
869
|
+
""", bind_params)
|
|
870
|
+
for row in cur.fetchall():
|
|
871
|
+
tname = row[0]
|
|
872
|
+
if tname not in all_columns:
|
|
873
|
+
all_columns[tname] = []
|
|
874
|
+
all_columns[tname].append({
|
|
875
|
+
"name": row[1], "data_type": row[2], "type": row[3],
|
|
876
|
+
"nullable": row[4], "default": str(row[5]).strip() if row[5] else None,
|
|
877
|
+
"column_id": row[6],
|
|
878
|
+
"data_length": row[7], "precision": row[8], "scale": row[9],
|
|
879
|
+
})
|
|
880
|
+
col_count += 1
|
|
881
|
+
chunk_times.append(_time.time() - t0)
|
|
882
|
+
|
|
883
|
+
if on_progress:
|
|
884
|
+
on_progress(0, total_tbl, f"columns done ({col_count})")
|
|
885
|
+
|
|
886
|
+
if on_progress:
|
|
887
|
+
on_progress(0, total_tbl, "loading col comments...")
|
|
888
|
+
|
|
889
|
+
# ═══ Pre-load ALL column comments ═══
|
|
890
|
+
col_comments = {}
|
|
891
|
+
try:
|
|
892
|
+
cur.execute(f"""
|
|
893
|
+
SELECT TABLE_NAME, COLUMN_NAME, COMMENTS FROM {prefix}_COL_COMMENTS
|
|
894
|
+
WHERE OWNER = :schema AND COMMENTS IS NOT NULL
|
|
895
|
+
{_pre_clause()}
|
|
896
|
+
""", {"schema": schema_upper, **_pre_params})
|
|
897
|
+
for tname, cname, comment in cur.fetchall():
|
|
898
|
+
col_comments[f"{tname}.{cname}"] = comment
|
|
899
|
+
except Exception:
|
|
900
|
+
pass
|
|
901
|
+
|
|
902
|
+
if on_progress:
|
|
903
|
+
on_progress(0, total_tbl, "loading PKs...")
|
|
904
|
+
|
|
905
|
+
# ═══ Pre-load ALL primary keys ═══
|
|
906
|
+
pk_cols = set()
|
|
907
|
+
try:
|
|
908
|
+
cur.execute(f"""
|
|
909
|
+
SELECT acc.TABLE_NAME, acc.COLUMN_NAME
|
|
910
|
+
FROM {prefix}_CONSTRAINTS ac
|
|
911
|
+
JOIN {prefix}_CONS_COLUMNS acc ON acc.CONSTRAINT_NAME = ac.CONSTRAINT_NAME AND acc.OWNER = ac.OWNER
|
|
912
|
+
WHERE ac.OWNER = :schema AND ac.CONSTRAINT_TYPE = 'P'
|
|
913
|
+
{_pre_clause('ac')}
|
|
914
|
+
""", {"schema": schema_upper, **_pre_params})
|
|
915
|
+
for tname, cname in cur.fetchall():
|
|
916
|
+
pk_cols.add(f"{tname}.{cname}")
|
|
917
|
+
except Exception:
|
|
918
|
+
pass
|
|
919
|
+
|
|
920
|
+
if on_progress:
|
|
921
|
+
on_progress(0, total_tbl, "loading constraint columns...")
|
|
922
|
+
|
|
923
|
+
# ═══ Pre-load ALL constraint columns (for COLUMNS_LIST aggregation) ═══
|
|
924
|
+
cons_cols = {} # {constraint_name: [col1, col2, ...]} ordered
|
|
925
|
+
try:
|
|
926
|
+
cur.execute(f"""
|
|
927
|
+
SELECT CONSTRAINT_NAME, COLUMN_NAME
|
|
928
|
+
FROM {prefix}_CONS_COLUMNS
|
|
929
|
+
WHERE OWNER = :schema
|
|
930
|
+
{_pre_clause()}
|
|
931
|
+
ORDER BY CONSTRAINT_NAME, POSITION
|
|
932
|
+
""", {"schema": schema_upper, **_pre_params})
|
|
933
|
+
for cname, col in cur.fetchall():
|
|
934
|
+
cons_cols.setdefault(cname, []).append(col)
|
|
935
|
+
except Exception:
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
if on_progress:
|
|
939
|
+
on_progress(0, total_tbl, "loading constraints...")
|
|
940
|
+
|
|
941
|
+
# ═══ Pre-load ALL constraints (now includes P/U/R/C and columns_list) ═══
|
|
942
|
+
all_constraints = {}
|
|
943
|
+
try:
|
|
944
|
+
cur.execute(f"""
|
|
945
|
+
SELECT ac.TABLE_NAME, ac.CONSTRAINT_NAME, ac.CONSTRAINT_TYPE,
|
|
946
|
+
ac.SEARCH_CONDITION,
|
|
947
|
+
(SELECT TABLE_NAME FROM {prefix}_CONSTRAINTS WHERE CONSTRAINT_NAME = ac.R_CONSTRAINT_NAME AND OWNER = ac.OWNER AND ROWNUM = 1) AS REF_TABLE
|
|
948
|
+
FROM {prefix}_CONSTRAINTS ac
|
|
949
|
+
WHERE ac.OWNER = :schema AND ac.CONSTRAINT_TYPE IN ('P','U','R','C')
|
|
950
|
+
{_pre_clause('ac')}
|
|
951
|
+
ORDER BY ac.TABLE_NAME, ac.CONSTRAINT_TYPE
|
|
952
|
+
""", {"schema": schema_upper, **_pre_params})
|
|
953
|
+
for tname, cname, ctype, search_cond, ref_table in cur.fetchall():
|
|
954
|
+
if tname not in all_constraints:
|
|
955
|
+
all_constraints[tname] = []
|
|
956
|
+
c = {"name": cname, "type": ctype}
|
|
957
|
+
cols = cons_cols.get(cname)
|
|
958
|
+
if cols:
|
|
959
|
+
c["columns_list"] = ",".join(cols)
|
|
960
|
+
if search_cond:
|
|
961
|
+
c["condition"] = str(search_cond)
|
|
962
|
+
if ref_table:
|
|
963
|
+
c["ref_table"] = ref_table
|
|
964
|
+
all_constraints[tname].append(c)
|
|
965
|
+
except Exception:
|
|
966
|
+
pass
|
|
967
|
+
|
|
968
|
+
if on_progress:
|
|
969
|
+
on_progress(0, total_tbl, "loading indexes...")
|
|
970
|
+
|
|
971
|
+
# ═══ Pre-load ALL indexes + columns ═══
|
|
972
|
+
idx_cols = {} # {index_name: [col1, col2, ...]} ordered
|
|
973
|
+
try:
|
|
974
|
+
cur.execute(f"""
|
|
975
|
+
SELECT INDEX_NAME, COLUMN_NAME
|
|
976
|
+
FROM {prefix}_IND_COLUMNS
|
|
977
|
+
WHERE INDEX_OWNER = :schema
|
|
978
|
+
{_pre_clause()}
|
|
979
|
+
ORDER BY INDEX_NAME, COLUMN_POSITION
|
|
980
|
+
""", {"schema": schema_upper, **_pre_params})
|
|
981
|
+
for iname, col in cur.fetchall():
|
|
982
|
+
idx_cols.setdefault(iname, []).append(col)
|
|
983
|
+
except Exception:
|
|
984
|
+
pass
|
|
985
|
+
|
|
986
|
+
all_indexes = {} # {table_name: [{name,type,uniqueness,columns_list,...}, ...]}
|
|
987
|
+
try:
|
|
988
|
+
cur.execute(f"""
|
|
989
|
+
SELECT TABLE_NAME, INDEX_NAME, INDEX_TYPE, UNIQUENESS,
|
|
990
|
+
TABLESPACE_NAME, STATUS, PARTITIONED
|
|
991
|
+
FROM {prefix}_INDEXES
|
|
992
|
+
WHERE OWNER = :schema
|
|
993
|
+
AND INDEX_NAME NOT LIKE 'BIN$%%'
|
|
994
|
+
{_pre_clause()}
|
|
995
|
+
ORDER BY TABLE_NAME, INDEX_NAME
|
|
996
|
+
""", {"schema": schema_upper, **_pre_params})
|
|
997
|
+
for tname, iname, itype, uniq, ts, status, partitioned in cur.fetchall():
|
|
998
|
+
if tname not in all_indexes:
|
|
999
|
+
all_indexes[tname] = []
|
|
1000
|
+
ix = {"name": iname, "type": itype, "uniqueness": uniq}
|
|
1001
|
+
cols = idx_cols.get(iname)
|
|
1002
|
+
if cols:
|
|
1003
|
+
ix["columns_list"] = ",".join(cols)
|
|
1004
|
+
if ts:
|
|
1005
|
+
ix["tablespace"] = ts
|
|
1006
|
+
if status:
|
|
1007
|
+
ix["status"] = status
|
|
1008
|
+
if partitioned:
|
|
1009
|
+
ix["partitioned"] = partitioned
|
|
1010
|
+
all_indexes[tname].append(ix)
|
|
1011
|
+
except Exception:
|
|
1012
|
+
pass
|
|
1013
|
+
|
|
1014
|
+
if on_progress:
|
|
1015
|
+
on_progress(0, total_tbl, "building entries...")
|
|
1016
|
+
|
|
1017
|
+
# ═══ Build results per table ═══
|
|
1018
|
+
for idx, (table_name_raw, last_ddl, table_comment, obj_type) in enumerate(tables):
|
|
1019
|
+
table_name = table_name_raw or ''
|
|
1020
|
+
if not table_name:
|
|
1021
|
+
continue
|
|
1022
|
+
|
|
1023
|
+
if on_progress:
|
|
1024
|
+
on_progress(idx + 1, total_tbl, table_name)
|
|
1025
|
+
|
|
1026
|
+
bau_type = obj_type
|
|
1027
|
+
|
|
1028
|
+
# View source (still per-object — small number of views)
|
|
1029
|
+
view_source = None
|
|
1030
|
+
if obj_type == 'VIEW':
|
|
1031
|
+
try:
|
|
1032
|
+
cur.execute(f"SELECT TEXT FROM {prefix}_VIEWS WHERE OWNER = :schema AND VIEW_NAME = :vname",
|
|
1033
|
+
{"schema": schema_upper, "vname": table_name})
|
|
1034
|
+
row = cur.fetchone()
|
|
1035
|
+
if row and row[0]:
|
|
1036
|
+
view_source = f"CREATE OR REPLACE VIEW {schema_upper}.{table_name} AS\n{row[0]}"
|
|
1037
|
+
except Exception:
|
|
1038
|
+
pass
|
|
1039
|
+
elif obj_type == 'MATERIALIZED VIEW':
|
|
1040
|
+
try:
|
|
1041
|
+
cur.execute(f"SELECT QUERY FROM {prefix}_MVIEWS WHERE OWNER = :schema AND MVIEW_NAME = :vname",
|
|
1042
|
+
{"schema": schema_upper, "vname": table_name})
|
|
1043
|
+
row = cur.fetchone()
|
|
1044
|
+
if row and row[0]:
|
|
1045
|
+
view_source = f"CREATE MATERIALIZED VIEW {schema_upper}.{table_name} AS\n{row[0]}"
|
|
1046
|
+
except Exception:
|
|
1047
|
+
pass
|
|
1048
|
+
|
|
1049
|
+
# Columns from pre-loaded dict
|
|
1050
|
+
columns = []
|
|
1051
|
+
for c in all_columns.get(table_name, []):
|
|
1052
|
+
col = {"name": c["name"], "type": c["type"], "nullable": c["nullable"]}
|
|
1053
|
+
if c.get("column_id") is not None:
|
|
1054
|
+
col["column_id"] = c["column_id"]
|
|
1055
|
+
if f"{table_name}.{c['name']}" in pk_cols:
|
|
1056
|
+
col["is_pk"] = "Y"
|
|
1057
|
+
if c["default"]:
|
|
1058
|
+
col["default"] = c["default"]
|
|
1059
|
+
comment = col_comments.get(f"{table_name}.{c['name']}")
|
|
1060
|
+
if comment:
|
|
1061
|
+
col["comment"] = comment
|
|
1062
|
+
# UI inference (server-side) needs raw size metadata
|
|
1063
|
+
if c.get("data_length") is not None:
|
|
1064
|
+
col["data_length"] = c["data_length"]
|
|
1065
|
+
if c.get("precision") is not None:
|
|
1066
|
+
col["precision"] = c["precision"]
|
|
1067
|
+
if c.get("scale") is not None:
|
|
1068
|
+
col["scale"] = c["scale"]
|
|
1069
|
+
columns.append(col)
|
|
1070
|
+
|
|
1071
|
+
constraints = all_constraints.get(table_name, [])
|
|
1072
|
+
indexes = all_indexes.get(table_name, [])
|
|
1073
|
+
|
|
1074
|
+
entry = {
|
|
1075
|
+
"name": table_name,
|
|
1076
|
+
"type": bau_type,
|
|
1077
|
+
"last_ddl": last_ddl,
|
|
1078
|
+
"columns": columns,
|
|
1079
|
+
}
|
|
1080
|
+
if table_comment:
|
|
1081
|
+
entry["comment"] = table_comment
|
|
1082
|
+
if constraints:
|
|
1083
|
+
entry["constraints"] = constraints
|
|
1084
|
+
if indexes:
|
|
1085
|
+
entry["indexes"] = indexes
|
|
1086
|
+
if view_source:
|
|
1087
|
+
entry["source"] = view_source
|
|
1088
|
+
result.append(entry)
|
|
1089
|
+
|
|
1090
|
+
cur.close()
|
|
1091
|
+
return result
|
|
1092
|
+
|
|
1093
|
+
# ─────────────────────────────────────────────────────────
|
|
1094
|
+
# APEX extractor (DOCAPEX module)
|
|
1095
|
+
# ─────────────────────────────────────────────────────────
|
|
1096
|
+
def extract_apex(self, schema: str, application_id: int, on_progress=None) -> dict:
|
|
1097
|
+
"""Snapshot one Oracle APEX application from the APEX_* dictionary views.
|
|
1098
|
+
|
|
1099
|
+
Returns a dict matching the shape consumed by SYNC_APEX_APP on the server:
|
|
1100
|
+
{
|
|
1101
|
+
"schema": <upper schema>,
|
|
1102
|
+
"app": { application_id, app_name, app_alias, ... },
|
|
1103
|
+
"pages": [ {page_id, page_name, ...}, ... ],
|
|
1104
|
+
"regions": [ {page_id, region_id, ...}, ... ],
|
|
1105
|
+
"items": [ {page_id, region_id, item_id, ...}, ... ],
|
|
1106
|
+
"proc": [ {page_id, process_id, ...}, ... ],
|
|
1107
|
+
"da": [ {page_id, region_id, da_id, ...}, ... ]
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
The agent must have SELECT on APEX_APPLICATIONS and the APEX_APPLICATION_PAGE_*
|
|
1111
|
+
views (granted to PUBLIC by default in standard APEX installs).
|
|
1112
|
+
"""
|
|
1113
|
+
self._set_action(f"EXTRACT_APEX:{schema}:{application_id}")
|
|
1114
|
+
schema_upper = schema.upper()
|
|
1115
|
+
cur = self._conn.cursor()
|
|
1116
|
+
|
|
1117
|
+
def _fmt_date(v):
|
|
1118
|
+
return v.strftime("%Y-%m-%dT%H:%M:%S") if v else None
|
|
1119
|
+
|
|
1120
|
+
def _to_clob(v):
|
|
1121
|
+
# oracledb returns LobVar for CLOB columns — call .read() to get str.
|
|
1122
|
+
if v is None:
|
|
1123
|
+
return None
|
|
1124
|
+
if hasattr(v, "read"):
|
|
1125
|
+
try:
|
|
1126
|
+
return v.read()
|
|
1127
|
+
except Exception:
|
|
1128
|
+
return None
|
|
1129
|
+
return str(v)
|
|
1130
|
+
|
|
1131
|
+
# --- APP --------------------------------------------------------
|
|
1132
|
+
if on_progress: on_progress(1, 6, "fetching app")
|
|
1133
|
+
cur.execute("""
|
|
1134
|
+
SELECT APPLICATION_ID, APPLICATION_NAME, ALIAS, WORKSPACE,
|
|
1135
|
+
AUTHENTICATION_SCHEME, THEME_NAME, BUILD_STATUS, VERSION,
|
|
1136
|
+
PAGE_COUNT, LAST_UPDATED_BY, LAST_UPDATED_ON, OWNER
|
|
1137
|
+
FROM APEX_APPLICATIONS
|
|
1138
|
+
WHERE APPLICATION_ID = :app_id
|
|
1139
|
+
AND OWNER = :owner
|
|
1140
|
+
""", app_id=application_id, owner=schema_upper)
|
|
1141
|
+
row = cur.fetchone()
|
|
1142
|
+
if not row:
|
|
1143
|
+
cur.close()
|
|
1144
|
+
raise ValueError(
|
|
1145
|
+
f"APEX application {application_id} not found for owner {schema_upper}.")
|
|
1146
|
+
app = {
|
|
1147
|
+
"application_id": row[0],
|
|
1148
|
+
"app_name": row[1],
|
|
1149
|
+
"app_alias": row[2],
|
|
1150
|
+
"workspace_name": row[3],
|
|
1151
|
+
"authentication_scheme": row[4],
|
|
1152
|
+
"theme_name": row[5],
|
|
1153
|
+
"build_status": row[6],
|
|
1154
|
+
"app_version": row[7],
|
|
1155
|
+
"page_count": row[8],
|
|
1156
|
+
"last_updated_by": row[9],
|
|
1157
|
+
"last_updated_on": _fmt_date(row[10]),
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
# --- PAGES ------------------------------------------------------
|
|
1161
|
+
if on_progress: on_progress(2, 6, "fetching pages")
|
|
1162
|
+
cur.execute("""
|
|
1163
|
+
SELECT PAGE_ID, PAGE_NAME, PAGE_TITLE, PAGE_ALIAS, PAGE_MODE,
|
|
1164
|
+
PAGE_GROUP, PARENT_PAGE_ID, AUTHORIZATION_SCHEME, HELP_TEXT
|
|
1165
|
+
FROM APEX_APPLICATION_PAGES
|
|
1166
|
+
WHERE APPLICATION_ID = :app_id
|
|
1167
|
+
ORDER BY PAGE_ID
|
|
1168
|
+
""", app_id=application_id)
|
|
1169
|
+
pages = []
|
|
1170
|
+
for r in cur.fetchall():
|
|
1171
|
+
pages.append({
|
|
1172
|
+
"page_id": r[0],
|
|
1173
|
+
"page_name": r[1],
|
|
1174
|
+
"page_title": r[2],
|
|
1175
|
+
"page_alias": r[3],
|
|
1176
|
+
"page_mode": r[4],
|
|
1177
|
+
"page_type": r[5], # PAGE_GROUP serves as page_type label
|
|
1178
|
+
"parent_page_id": r[6],
|
|
1179
|
+
"authorization_scheme": r[7],
|
|
1180
|
+
"help_text": _to_clob(r[8]),
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
# --- REGIONS ----------------------------------------------------
|
|
1184
|
+
if on_progress: on_progress(3, 6, "fetching regions")
|
|
1185
|
+
cur.execute("""
|
|
1186
|
+
SELECT PAGE_ID, REGION_ID, REGION_NAME, REGION_SOURCE_TYPE, TEMPLATE_NAME,
|
|
1187
|
+
DISPLAY_POINT, DISPLAY_SEQUENCE, PARENT_REGION_ID, REGION_SOURCE
|
|
1188
|
+
FROM APEX_APPLICATION_PAGE_REGIONS
|
|
1189
|
+
WHERE APPLICATION_ID = :app_id
|
|
1190
|
+
ORDER BY PAGE_ID, DISPLAY_SEQUENCE, REGION_ID
|
|
1191
|
+
""", app_id=application_id)
|
|
1192
|
+
regions = []
|
|
1193
|
+
for r in cur.fetchall():
|
|
1194
|
+
regions.append({
|
|
1195
|
+
"page_id": r[0],
|
|
1196
|
+
"region_id": r[1],
|
|
1197
|
+
"region_name": r[2],
|
|
1198
|
+
"region_type": r[3],
|
|
1199
|
+
"region_template": r[4],
|
|
1200
|
+
"display_position": r[5],
|
|
1201
|
+
"display_sequence": r[6],
|
|
1202
|
+
"parent_region_id": r[7],
|
|
1203
|
+
"region_source": _to_clob(r[8]),
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
# --- ITEMS ------------------------------------------------------
|
|
1207
|
+
if on_progress: on_progress(4, 6, "fetching items")
|
|
1208
|
+
cur.execute("""
|
|
1209
|
+
SELECT PAGE_ID, REGION_ID, ITEM_ID, ITEM_NAME, DISPLAY_AS,
|
|
1210
|
+
LABEL, DISPLAY_SEQUENCE, NULL AS DATA_TYPE,
|
|
1211
|
+
IS_REQUIRED, ITEM_DEFAULT, ITEM_SOURCE_TYPE, ITEM_SOURCE,
|
|
1212
|
+
LOV_NAMED_LOV, HELP_TEXT
|
|
1213
|
+
FROM APEX_APPLICATION_PAGE_ITEMS
|
|
1214
|
+
WHERE APPLICATION_ID = :app_id
|
|
1215
|
+
ORDER BY PAGE_ID, DISPLAY_SEQUENCE, ITEM_NAME
|
|
1216
|
+
""", app_id=application_id)
|
|
1217
|
+
items = []
|
|
1218
|
+
for r in cur.fetchall():
|
|
1219
|
+
items.append({
|
|
1220
|
+
"page_id": r[0],
|
|
1221
|
+
"region_id": r[1],
|
|
1222
|
+
"item_id": r[2],
|
|
1223
|
+
"item_name": r[3],
|
|
1224
|
+
"item_type": r[4],
|
|
1225
|
+
"label": r[5],
|
|
1226
|
+
"display_sequence": r[6],
|
|
1227
|
+
"data_type": r[7],
|
|
1228
|
+
"is_required": ("Y" if r[8] in ("Yes", "Y", True) else "N"),
|
|
1229
|
+
"default_value": r[9],
|
|
1230
|
+
"source_type": r[10],
|
|
1231
|
+
"source_value": r[11],
|
|
1232
|
+
"lov_name": r[12],
|
|
1233
|
+
"help_text": _to_clob(r[13]),
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
# --- PROC -------------------------------------------------------
|
|
1237
|
+
if on_progress: on_progress(5, 6, "fetching processes")
|
|
1238
|
+
cur.execute("""
|
|
1239
|
+
SELECT PAGE_ID, PROCESS_ID, PROCESS_NAME, PROCESS_TYPE,
|
|
1240
|
+
PROCESS_POINT, EXECUTION_SEQUENCE,
|
|
1241
|
+
PROCESS_SOURCE, WHEN_BUTTON_LABEL
|
|
1242
|
+
FROM APEX_APPLICATION_PAGE_PROC
|
|
1243
|
+
WHERE APPLICATION_ID = :app_id
|
|
1244
|
+
ORDER BY PAGE_ID, EXECUTION_SEQUENCE, PROCESS_ID
|
|
1245
|
+
""", app_id=application_id)
|
|
1246
|
+
proc = []
|
|
1247
|
+
for r in cur.fetchall():
|
|
1248
|
+
proc.append({
|
|
1249
|
+
"page_id": r[0],
|
|
1250
|
+
"process_id": r[1],
|
|
1251
|
+
"process_name": r[2],
|
|
1252
|
+
"process_type": r[3],
|
|
1253
|
+
"process_point": r[4],
|
|
1254
|
+
"display_sequence": r[5],
|
|
1255
|
+
"process_source": _to_clob(r[6]),
|
|
1256
|
+
"when_button_pressed": r[7],
|
|
1257
|
+
})
|
|
1258
|
+
|
|
1259
|
+
# --- DA ---------------------------------------------------------
|
|
1260
|
+
if on_progress: on_progress(6, 6, "fetching dynamic actions")
|
|
1261
|
+
cur.execute("""
|
|
1262
|
+
SELECT PAGE_ID, NULL AS REGION_ID, DYNAMIC_ACTION_ID, DYNAMIC_ACTION_NAME,
|
|
1263
|
+
EVENT_NAME, AFFECTED_ELEMENTS_TYPE, AFFECTED_ELEMENTS,
|
|
1264
|
+
CONDITION_CLIENT_SIDE_TYPE, CONDITION_CLIENT_SIDE_EXPR1
|
|
1265
|
+
FROM APEX_APPLICATION_PAGE_DA
|
|
1266
|
+
WHERE APPLICATION_ID = :app_id
|
|
1267
|
+
ORDER BY PAGE_ID, DYNAMIC_ACTION_ID
|
|
1268
|
+
""", app_id=application_id)
|
|
1269
|
+
da = []
|
|
1270
|
+
for r in cur.fetchall():
|
|
1271
|
+
da.append({
|
|
1272
|
+
"page_id": r[0],
|
|
1273
|
+
"region_id": r[1],
|
|
1274
|
+
"da_id": r[2],
|
|
1275
|
+
"da_name": r[3],
|
|
1276
|
+
"event_name": r[4],
|
|
1277
|
+
"selection_type": r[5],
|
|
1278
|
+
"selection_value": r[6],
|
|
1279
|
+
"condition_type": r[7],
|
|
1280
|
+
"condition_value": r[8],
|
|
1281
|
+
"actions_json": None, # v1: aggregate APEX_APPLICATION_PAGE_DA_ACTS later
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
cur.close()
|
|
1285
|
+
return {
|
|
1286
|
+
"schema": schema_upper,
|
|
1287
|
+
"app": app,
|
|
1288
|
+
"pages": pages,
|
|
1289
|
+
"regions": regions,
|
|
1290
|
+
"items": items,
|
|
1291
|
+
"proc": proc,
|
|
1292
|
+
"da": da,
|
|
1293
|
+
}
|
|
1294
|
+
|