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/mssql.py ADDED
@@ -0,0 +1,443 @@
1
+ """BAUCLI — SQL Server database extractor.
2
+
3
+ Uses python-tds (pytds) — pure Python, no compilation needed.
4
+ Works on Windows, macOS, Linux without FreeTDS or Visual C++.
5
+ Extracts:
6
+ - Functions from sys.objects + OBJECT_DEFINITION()
7
+ - Procedures from sys.objects + OBJECT_DEFINITION()
8
+ - Triggers from sys.triggers + OBJECT_DEFINITION()
9
+ - Views from sys.views + OBJECT_DEFINITION()
10
+ - Table metadata from INFORMATION_SCHEMA.COLUMNS, sys.indexes, etc.
11
+ """
12
+
13
+ from typing import Optional
14
+ from baucli.db import BaseExtractor
15
+
16
+
17
+ class MSSQLExtractor(BaseExtractor):
18
+ """SQL Server database extractor using pytds."""
19
+
20
+ engine = "MSSQL"
21
+
22
+ def connect(self):
23
+ import pytds
24
+ from baucli import __version__
25
+ self._conn = pytds.connect(
26
+ server=self.host,
27
+ port=self.port,
28
+ database=self.database,
29
+ user=self.user,
30
+ password=self.password,
31
+ appname=f"BAUCLI v{__version__}",
32
+ login_timeout=60,
33
+ timeout=14400, # 4 hours query timeout for large schemas
34
+ autocommit=True,
35
+ )
36
+
37
+ def disconnect(self):
38
+ if self._conn:
39
+ try:
40
+ self._conn.close()
41
+ except Exception:
42
+ pass
43
+ self._conn = None
44
+
45
+ def test_connection(self) -> dict:
46
+ cur = self._conn.cursor()
47
+ cur.execute("SELECT @@VERSION")
48
+ row = cur.fetchone()
49
+ version = row[0] if row else "Unknown"
50
+ cur.execute("SELECT DB_NAME()")
51
+ row = cur.fetchone()
52
+ db_name = row[0] if row else "Unknown"
53
+ cur.execute("SELECT SCHEMA_NAME()")
54
+ row = cur.fetchone()
55
+ current_schema = row[0] if row else "dbo"
56
+ cur.close()
57
+ return {
58
+ "status": "ok",
59
+ "engine": "MSSQL",
60
+ "version": version.split('\n')[0] if version else "Unknown",
61
+ "db_name": db_name,
62
+ "current_schema": current_schema,
63
+ }
64
+
65
+ def get_native_identifier(self) -> Optional[str]:
66
+ try:
67
+ cur = self._conn.cursor()
68
+ cur.execute("SELECT SERVERPROPERTY('ServerName')")
69
+ row = cur.fetchone()
70
+ cur.close()
71
+ return str(row[0]) if row else None
72
+ except Exception:
73
+ return None
74
+
75
+ def count_objects(self, schema: str) -> int:
76
+ cur = self._conn.cursor()
77
+ cur.execute("""
78
+ SELECT COUNT(*) FROM sys.objects o
79
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
80
+ WHERE s.name = %s AND o.type IN ('P', 'FN', 'IF', 'TF', 'AF', 'TR')
81
+ AND o.is_ms_shipped = 0
82
+ """, (schema,))
83
+ cnt = cur.fetchone()[0]
84
+ cur.close()
85
+ return cnt
86
+
87
+ def count_tables(self, schema: str) -> int:
88
+ cur = self._conn.cursor()
89
+ cur.execute("""
90
+ SELECT COUNT(*) FROM sys.objects o
91
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
92
+ WHERE s.name = %s AND o.type IN ('U', 'V') AND o.is_ms_shipped = 0
93
+ """, (schema,))
94
+ cnt = cur.fetchone()[0]
95
+ cur.close()
96
+ return cnt
97
+
98
+ def extract_sequences(self, schema: str) -> list:
99
+ """Sequences do schema (sys.sequences, SQL Server 2012+).
100
+
101
+ IDENTITY columns NAO sao sequences (o vinculo de identity nao entra
102
+ aqui). O servidor persiste os metadados e roda DISCOVER (default de
103
+ coluna `NEXT VALUE FOR seq`).
104
+ """
105
+ cur = self._conn.cursor()
106
+ try:
107
+ cur.execute("""
108
+ SELECT sch.name, seq.name, seq.minimum_value, seq.maximum_value,
109
+ seq.increment, seq.is_cycling, seq.cache_size, seq.current_value
110
+ FROM sys.sequences seq
111
+ JOIN sys.schemas sch ON sch.schema_id = seq.schema_id
112
+ WHERE sch.name = %s
113
+ """, (schema,))
114
+ rows = cur.fetchall()
115
+ except Exception:
116
+ rows = []
117
+ cur.close()
118
+
119
+ def _n(v):
120
+ return int(v) if v is not None else None
121
+
122
+ out = []
123
+ for r in rows:
124
+ cyc = "Y" if (r[5] in (1, True)) else "N"
125
+ out.append({"owner": r[0], "sequence_name": r[1],
126
+ "min_value": _n(r[2]), "max_value": _n(r[3]),
127
+ "increment_by": _n(r[4]),
128
+ "cycle_flag": cyc, "order_flag": "N",
129
+ "cache_size": _n(r[6]), "last_number": _n(r[7])})
130
+ return out
131
+
132
+ def extract_objects(self, schema: str, since_date: str = None, on_progress=None, name_filter: str = None) -> list:
133
+ """Extract functions, procedures, triggers from SQL Server.
134
+
135
+ Uses two-pass strategy: fast listing query, then OBJECT_DEFINITION per object
136
+ for real-time progress reporting.
137
+ """
138
+ result = []
139
+ cur = self._conn.cursor()
140
+
141
+ # ═══ Functions, Procedures ═══
142
+ date_filter = ""
143
+ params = [schema]
144
+ if since_date:
145
+ date_filter = "AND o.modify_date >= %s"
146
+ params.append(since_date[:19])
147
+
148
+ # Pass 1: fast listing (no OBJECT_DEFINITION — instant)
149
+ cur.execute(f"""
150
+ SELECT o.name,
151
+ CASE o.type
152
+ WHEN 'P' THEN 'PROCEDURE'
153
+ WHEN 'FN' THEN 'FUNCTION'
154
+ WHEN 'IF' THEN 'FUNCTION'
155
+ WHEN 'TF' THEN 'FUNCTION'
156
+ WHEN 'AF' THEN 'FUNCTION'
157
+ END AS type,
158
+ CONVERT(VARCHAR(19), o.modify_date, 126) AS last_ddl,
159
+ o.object_id
160
+ FROM sys.objects o
161
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
162
+ WHERE s.name = %s
163
+ AND o.type IN ('P', 'FN', 'IF', 'TF', 'AF')
164
+ AND o.is_ms_shipped = 0
165
+ {date_filter}
166
+ ORDER BY o.type, o.name
167
+ """, tuple(params))
168
+
169
+ obj_list = cur.fetchall()
170
+ total_obj = len(obj_list)
171
+
172
+ # Pass 2: get source per object (progress callback per item)
173
+ for idx, (name, obj_type, last_ddl, obj_id) in enumerate(obj_list):
174
+ if on_progress:
175
+ on_progress(idx + 1, total_obj, name)
176
+
177
+ try:
178
+ cur.execute("SELECT CAST(OBJECT_DEFINITION(%s) AS NVARCHAR(MAX))", (obj_id,))
179
+ row = cur.fetchone()
180
+ source = row[0] if row and row[0] else None
181
+ except Exception:
182
+ source = None
183
+
184
+ if source:
185
+ result.append({
186
+ "name": name,
187
+ "type": obj_type,
188
+ "source": source,
189
+ "last_ddl": last_ddl,
190
+ })
191
+
192
+ # ═══ Triggers ═══
193
+ trig_params = [schema]
194
+ trig_date_filter = ""
195
+ if since_date:
196
+ trig_date_filter = "AND t.modify_date >= %s"
197
+ trig_params.append(since_date[:19])
198
+
199
+ try:
200
+ cur.execute(f"""
201
+ SELECT t.name,
202
+ CONVERT(VARCHAR(19), t.modify_date, 126) AS last_ddl,
203
+ CAST(OBJECT_DEFINITION(t.object_id) AS NVARCHAR(MAX)) AS source,
204
+ OBJECT_NAME(t.parent_id) AS table_name,
205
+ CASE WHEN t.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END AS timing
206
+ FROM sys.triggers t
207
+ JOIN sys.objects o ON o.object_id = t.parent_id
208
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
209
+ WHERE s.name = %s
210
+ AND t.is_ms_shipped = 0
211
+ {trig_date_filter}
212
+ ORDER BY t.name
213
+ """, tuple(trig_params))
214
+
215
+ trg_rows = cur.fetchall()
216
+ for name, last_ddl, source, table_name, timing in trg_rows:
217
+ if on_progress:
218
+ on_progress(total_obj + len([r for r in result if r['type']=='TRIGGER']) + 1,
219
+ total_obj + len(trg_rows), name)
220
+ if source:
221
+ result.append({
222
+ "name": name,
223
+ "type": "TRIGGER",
224
+ "source": source,
225
+ "last_ddl": last_ddl,
226
+ "description": f"Trigger on {table_name} ({timing})",
227
+ })
228
+ except Exception:
229
+ pass
230
+
231
+ cur.close()
232
+ return result
233
+
234
+ def extract_tables(self, schema: str, since_date: str = None, on_progress=None, name_filter: str = None) -> list:
235
+ """Extract table metadata from INFORMATION_SCHEMA and sys views.
236
+
237
+ Args:
238
+ schema: Schema name (e.g. 'dbo')
239
+ since_date: Filter by modify_date
240
+ """
241
+ result = []
242
+ cur = self._conn.cursor()
243
+
244
+ # Get tables AND views
245
+ date_filter = ""
246
+ params = [schema]
247
+ if since_date:
248
+ date_filter = "AND o.modify_date >= %s"
249
+ params.append(since_date[:19])
250
+
251
+ cur.execute(f"""
252
+ SELECT o.name AS TABLE_NAME,
253
+ CONVERT(VARCHAR(19), o.modify_date, 126) AS last_ddl,
254
+ CASE o.type WHEN 'U' THEN 'TABLE' WHEN 'V' THEN 'VIEW' END AS table_type,
255
+ CAST(OBJECT_DEFINITION(o.object_id) AS NVARCHAR(MAX)) AS view_source
256
+ FROM sys.objects o
257
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
258
+ WHERE s.name = %s
259
+ AND o.type IN ('U', 'V')
260
+ AND o.is_ms_shipped = 0
261
+ {date_filter}
262
+ ORDER BY o.type, o.name
263
+ """, tuple(params))
264
+
265
+ tables = cur.fetchall()
266
+ total_tbl = len(tables)
267
+
268
+ if total_tbl == 0:
269
+ cur.close()
270
+ return []
271
+
272
+ # Report progress during pre-load phases
273
+ CHUNK = 500 # Process in chunks for large schemas
274
+ import time as _time
275
+
276
+ # ═══ Pre-load columns in chunks ═══
277
+ all_columns = {}
278
+ col_count = 0
279
+ table_names = [t[0] for t in tables]
280
+ total_chunks = (len(table_names) + CHUNK - 1) // CHUNK
281
+ chunk_times = []
282
+
283
+ for ci, cs in enumerate(range(0, len(table_names), CHUNK)):
284
+ chunk = table_names[cs:cs + CHUNK]
285
+ t0 = _time.time()
286
+
287
+ eta = ""
288
+ if len(chunk_times) >= 2:
289
+ avg = sum(chunk_times) / len(chunk_times)
290
+ remaining = total_chunks - ci
291
+ eta_s = int(remaining * avg)
292
+ eta = f" ETA {eta_s//60}m{eta_s%60:02d}s" if eta_s >= 60 else f" ETA {eta_s}s"
293
+
294
+ if on_progress:
295
+ on_progress(0, total_tbl, f"loading columns {cs}/{total_tbl}{eta}")
296
+
297
+ placeholders = ','.join(['%s'] * len(chunk))
298
+ cur.execute(f"""
299
+ SELECT c.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE,
300
+ CASE
301
+ WHEN c.DATA_TYPE IN ('varchar','nvarchar','char','nchar')
302
+ THEN c.DATA_TYPE + '(' + CASE WHEN c.CHARACTER_MAXIMUM_LENGTH = -1 THEN 'MAX' ELSE CAST(c.CHARACTER_MAXIMUM_LENGTH AS VARCHAR) END + ')'
303
+ WHEN c.DATA_TYPE IN ('decimal','numeric')
304
+ THEN c.DATA_TYPE + '(' + CAST(c.NUMERIC_PRECISION AS VARCHAR) + ',' + CAST(c.NUMERIC_SCALE AS VARCHAR) + ')'
305
+ ELSE c.DATA_TYPE
306
+ END AS full_type,
307
+ c.IS_NULLABLE, c.COLUMN_DEFAULT, c.ORDINAL_POSITION
308
+ FROM INFORMATION_SCHEMA.COLUMNS c
309
+ WHERE c.TABLE_SCHEMA = %s AND c.TABLE_NAME IN ({placeholders})
310
+ ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION
311
+ """, tuple([schema] + chunk))
312
+ for row in cur.fetchall():
313
+ tname = row[0]
314
+ if tname not in all_columns:
315
+ all_columns[tname] = []
316
+ all_columns[tname].append({
317
+ "name": row[1], "type": row[3],
318
+ "nullable": row[4], "default": str(row[5]) if row[5] else None,
319
+ "ordinal": row[6],
320
+ })
321
+ col_count += 1
322
+ chunk_times.append(_time.time() - t0)
323
+
324
+ if on_progress:
325
+ on_progress(0, total_tbl, f"columns done ({col_count}), loading PKs...")
326
+
327
+ # ═══ Pre-load ALL primary keys ═══
328
+ pk_cols = set()
329
+ try:
330
+ cur.execute("""
331
+ SELECT ku.TABLE_NAME, ku.COLUMN_NAME
332
+ FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
333
+ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
334
+ ON ku.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND ku.TABLE_SCHEMA = tc.TABLE_SCHEMA
335
+ WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' AND tc.TABLE_SCHEMA = %s
336
+ """, (schema,))
337
+ for tname, cname in cur.fetchall():
338
+ pk_cols.add(f"{tname}.{cname}")
339
+ except Exception:
340
+ pass
341
+
342
+ if on_progress:
343
+ on_progress(0, total_tbl, f"PKs loaded ({len(pk_cols)}), loading comments...")
344
+
345
+ # ═══ Pre-load ALL table comments (extended properties) ═══
346
+ table_comments = {}
347
+ try:
348
+ cur.execute("""
349
+ SELECT o.name, CAST(ep.value AS VARCHAR(500))
350
+ FROM sys.extended_properties ep
351
+ JOIN sys.objects o ON o.object_id = ep.major_id
352
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
353
+ WHERE s.name = %s AND ep.name = 'MS_Description' AND ep.minor_id = 0
354
+ """, (schema,))
355
+ for tname, comment in cur.fetchall():
356
+ table_comments[tname] = comment
357
+ except Exception:
358
+ pass
359
+
360
+ if on_progress:
361
+ on_progress(0, total_tbl, f"comments loaded, loading col comments...")
362
+
363
+ # ═══ Pre-load ALL column comments ═══
364
+ col_comments = {}
365
+ try:
366
+ cur.execute("""
367
+ SELECT o.name AS table_name, c.name AS column_name, CAST(ep.value AS VARCHAR(500))
368
+ FROM sys.extended_properties ep
369
+ JOIN sys.columns c ON c.object_id = ep.major_id AND c.column_id = ep.minor_id
370
+ JOIN sys.objects o ON o.object_id = ep.major_id
371
+ JOIN sys.schemas s ON s.schema_id = o.schema_id
372
+ WHERE s.name = %s AND ep.name = 'MS_Description' AND ep.minor_id > 0
373
+ """, (schema,))
374
+ for tname, cname, comment in cur.fetchall():
375
+ col_comments[f"{tname}.{cname}"] = comment
376
+ except Exception:
377
+ pass
378
+
379
+ if on_progress:
380
+ on_progress(0, total_tbl, f"loading constraints...")
381
+
382
+ # ═══ Pre-load ALL constraints ═══
383
+ all_constraints = {}
384
+ try:
385
+ cur.execute("""
386
+ SELECT tc.TABLE_NAME, tc.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE,
387
+ STRING_AGG(ku.COLUMN_NAME, ',') WITHIN GROUP (ORDER BY ku.ORDINAL_POSITION) AS columns
388
+ FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
389
+ JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
390
+ ON ku.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND ku.TABLE_SCHEMA = tc.TABLE_SCHEMA
391
+ WHERE tc.TABLE_SCHEMA = %s
392
+ GROUP BY tc.TABLE_NAME, tc.CONSTRAINT_NAME, tc.CONSTRAINT_TYPE
393
+ ORDER BY tc.TABLE_NAME, tc.CONSTRAINT_TYPE, tc.CONSTRAINT_NAME
394
+ """, (schema,))
395
+ for tname, cname, ctype, cols in cur.fetchall():
396
+ if tname not in all_constraints:
397
+ all_constraints[tname] = []
398
+ all_constraints[tname].append({
399
+ "name": cname, "type": ctype, "columns": cols,
400
+ })
401
+ except Exception:
402
+ pass
403
+
404
+ if on_progress:
405
+ on_progress(0, total_tbl, f"all metadata loaded, building {total_tbl} entries...")
406
+
407
+ # ═══ Build results per table ═══
408
+ for idx, (table_name, last_ddl, table_type, view_src) in enumerate(tables):
409
+ if on_progress:
410
+ on_progress(idx + 1, total_tbl, table_name)
411
+
412
+ # Columns from pre-loaded dict
413
+ columns = []
414
+ for c in all_columns.get(table_name, []):
415
+ col = {"name": c["name"], "type": c["type"], "nullable": c["nullable"]}
416
+ if f"{table_name}.{c['name']}" in pk_cols:
417
+ col["is_pk"] = "Y"
418
+ if c["default"]:
419
+ col["default"] = c["default"]
420
+ comment = col_comments.get(f"{table_name}.{c['name']}")
421
+ if comment:
422
+ col["comment"] = comment
423
+ columns.append(col)
424
+
425
+ table_comment = table_comments.get(table_name)
426
+ constraints = all_constraints.get(table_name, [])
427
+
428
+ entry = {
429
+ "name": table_name,
430
+ "type": table_type,
431
+ "last_ddl": last_ddl,
432
+ "columns": columns,
433
+ }
434
+ if table_comment:
435
+ entry["comment"] = table_comment
436
+ if constraints:
437
+ entry["constraints"] = constraints
438
+ if view_src:
439
+ entry["source"] = view_src
440
+ result.append(entry)
441
+
442
+ cur.close()
443
+ return result