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/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
+