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/mysql.py ADDED
@@ -0,0 +1,388 @@
1
+ """BAUCLI — MySQL database extractor.
2
+
3
+ Uses mysql-connector-python.
4
+ Extracts:
5
+ - Functions from INFORMATION_SCHEMA.ROUTINES + SHOW CREATE FUNCTION
6
+ - Procedures from INFORMATION_SCHEMA.ROUTINES + SHOW CREATE PROCEDURE
7
+ - Triggers from INFORMATION_SCHEMA.TRIGGERS + SHOW CREATE TRIGGER
8
+ - Views from INFORMATION_SCHEMA.VIEWS + SHOW CREATE VIEW
9
+ - Table metadata from INFORMATION_SCHEMA.COLUMNS, TABLE_CONSTRAINTS, etc.
10
+
11
+ In MySQL, schema = database (they are the same concept).
12
+ """
13
+
14
+ from typing import Optional
15
+ from baucli.db import BaseExtractor
16
+
17
+
18
+ class MySQLExtractor(BaseExtractor):
19
+ """MySQL database extractor using mysql-connector-python."""
20
+
21
+ engine = "MYSQL"
22
+
23
+ def connect(self):
24
+ import mysql.connector
25
+ from baucli import __version__
26
+ self._conn = mysql.connector.connect(
27
+ host=self.host,
28
+ port=self.port,
29
+ database=self.database,
30
+ user=self.user,
31
+ password=self.password,
32
+ charset='utf8mb4',
33
+ connection_timeout=30,
34
+ )
35
+ # Set application name via session variable
36
+ try:
37
+ cur = self._conn.cursor()
38
+ cur.execute(f"SET @baucli = 'BAUCLI v{__version__}'")
39
+ cur.close()
40
+ except Exception:
41
+ pass
42
+
43
+ def disconnect(self):
44
+ if self._conn:
45
+ try:
46
+ self._conn.close()
47
+ except Exception:
48
+ pass
49
+ self._conn = None
50
+
51
+ def test_connection(self) -> dict:
52
+ cur = self._conn.cursor()
53
+ cur.execute("SELECT VERSION()")
54
+ row = cur.fetchone()
55
+ version = row[0] if row else "Unknown"
56
+ cur.execute("SELECT DATABASE()")
57
+ row = cur.fetchone()
58
+ db_name = row[0] if row else "Unknown"
59
+ cur.execute("SELECT CURRENT_USER()")
60
+ row = cur.fetchone()
61
+ current_user = row[0] if row else "Unknown"
62
+ cur.close()
63
+ return {
64
+ "status": "ok",
65
+ "engine": "MYSQL",
66
+ "version": version,
67
+ "db_name": db_name,
68
+ "current_schema": current_user,
69
+ }
70
+
71
+ def get_native_identifier(self) -> Optional[str]:
72
+ try:
73
+ cur = self._conn.cursor()
74
+ cur.execute("SELECT @@server_uuid")
75
+ row = cur.fetchone()
76
+ cur.close()
77
+ return str(row[0]) if row else None
78
+ except Exception:
79
+ try:
80
+ cur = self._conn.cursor()
81
+ cur.execute("SELECT @@server_id")
82
+ row = cur.fetchone()
83
+ cur.close()
84
+ return str(row[0]) if row else None
85
+ except Exception:
86
+ return None
87
+
88
+ def count_objects(self, schema: str) -> int:
89
+ cur = self._conn.cursor()
90
+ cur.execute("""
91
+ SELECT COUNT(*) FROM INFORMATION_SCHEMA.ROUTINES
92
+ WHERE ROUTINE_SCHEMA = %s
93
+ """, (schema,))
94
+ cnt = cur.fetchone()[0]
95
+ # Add triggers
96
+ cur.execute("""
97
+ SELECT COUNT(*) FROM INFORMATION_SCHEMA.TRIGGERS
98
+ WHERE TRIGGER_SCHEMA = %s
99
+ """, (schema,))
100
+ cnt += cur.fetchone()[0]
101
+ cur.close()
102
+ return cnt
103
+
104
+ def count_tables(self, schema: str) -> int:
105
+ cur = self._conn.cursor()
106
+ cur.execute("""
107
+ SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
108
+ WHERE TABLE_SCHEMA = %s AND TABLE_TYPE IN ('BASE TABLE', 'VIEW')
109
+ """, (schema,))
110
+ cnt = cur.fetchone()[0]
111
+ cur.close()
112
+ return cnt
113
+
114
+ def extract_sequences(self, schema: str) -> list:
115
+ """MySQL nao tem objetos SEQUENCE (usa AUTO_INCREMENT em coluna).
116
+
117
+ DOCSEQ fica vazio para MySQL — nada a enviar. (MariaDB 10.3+ tem
118
+ SEQUENCE, mas fica fora do escopo desta fase.)
119
+ """
120
+ return []
121
+
122
+ def extract_objects(self, schema: str, since_date: str = None, on_progress=None, name_filter: str = None) -> list:
123
+ """Extract functions, procedures, triggers, and views from MySQL.
124
+
125
+ In MySQL, schema = database name.
126
+
127
+ Args:
128
+ schema: Database name
129
+ since_date: Filter by LAST_ALTERED (for routines) or CREATED (for triggers)
130
+ """
131
+ result = []
132
+ cur = self._conn.cursor()
133
+
134
+ # ═══ Functions ═══
135
+ date_filter = ""
136
+ params = [schema]
137
+ if since_date:
138
+ date_filter = "AND LAST_ALTERED >= %s"
139
+ params.append(since_date[:19]) # Trim to YYYY-MM-DDTHH:MI:SS
140
+
141
+ cur.execute(f"""
142
+ SELECT ROUTINE_NAME, ROUTINE_TYPE,
143
+ REPLACE(CAST(LAST_ALTERED AS CHAR), ' ', 'T') AS LAST_DDL,
144
+ ROUTINE_COMMENT
145
+ FROM INFORMATION_SCHEMA.ROUTINES
146
+ WHERE ROUTINE_SCHEMA = %s
147
+ AND ROUTINE_TYPE IN ('FUNCTION', 'PROCEDURE')
148
+ {date_filter}
149
+ ORDER BY ROUTINE_TYPE, ROUTINE_NAME
150
+ """, params)
151
+
152
+ routines = cur.fetchall()
153
+ total_routines = len(routines)
154
+ for idx, (name, rtype, last_ddl, comment) in enumerate(routines):
155
+ if on_progress:
156
+ on_progress(idx + 1, total_routines, name)
157
+ try:
158
+ cur.execute(f"SHOW CREATE {rtype} `{schema}`.`{name}`")
159
+ row = cur.fetchone()
160
+ # SHOW CREATE returns: (name, sql_mode, create_stmt, ...)
161
+ source = row[2] if row and len(row) > 2 else None
162
+ if not source and row and len(row) > 1:
163
+ source = row[1]
164
+ except Exception:
165
+ source = None
166
+
167
+ if source:
168
+ entry = {
169
+ "name": name,
170
+ "type": rtype,
171
+ "source": source,
172
+ "last_ddl": last_ddl,
173
+ }
174
+ if comment:
175
+ entry["description"] = comment
176
+ result.append(entry)
177
+
178
+ # ═══ Triggers ═══
179
+ trig_params = [schema]
180
+ trig_date_filter = ""
181
+ if since_date:
182
+ trig_date_filter = "AND CREATED >= %s"
183
+ trig_params.append(since_date[:19])
184
+
185
+ try:
186
+ cur.execute(f"""
187
+ SELECT TRIGGER_NAME,
188
+ EVENT_OBJECT_TABLE,
189
+ ACTION_TIMING,
190
+ EVENT_MANIPULATION,
191
+ REPLACE(CAST(CREATED AS CHAR), ' ', 'T') AS LAST_DDL
192
+ FROM INFORMATION_SCHEMA.TRIGGERS
193
+ WHERE TRIGGER_SCHEMA = %s
194
+ {trig_date_filter}
195
+ ORDER BY TRIGGER_NAME
196
+ """, trig_params)
197
+
198
+ triggers = cur.fetchall()
199
+ for name, table_name, timing, event, last_ddl in triggers:
200
+ try:
201
+ cur.execute(f"SHOW CREATE TRIGGER `{schema}`.`{name}`")
202
+ row = cur.fetchone()
203
+ # SHOW CREATE TRIGGER returns: (name, sql_mode, create_stmt, ...)
204
+ source = row[2] if row and len(row) > 2 else None
205
+ except Exception:
206
+ source = None
207
+
208
+ if source:
209
+ result.append({
210
+ "name": name,
211
+ "type": "TRIGGER",
212
+ "source": source,
213
+ "last_ddl": last_ddl,
214
+ "description": f"Trigger on {table_name} ({timing} {event})",
215
+ })
216
+ except Exception:
217
+ pass # Skip triggers if query fails
218
+
219
+ # Views are extracted via extract_tables (as DOCTAB entries, not PL/SQL objects)
220
+
221
+ cur.close()
222
+ return result
223
+
224
+ def extract_tables(self, schema: str, since_date: str = None, on_progress=None, name_filter: str = None) -> list:
225
+ """Extract table metadata from INFORMATION_SCHEMA.
226
+
227
+ Args:
228
+ schema: Database name (MySQL schema = database)
229
+ since_date: Filter by UPDATE_TIME
230
+ """
231
+ result = []
232
+ cur = self._conn.cursor()
233
+
234
+ # Get tables AND views
235
+ date_filter = ""
236
+ params = [schema]
237
+ if since_date:
238
+ date_filter = "AND (t.UPDATE_TIME >= %s OR t.CREATE_TIME >= %s)"
239
+ params.extend([since_date[:19], since_date[:19]])
240
+
241
+ cur.execute(f"""
242
+ SELECT t.TABLE_NAME,
243
+ REPLACE(CAST(COALESCE(t.UPDATE_TIME, t.CREATE_TIME) AS CHAR), ' ', 'T') AS LAST_DDL,
244
+ t.TABLE_COMMENT,
245
+ t.TABLE_TYPE
246
+ FROM INFORMATION_SCHEMA.TABLES t
247
+ WHERE t.TABLE_SCHEMA = %s
248
+ AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')
249
+ {date_filter}
250
+ ORDER BY t.TABLE_TYPE, t.TABLE_NAME
251
+ """, params)
252
+
253
+ tables = cur.fetchall()
254
+ total_tbl = len(tables)
255
+
256
+ if total_tbl == 0:
257
+ cur.close()
258
+ return []
259
+
260
+ CHUNK = 500
261
+
262
+ # ═══ Pre-load columns in chunks ═══
263
+ all_columns = {}
264
+ table_names = [t[0] for t in tables]
265
+ col_count = 0
266
+
267
+ import time as _time
268
+ total_chunks = (len(table_names) + CHUNK - 1) // CHUNK
269
+ chunk_times = []
270
+
271
+ for ci, cs in enumerate(range(0, len(table_names), CHUNK)):
272
+ chunk = table_names[cs:cs + CHUNK]
273
+ t0 = _time.time()
274
+
275
+ eta = ""
276
+ if len(chunk_times) >= 2:
277
+ avg = sum(chunk_times) / len(chunk_times)
278
+ remaining = total_chunks - ci
279
+ eta_s = int(remaining * avg)
280
+ eta = f" ETA {eta_s//60}m{eta_s%60:02d}s" if eta_s >= 60 else f" ETA {eta_s}s"
281
+
282
+ if on_progress:
283
+ on_progress(0, total_tbl, f"loading columns {cs}/{total_tbl}{eta}")
284
+
285
+ placeholders = ','.join(['%s'] * len(chunk))
286
+ cur.execute(f"""
287
+ SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE,
288
+ COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_KEY, ORDINAL_POSITION
289
+ FROM INFORMATION_SCHEMA.COLUMNS
290
+ WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders})
291
+ ORDER BY TABLE_NAME, ORDINAL_POSITION
292
+ """, tuple([schema] + chunk))
293
+ for row in cur.fetchall():
294
+ tname = row[0]
295
+ if tname not in all_columns:
296
+ all_columns[tname] = []
297
+ all_columns[tname].append({
298
+ "name": row[1], "type": row[2], "nullable": row[3],
299
+ "default": str(row[4]) if row[4] is not None else None,
300
+ "comment": row[5] if row[5] else None,
301
+ "is_pk": "Y" if row[6] == "PRI" else "N",
302
+ })
303
+ col_count += 1
304
+ chunk_times.append(_time.time() - t0)
305
+
306
+ if on_progress:
307
+ on_progress(0, total_tbl, f"columns done ({col_count})")
308
+
309
+ if on_progress:
310
+ on_progress(0, total_tbl, "loading constraints...")
311
+
312
+ # ═══ Pre-load ALL constraints ═══
313
+ all_constraints = {}
314
+ try:
315
+ cur.execute("""
316
+ SELECT tc.TABLE_NAME, tc.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE,
317
+ rc.REFERENCED_TABLE_NAME
318
+ FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
319
+ LEFT JOIN INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
320
+ ON rc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
321
+ AND rc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
322
+ AND rc.TABLE_NAME = tc.TABLE_NAME
323
+ WHERE tc.TABLE_SCHEMA = %s
324
+ AND tc.CONSTRAINT_TYPE IN ('UNIQUE', 'FOREIGN KEY', 'CHECK')
325
+ ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_TYPE
326
+ """, (schema,))
327
+ for tname, cname, ctype, ref_table in cur.fetchall():
328
+ if tname not in all_constraints:
329
+ all_constraints[tname] = []
330
+ bau_type = {'UNIQUE': 'U', 'FOREIGN KEY': 'R', 'CHECK': 'C'}.get(ctype, ctype)
331
+ c = {"name": cname, "type": bau_type}
332
+ if ref_table:
333
+ c["ref_table"] = ref_table
334
+ all_constraints[tname].append(c)
335
+ except Exception:
336
+ pass
337
+
338
+ if on_progress:
339
+ on_progress(0, total_tbl, "building entries...")
340
+
341
+ # ═══ Build results per table ═══
342
+ for idx, (table_name, last_ddl, table_comment, mysql_type) in enumerate(tables):
343
+ if on_progress:
344
+ on_progress(idx + 1, total_tbl, table_name)
345
+ bau_type = 'VIEW' if mysql_type == 'VIEW' else 'TABLE'
346
+
347
+ # View source (per-object — small number)
348
+ view_source = None
349
+ if bau_type == 'VIEW':
350
+ try:
351
+ cur.execute("""
352
+ SELECT VIEW_DEFINITION FROM INFORMATION_SCHEMA.VIEWS
353
+ WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
354
+ """, (schema, table_name))
355
+ row = cur.fetchone()
356
+ if row and row[0]:
357
+ view_source = f"CREATE OR REPLACE VIEW `{schema}`.`{table_name}` AS\n{row[0]}"
358
+ except Exception:
359
+ pass
360
+
361
+ # Columns from pre-loaded dict
362
+ columns = []
363
+ for c in all_columns.get(table_name, []):
364
+ col = {"name": c["name"], "type": c["type"], "nullable": c["nullable"], "is_pk": c["is_pk"]}
365
+ if c["default"]:
366
+ col["default"] = c["default"]
367
+ if c["comment"]:
368
+ col["comment"] = c["comment"]
369
+ columns.append(col)
370
+
371
+ constraints = all_constraints.get(table_name, [])
372
+
373
+ entry = {
374
+ "name": table_name,
375
+ "type": bau_type,
376
+ "last_ddl": last_ddl,
377
+ "columns": columns,
378
+ }
379
+ if table_comment:
380
+ entry["comment"] = table_comment
381
+ if constraints:
382
+ entry["constraints"] = constraints
383
+ if view_source:
384
+ entry["source"] = view_source
385
+ result.append(entry)
386
+
387
+ cur.close()
388
+ return result