al-data-utils 0.3.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.
Files changed (60) hide show
  1. al_data_utils/__init__.py +10 -0
  2. al_data_utils/airflow_utils/__init__.py +13 -0
  3. al_data_utils/airflow_utils/airflow_utils.py +83 -0
  4. al_data_utils/azure_as_utils/__init__.py +31 -0
  5. al_data_utils/azure_as_utils/auth.py +82 -0
  6. al_data_utils/azure_as_utils/azure_as_utils.py +223 -0
  7. al_data_utils/azure_as_utils/client.py +158 -0
  8. al_data_utils/azure_as_utils/errors.py +78 -0
  9. al_data_utils/azure_as_utils/models.py +265 -0
  10. al_data_utils/azure_as_utils/poller.py +121 -0
  11. al_data_utils/backup_utils/__init__.py +9 -0
  12. al_data_utils/backup_utils/mssql/dacpac/__init__.py +1 -0
  13. al_data_utils/backup_utils/mssql/dacpac/sqlpackage.py +207 -0
  14. al_data_utils/backup_utils/mssql/schema_snapshot/__init__.py +8 -0
  15. al_data_utils/backup_utils/mssql/schema_snapshot/config.py +150 -0
  16. al_data_utils/backup_utils/mssql/schema_snapshot/emit/__init__.py +11 -0
  17. al_data_utils/backup_utils/mssql/schema_snapshot/emit/foreign_keys.py +38 -0
  18. al_data_utils/backup_utils/mssql/schema_snapshot/emit/indexes.py +37 -0
  19. al_data_utils/backup_utils/mssql/schema_snapshot/emit/sql_objects.py +17 -0
  20. al_data_utils/backup_utils/mssql/schema_snapshot/emit/tables.py +139 -0
  21. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/__init__.py +19 -0
  22. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/foreign_keys.py +173 -0
  23. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/functions.py +133 -0
  24. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/indexes.py +190 -0
  25. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/sequences.py +180 -0
  26. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/stored_procedures.py +313 -0
  27. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/synonyms.py +135 -0
  28. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/tables.py +251 -0
  29. al_data_utils/backup_utils/mssql/schema_snapshot/introspect/views.py +244 -0
  30. al_data_utils/backup_utils/mssql/schema_snapshot/io/__init__.py +10 -0
  31. al_data_utils/backup_utils/mssql/schema_snapshot/io/layout.py +28 -0
  32. al_data_utils/backup_utils/mssql/schema_snapshot/io/writer.py +40 -0
  33. al_data_utils/backup_utils/mssql/schema_snapshot/models/__init__.py +14 -0
  34. al_data_utils/backup_utils/mssql/schema_snapshot/models/foreign_key_def.py +19 -0
  35. al_data_utils/backup_utils/mssql/schema_snapshot/models/index_def.py +19 -0
  36. al_data_utils/backup_utils/mssql/schema_snapshot/models/sql_object_def.py +11 -0
  37. al_data_utils/backup_utils/mssql/schema_snapshot/models/table_column_def.py +27 -0
  38. al_data_utils/backup_utils/mssql/schema_snapshot/models/table_def.py +22 -0
  39. al_data_utils/backup_utils/mssql/schema_snapshot/snapshot_processor.py +754 -0
  40. al_data_utils/backup_utils/mssql/scripter/__init__.py +1 -0
  41. al_data_utils/config_utils/__init__.py +6 -0
  42. al_data_utils/config_utils/config_utils.py +31 -0
  43. al_data_utils/files_sync_utils/__init__.py +15 -0
  44. al_data_utils/files_sync_utils/files_sync_utils.py +567 -0
  45. al_data_utils/geo_utils/__init__.py +0 -0
  46. al_data_utils/git_utils/__init__.py +27 -0
  47. al_data_utils/git_utils/git_utils.py +142 -0
  48. al_data_utils/logging_utils/__init__.py +3 -0
  49. al_data_utils/logging_utils/logging_utils.py +39 -0
  50. al_data_utils/ones_api_utils/__init__.py +3 -0
  51. al_data_utils/ones_api_utils/ones_api_utils.py +61 -0
  52. al_data_utils/sql_utils/__init__.py +3 -0
  53. al_data_utils/sql_utils/sql_utils.py +158 -0
  54. al_data_utils/tg_utils/__init__.py +3 -0
  55. al_data_utils/tg_utils/tg_utils.py +45 -0
  56. al_data_utils-0.3.1.dist-info/METADATA +157 -0
  57. al_data_utils-0.3.1.dist-info/RECORD +60 -0
  58. al_data_utils-0.3.1.dist-info/WHEEL +5 -0
  59. al_data_utils-0.3.1.dist-info/licenses/LICENSE +21 -0
  60. al_data_utils-0.3.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,754 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import replace
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ import re
8
+ from uuid import uuid4
9
+
10
+ from al_data_utils import files_sync_utils
11
+ from al_data_utils.logging_utils import info
12
+ from al_data_utils.sql_utils.sql_utils import UnifiedSQLClient, SQLDialect
13
+
14
+ from .config import SnapshotConfig
15
+ from .emit import emit_index_ddl, emit_table_ddl, emit_sql_object_ddl
16
+ from .introspect import (
17
+ get_indexes,
18
+ get_foreign_keys,
19
+ get_tables,
20
+ get_views,
21
+ get_stored_procedures,
22
+ get_functions,
23
+ get_sequences,
24
+ get_synonyms,
25
+ )
26
+ from .io import (
27
+ table_file_path,
28
+ object_file_path,
29
+ write_text_atomic,
30
+ set_mtime_like,
31
+ read_text_if_exists,
32
+ )
33
+
34
+
35
+ def _run_id() -> str:
36
+ return datetime.now().strftime('%Y%m%d_%H%M%S')
37
+
38
+
39
+ def _safe_path_token(value: str) -> str:
40
+ # Keep only characters that are safe and portable across Windows/Linux filesystems.
41
+ token = re.sub(r'[^A-Za-z0-9._-]+', '_', value.strip())
42
+ token = token.strip('._-')
43
+ return token[:80] if token else 'default'
44
+
45
+
46
+ def _run_folder_name(cfg: SnapshotConfig) -> str:
47
+ rid = _run_id()
48
+ if cfg.staging_include_run_guid:
49
+ return f'{rid}_{uuid4().hex[:8]}'
50
+ return rid
51
+
52
+
53
+ def _run_root(*, staging_root: Path, cfg: SnapshotConfig) -> Path:
54
+ base = staging_root
55
+ if cfg.staging_group_by_database and cfg.staging_database_name:
56
+ base = base / _safe_path_token(cfg.staging_database_name)
57
+ return base / _run_folder_name(cfg)
58
+
59
+
60
+ def _default_staging_root(output_root: Path) -> Path:
61
+ return Path(str(output_root) + '__staging')
62
+
63
+
64
+ def _batch_sep(cfg: SnapshotConfig) -> str:
65
+ return cfg.batch_separator.strip()
66
+
67
+
68
+ def _join_blocks(blocks: list[str], cfg: SnapshotConfig) -> str:
69
+ sep = _batch_sep(cfg)
70
+ nl = cfg.newline
71
+ if not blocks:
72
+ return ''
73
+ if sep:
74
+ return (f'{nl}{sep}{nl}{nl}').join(blocks) + f'{nl}{sep}{nl}'
75
+ return (f'{nl}{nl}').join(blocks) + nl
76
+
77
+
78
+ def _read_template_override(
79
+ *, cfg: SnapshotConfig, file_name: str, object_folder: str | None = None
80
+ ) -> str | None:
81
+ if not cfg.prefer_template_files:
82
+ return None
83
+ root = cfg.datagrip_templates_root
84
+ if root is None:
85
+ return None
86
+
87
+ folder_candidates: list[str] = []
88
+ if object_folder:
89
+ folder_candidates.append(object_folder)
90
+ # Backward-compat alias between old and new naming.
91
+ if object_folder == 'routines':
92
+ folder_candidates.append('stored_procedures')
93
+ if object_folder == 'stored_procedures':
94
+ folder_candidates.append('routines')
95
+
96
+ # 1) Exact folder + file path.
97
+ for folder in folder_candidates:
98
+ candidate = root / folder / file_name
99
+ if candidate.exists() and candidate.is_file():
100
+ return candidate.read_text(encoding=cfg.encoding)
101
+
102
+ # 2) Legacy flat root path.
103
+ candidate = root / file_name
104
+ if candidate.exists() and candidate.is_file():
105
+ return candidate.read_text(encoding=cfg.encoding)
106
+
107
+ lower_name = file_name.lower()
108
+ try:
109
+ # 3) Recursive search, folder-scoped first.
110
+ for folder in folder_candidates:
111
+ folder_path = root / folder
112
+ if not folder_path.exists() or not folder_path.is_dir():
113
+ continue
114
+ for p in folder_path.rglob('*.sql'):
115
+ if p.name.lower() == lower_name:
116
+ return p.read_text(encoding=cfg.encoding)
117
+
118
+ # 4) Recursive fallback across template root.
119
+ for p in root.rglob('*.sql'):
120
+ if p.name.lower() == lower_name:
121
+ return p.read_text(encoding=cfg.encoding)
122
+ except Exception:
123
+ return None
124
+
125
+ return None
126
+
127
+
128
+ def _iter_template_folder_files(
129
+ *, cfg: SnapshotConfig, object_folder: str
130
+ ) -> list[Path]:
131
+ root = cfg.datagrip_templates_root
132
+ if root is None:
133
+ return []
134
+ folder = root / object_folder
135
+ if not folder.exists() or not folder.is_dir():
136
+ return []
137
+ return sorted(folder.glob('*.sql'))
138
+
139
+
140
+ def _parse_schema_object_from_file_name(file_name: str) -> tuple[str, str] | None:
141
+ name = file_name.strip()
142
+ if not name.lower().endswith('.sql'):
143
+ return None
144
+ stem = name[:-4]
145
+ if '.' not in stem:
146
+ return None
147
+ schema, object_name = stem.split('.', 1)
148
+ schema = schema.strip()
149
+ object_name = object_name.strip()
150
+ if not schema or not object_name:
151
+ return None
152
+ return schema, object_name
153
+
154
+
155
+ def _render_table_file(
156
+ *, schema: str, table: str, table_ddl: str, indexes: list[str], cfg: SnapshotConfig
157
+ ) -> str:
158
+ nl = cfg.newline
159
+ lines: list[str] = []
160
+
161
+ if cfg.header_comment:
162
+ lines.append(cfg.header_comment)
163
+ if cfg.section_comments:
164
+ lines.append(f'-- table: {schema}.{table}')
165
+ lines.append('')
166
+
167
+ if table_ddl:
168
+ if cfg.section_comments:
169
+ lines.append('-- === table ===')
170
+ lines.append(table_ddl.strip())
171
+ lines.append('')
172
+
173
+ if indexes:
174
+ if cfg.section_comments:
175
+ lines.append('-- === indexes ===')
176
+ lines.append(_join_blocks(indexes, cfg).rstrip(nl))
177
+ lines.append('')
178
+
179
+ return nl.join(lines).strip() + nl
180
+
181
+
182
+ def _render_sql_object_file(
183
+ *,
184
+ schema: str,
185
+ object_name: str,
186
+ object_kind: str,
187
+ object_ddl: str,
188
+ cfg: SnapshotConfig,
189
+ ) -> str:
190
+ nl = cfg.newline
191
+ lines: list[str] = []
192
+
193
+ if cfg.header_comment:
194
+ lines.append(cfg.header_comment)
195
+ if cfg.section_comments:
196
+ lines.append(f'-- {object_kind}: {schema}.{object_name}')
197
+ lines.append('')
198
+ lines.append(object_ddl.strip())
199
+
200
+ sep = _batch_sep(cfg)
201
+ if sep:
202
+ lines.append('')
203
+ lines.append(sep)
204
+
205
+ return nl.join(lines).strip() + nl
206
+
207
+
208
+ def _write_object_file(
209
+ *, text: str, staging_path: Path, dest_path: Path, cfg: SnapshotConfig
210
+ ) -> None:
211
+ write_text_atomic(staging_path, text, encoding=cfg.encoding, newline=cfg.newline)
212
+
213
+ # keep mtime aligned when content matches so files_sync_utils doesn't "update"
214
+ dest_text = read_text_if_exists(dest_path, encoding=cfg.encoding)
215
+ if dest_text is not None:
216
+ norm_dest = dest_text.replace('\r\n', '\n').replace('\r', '\n')
217
+ norm_new = text.replace('\r\n', '\n').replace('\r', '\n')
218
+ if norm_dest == norm_new:
219
+ set_mtime_like(staging_path, dest_path)
220
+
221
+
222
+ def snapshot_schema(
223
+ *, sql_client: UnifiedSQLClient, config: SnapshotConfig
224
+ ) -> list[Path]:
225
+ cfg = config
226
+ _info = info if cfg.verbose else (lambda _: None)
227
+ output_root = cfg.output_root
228
+ staging_root = cfg.staging_root or _default_staging_root(output_root)
229
+ run_root = _run_root(staging_root=staging_root, cfg=cfg)
230
+
231
+ _info(f'[schema_snapshot..] run root: {run_root}')
232
+ _info(
233
+ '[schema_snapshot..] collecting metadata (tables, indexes, foreign keys, views, procedures)...'
234
+ )
235
+
236
+ if cfg.include_table_definitions:
237
+ _info('[schema_snapshot..] metadata step: fetching tables (filtered SQL query)')
238
+ table_defs = get_tables(
239
+ sql_client,
240
+ include_schemas=cfg.include_schemas,
241
+ exclude_schemas=cfg.exclude_schemas,
242
+ include_tables=cfg.include_tables,
243
+ exclude_tables=cfg.exclude_tables,
244
+ )
245
+ else:
246
+ table_defs = []
247
+
248
+ if cfg.include_indexes:
249
+ _info('[schema_snapshot..] metadata step: fetching indexes (filtered SQL query)')
250
+ indexes = get_indexes(
251
+ sql_client,
252
+ include_schemas=cfg.include_schemas,
253
+ exclude_schemas=cfg.exclude_schemas,
254
+ include_tables=cfg.include_tables,
255
+ exclude_tables=cfg.exclude_tables,
256
+ )
257
+ else:
258
+ indexes = []
259
+
260
+ if cfg.include_foreign_keys:
261
+ _info(
262
+ '[schema_snapshot..] metadata step: fetching foreign keys (filtered SQL query)'
263
+ )
264
+ fks = get_foreign_keys(
265
+ sql_client,
266
+ include_schemas=cfg.include_schemas,
267
+ exclude_schemas=cfg.exclude_schemas,
268
+ include_tables=cfg.include_tables,
269
+ exclude_tables=cfg.exclude_tables,
270
+ )
271
+ else:
272
+ fks = []
273
+
274
+ if cfg.include_views:
275
+ _info('[schema_snapshot..] metadata step: fetching views (filtered SQL query)')
276
+ views = get_views(
277
+ sql_client,
278
+ include_schemas=cfg.include_schemas,
279
+ exclude_schemas=cfg.exclude_schemas,
280
+ include_views=cfg.include_views_objects,
281
+ exclude_views=cfg.exclude_views_objects,
282
+ )
283
+ else:
284
+ views = []
285
+
286
+ if cfg.include_stored_procedures:
287
+ _info(
288
+ '[schema_snapshot..] metadata step: fetching stored procedures '
289
+ '(filtered SQL query/per-object definitions)'
290
+ )
291
+ sprocs = get_stored_procedures(
292
+ sql_client,
293
+ include_schemas=cfg.include_schemas,
294
+ exclude_schemas=cfg.exclude_schemas,
295
+ include_objects=cfg.include_stored_procedures_objects,
296
+ exclude_objects=cfg.exclude_stored_procedures_objects,
297
+ verbose=cfg.verbose,
298
+ )
299
+ else:
300
+ sprocs = []
301
+
302
+ if cfg.include_functions:
303
+ _info(
304
+ '[schema_snapshot..] metadata step: fetching functions (filtered SQL query)'
305
+ )
306
+ funcs = get_functions(
307
+ sql_client,
308
+ include_schemas=cfg.include_schemas,
309
+ exclude_schemas=cfg.exclude_schemas,
310
+ include_objects=cfg.include_functions_objects,
311
+ exclude_objects=cfg.exclude_functions_objects,
312
+ )
313
+ else:
314
+ funcs = []
315
+
316
+ if cfg.include_sequences:
317
+ _info(
318
+ '[schema_snapshot..] metadata step: fetching sequences (filtered SQL query)'
319
+ )
320
+ sequences = get_sequences(
321
+ sql_client,
322
+ include_schemas=cfg.include_schemas,
323
+ exclude_schemas=cfg.exclude_schemas,
324
+ include_objects=cfg.include_sequences_objects,
325
+ exclude_objects=cfg.exclude_sequences_objects,
326
+ )
327
+ else:
328
+ sequences = []
329
+
330
+ if cfg.include_synonyms:
331
+ _info(
332
+ '[schema_snapshot..] metadata step: fetching synonyms (filtered SQL query)'
333
+ )
334
+ synonyms = get_synonyms(
335
+ sql_client,
336
+ include_schemas=cfg.include_schemas,
337
+ exclude_schemas=cfg.exclude_schemas,
338
+ include_objects=cfg.include_synonyms_objects,
339
+ exclude_objects=cfg.exclude_synonyms_objects,
340
+ )
341
+ else:
342
+ synonyms = []
343
+
344
+ _info(
345
+ '[schema_snapshot..] metadata counts: '
346
+ f'tables={len(table_defs)}, indexes={len(indexes)}, '
347
+ f'foreign_keys={len(fks)}, views={len(views)}, '
348
+ f'procedures={len(sprocs)}, functions={len(funcs)}, '
349
+ f'sequences={len(sequences)}, synonyms={len(synonyms)}'
350
+ )
351
+
352
+ idx_by_table: dict[tuple[str, str], list[str]] = defaultdict(list)
353
+ for idx in indexes:
354
+ if idx.is_hypothetical:
355
+ continue
356
+ if not cfg.is_schema_allowed(idx.schema_name):
357
+ continue
358
+ if not cfg.is_table_allowed(idx.schema_name, idx.table_name):
359
+ continue
360
+ idx_by_table[(idx.schema_name, idx.table_name)].append(emit_index_ddl(idx))
361
+
362
+ fk_by_table: dict[tuple[str, str], list] = defaultdict(list)
363
+ for fk in fks:
364
+ if not cfg.is_schema_allowed(fk.parent_schema):
365
+ continue
366
+ if not cfg.is_table_allowed(fk.parent_schema, fk.parent_table):
367
+ continue
368
+ fk_by_table[(fk.parent_schema, fk.parent_table)].append(fk)
369
+
370
+ tables_by_key = {
371
+ (t.schema_name, t.table_name): t
372
+ for t in table_defs
373
+ if cfg.is_schema_allowed(t.schema_name)
374
+ and cfg.is_table_allowed(t.schema_name, t.table_name)
375
+ }
376
+
377
+ all_tables = sorted(
378
+ set(tables_by_key.keys()) | set(idx_by_table.keys()) | set(fk_by_table.keys()),
379
+ key=lambda x: (x[0].lower(), x[1].lower()),
380
+ )
381
+
382
+ written_paths: list[Path] = []
383
+
384
+ _info(f'[schema_snapshot..] writing table files: {len(all_tables)}')
385
+
386
+ for schema, table in all_tables:
387
+ _info(f'[schema_snapshot..] extracting table: {schema}.{table}')
388
+ text = _read_template_override(
389
+ cfg=cfg, file_name=f'{schema}.{table}.sql', object_folder='tables'
390
+ )
391
+ if text is None:
392
+ table_def = tables_by_key.get((schema, table))
393
+ table_ddl = (
394
+ emit_table_ddl(
395
+ table_def, foreign_keys=fk_by_table.get((schema, table), [])
396
+ )
397
+ if table_def is not None
398
+ else ''
399
+ )
400
+
401
+ idx_list = sorted(idx_by_table.get((schema, table), []))
402
+
403
+ text = _render_table_file(
404
+ schema=schema,
405
+ table=table,
406
+ table_ddl=table_ddl,
407
+ indexes=idx_list,
408
+ cfg=cfg,
409
+ )
410
+
411
+ staging_path = table_file_path(
412
+ root=run_root,
413
+ root_folder_name=cfg.root_folder_name,
414
+ schema=schema,
415
+ table=table,
416
+ )
417
+ dest_path = table_file_path(
418
+ root=output_root,
419
+ root_folder_name=cfg.root_folder_name,
420
+ schema=schema,
421
+ table=table,
422
+ )
423
+
424
+ _write_object_file(
425
+ text=text, staging_path=staging_path, dest_path=dest_path, cfg=cfg
426
+ )
427
+ _info(f'[schema_snapshot..] wrote table file: {dest_path}')
428
+ written_paths.append(dest_path)
429
+
430
+ filtered_views = [
431
+ v
432
+ for v in views
433
+ if cfg.is_schema_allowed(v.schema_name)
434
+ and cfg.is_view_allowed(v.schema_name, v.object_name)
435
+ ]
436
+ _info(f'[schema_snapshot..] writing view files: {len(filtered_views)}')
437
+ for v in sorted(
438
+ filtered_views, key=lambda x: (x.schema_name.lower(), x.object_name.lower())
439
+ ):
440
+ _info(f'[schema_snapshot..] extracting view: {v.schema_name}.{v.object_name}')
441
+ text = _read_template_override(
442
+ cfg=cfg,
443
+ file_name=f'{v.schema_name}.{v.object_name}.sql',
444
+ object_folder='views',
445
+ )
446
+ if text is None:
447
+ text = _render_sql_object_file(
448
+ schema=v.schema_name,
449
+ object_name=v.object_name,
450
+ object_kind='view',
451
+ object_ddl=emit_sql_object_ddl(v),
452
+ cfg=cfg,
453
+ )
454
+ staging_path = object_file_path(
455
+ root=run_root,
456
+ root_folder_name=cfg.root_folder_name,
457
+ schema=v.schema_name,
458
+ object_folder='views',
459
+ object_name=v.object_name,
460
+ )
461
+ dest_path = object_file_path(
462
+ root=output_root,
463
+ root_folder_name=cfg.root_folder_name,
464
+ schema=v.schema_name,
465
+ object_folder='views',
466
+ object_name=v.object_name,
467
+ )
468
+ _write_object_file(
469
+ text=text, staging_path=staging_path, dest_path=dest_path, cfg=cfg
470
+ )
471
+ _info(f'[schema_snapshot..] wrote view file: {dest_path}')
472
+ written_paths.append(dest_path)
473
+
474
+ filtered_sprocs = [
475
+ p
476
+ for p in sprocs
477
+ if cfg.is_schema_allowed(p.schema_name)
478
+ and cfg.is_stored_procedure_allowed(p.schema_name, p.object_name)
479
+ ]
480
+ filtered_funcs = [
481
+ f
482
+ for f in funcs
483
+ if cfg.is_schema_allowed(f.schema_name)
484
+ and cfg.is_function_allowed(f.schema_name, f.object_name)
485
+ ]
486
+
487
+ total_routines = len(filtered_sprocs) + len(filtered_funcs)
488
+ _info(f'[schema_snapshot..] writing routines files: {total_routines}')
489
+
490
+ for f in sorted(
491
+ filtered_funcs, key=lambda x: (x.schema_name.lower(), x.object_name.lower())
492
+ ):
493
+ _info(f'[schema_snapshot..] extracting routine: {f.schema_name}.{f.object_name}')
494
+ text = _read_template_override(
495
+ cfg=cfg,
496
+ file_name=f'{f.schema_name}.{f.object_name}.sql',
497
+ object_folder='routines',
498
+ )
499
+ if text is None:
500
+ text = _render_sql_object_file(
501
+ schema=f.schema_name,
502
+ object_name=f.object_name,
503
+ object_kind='routine',
504
+ object_ddl=emit_sql_object_ddl(f),
505
+ cfg=cfg,
506
+ )
507
+ staging_path = object_file_path(
508
+ root=run_root,
509
+ root_folder_name=cfg.root_folder_name,
510
+ schema=f.schema_name,
511
+ object_folder='routines',
512
+ object_name=f.object_name,
513
+ )
514
+ dest_path = object_file_path(
515
+ root=output_root,
516
+ root_folder_name=cfg.root_folder_name,
517
+ schema=f.schema_name,
518
+ object_folder='routines',
519
+ object_name=f.object_name,
520
+ )
521
+ _write_object_file(
522
+ text=text, staging_path=staging_path, dest_path=dest_path, cfg=cfg
523
+ )
524
+ _info(f'[schema_snapshot..] wrote routine file: {dest_path}')
525
+ written_paths.append(dest_path)
526
+
527
+ for p in sorted(
528
+ filtered_sprocs, key=lambda x: (x.schema_name.lower(), x.object_name.lower())
529
+ ):
530
+ _info(f'[schema_snapshot..] extracting routine: {p.schema_name}.{p.object_name}')
531
+ text = _read_template_override(
532
+ cfg=cfg,
533
+ file_name=f'{p.schema_name}.{p.object_name}.sql',
534
+ object_folder='routines',
535
+ )
536
+ if text is None:
537
+ text = _render_sql_object_file(
538
+ schema=p.schema_name,
539
+ object_name=p.object_name,
540
+ object_kind='stored_procedure',
541
+ object_ddl=emit_sql_object_ddl(p),
542
+ cfg=cfg,
543
+ )
544
+ staging_path = object_file_path(
545
+ root=run_root,
546
+ root_folder_name=cfg.root_folder_name,
547
+ schema=p.schema_name,
548
+ object_folder='routines',
549
+ object_name=p.object_name,
550
+ )
551
+ dest_path = object_file_path(
552
+ root=output_root,
553
+ root_folder_name=cfg.root_folder_name,
554
+ schema=p.schema_name,
555
+ object_folder='routines',
556
+ object_name=p.object_name,
557
+ )
558
+ _write_object_file(
559
+ text=text, staging_path=staging_path, dest_path=dest_path, cfg=cfg
560
+ )
561
+ _info(f'[schema_snapshot..] wrote routine file: {dest_path}')
562
+ written_paths.append(dest_path)
563
+
564
+ filtered_sequences = [
565
+ s
566
+ for s in sequences
567
+ if cfg.is_schema_allowed(s.schema_name)
568
+ and cfg.is_sequence_allowed(s.schema_name, s.object_name)
569
+ ]
570
+ _info(f'[schema_snapshot..] writing sequences files: {len(filtered_sequences)}')
571
+ for s in sorted(
572
+ filtered_sequences, key=lambda x: (x.schema_name.lower(), x.object_name.lower())
573
+ ):
574
+ _info(
575
+ f'[schema_snapshot..] extracting sequence: {s.schema_name}.{s.object_name}'
576
+ )
577
+ text = _read_template_override(
578
+ cfg=cfg,
579
+ file_name=f'{s.schema_name}.{s.object_name}.sql',
580
+ object_folder='sequences',
581
+ )
582
+ if text is None:
583
+ text = _render_sql_object_file(
584
+ schema=s.schema_name,
585
+ object_name=s.object_name,
586
+ object_kind='sequence',
587
+ object_ddl=emit_sql_object_ddl(s),
588
+ cfg=cfg,
589
+ )
590
+ staging_path = object_file_path(
591
+ root=run_root,
592
+ root_folder_name=cfg.root_folder_name,
593
+ schema=s.schema_name,
594
+ object_folder='sequences',
595
+ object_name=s.object_name,
596
+ )
597
+ dest_path = object_file_path(
598
+ root=output_root,
599
+ root_folder_name=cfg.root_folder_name,
600
+ schema=s.schema_name,
601
+ object_folder='sequences',
602
+ object_name=s.object_name,
603
+ )
604
+ _write_object_file(
605
+ text=text, staging_path=staging_path, dest_path=dest_path, cfg=cfg
606
+ )
607
+ _info(f'[schema_snapshot..] wrote sequence file: {dest_path}')
608
+ written_paths.append(dest_path)
609
+
610
+ filtered_synonyms = [
611
+ s
612
+ for s in synonyms
613
+ if cfg.is_schema_allowed(s.schema_name)
614
+ and cfg.is_synonym_allowed(s.schema_name, s.object_name)
615
+ ]
616
+ _info(f'[schema_snapshot..] writing synonyms files: {len(filtered_synonyms)}')
617
+ for s in sorted(
618
+ filtered_synonyms, key=lambda x: (x.schema_name.lower(), x.object_name.lower())
619
+ ):
620
+ _info(f'[schema_snapshot..] extracting synonym: {s.schema_name}.{s.object_name}')
621
+ text = _read_template_override(
622
+ cfg=cfg,
623
+ file_name=f'{s.schema_name}.{s.object_name}.sql',
624
+ object_folder='synonyms',
625
+ )
626
+ if text is None:
627
+ text = _render_sql_object_file(
628
+ schema=s.schema_name,
629
+ object_name=s.object_name,
630
+ object_kind='synonym',
631
+ object_ddl=emit_sql_object_ddl(s),
632
+ cfg=cfg,
633
+ )
634
+ staging_path = object_file_path(
635
+ root=run_root,
636
+ root_folder_name=cfg.root_folder_name,
637
+ schema=s.schema_name,
638
+ object_folder='synonyms',
639
+ object_name=s.object_name,
640
+ )
641
+ dest_path = object_file_path(
642
+ root=output_root,
643
+ root_folder_name=cfg.root_folder_name,
644
+ schema=s.schema_name,
645
+ object_folder='synonyms',
646
+ object_name=s.object_name,
647
+ )
648
+ _write_object_file(
649
+ text=text, staging_path=staging_path, dest_path=dest_path, cfg=cfg
650
+ )
651
+ _info(f'[schema_snapshot..] wrote synonym file: {dest_path}')
652
+ written_paths.append(dest_path)
653
+
654
+ # Optional passthrough: copy template-only SQL files from dedicated folders.
655
+ # This allows extra object types (e.g., sequences/synonyms/routines functions)
656
+ # to be produced even when they are not introspected from the database yet.
657
+ if cfg.prefer_template_files and cfg.datagrip_templates_root is not None:
658
+ written_set = {str(p).lower() for p in written_paths}
659
+ for folder_name in ('tables', 'views', 'routines', 'sequences', 'synonyms'):
660
+ for template_path in _iter_template_folder_files(
661
+ cfg=cfg, object_folder=folder_name
662
+ ):
663
+ parsed = _parse_schema_object_from_file_name(template_path.name)
664
+ if parsed is None:
665
+ continue
666
+
667
+ schema, object_name = parsed
668
+ if folder_name == 'tables':
669
+ dest_path = table_file_path(
670
+ root=output_root,
671
+ root_folder_name=cfg.root_folder_name,
672
+ schema=schema,
673
+ table=object_name,
674
+ )
675
+ staging_path = table_file_path(
676
+ root=run_root,
677
+ root_folder_name=cfg.root_folder_name,
678
+ schema=schema,
679
+ table=object_name,
680
+ )
681
+ else:
682
+ dest_path = object_file_path(
683
+ root=output_root,
684
+ root_folder_name=cfg.root_folder_name,
685
+ schema=schema,
686
+ object_folder=folder_name,
687
+ object_name=object_name,
688
+ )
689
+ staging_path = object_file_path(
690
+ root=run_root,
691
+ root_folder_name=cfg.root_folder_name,
692
+ schema=schema,
693
+ object_folder=folder_name,
694
+ object_name=object_name,
695
+ )
696
+
697
+ key = str(dest_path).lower()
698
+ text = template_path.read_text(encoding=cfg.encoding)
699
+ _write_object_file(
700
+ text=text, staging_path=staging_path, dest_path=dest_path, cfg=cfg
701
+ )
702
+ _info(
703
+ f'[schema_snapshot..] wrote template passthrough file: {dest_path}'
704
+ )
705
+ written_paths.append(dest_path)
706
+ written_set.add(key)
707
+
708
+ # sync staging -> destination using existing module (unchanged)
709
+ sync_cfg = files_sync_utils.SyncConfig(
710
+ source_paths=[str(run_root)],
711
+ target_path=str(output_root),
712
+ log_folder=str(cfg.sync_log_folder or (output_root / 'sync_logs')),
713
+ copy_folders_names_to_skip=[],
714
+ copy_files_names_to_skip=[],
715
+ delete_folders_names_to_skip=[],
716
+ delete_files_names_to_skip=[],
717
+ delete_folders_names_to_try=[],
718
+ delete_files_names_to_try=[],
719
+ )
720
+
721
+ plan_df = files_sync_utils.build_plan_df(sync_cfg)
722
+ _info('[schema_snapshot..] syncing staging -> output...')
723
+ executed_df, summary = files_sync_utils.execute_plan(plan_df)
724
+ files_sync_utils.log_activity(executed_df, summary, sync_cfg.log_folder)
725
+ if cfg.sync_log_retention_days is not None and cfg.sync_log_retention_days >= 0:
726
+ files_sync_utils.log_folder_retention(
727
+ sync_cfg.log_folder, retain_days=cfg.sync_log_retention_days
728
+ )
729
+ _info('[schema_snapshot..] sync completed')
730
+
731
+ if cfg.cleanup_staging:
732
+ try:
733
+ import shutil
734
+
735
+ shutil.rmtree(run_root, ignore_errors=True)
736
+ _info('[schema_snapshot..] staging cleaned up')
737
+ except Exception:
738
+ pass
739
+
740
+ _info(f'[schema_snapshot..] done. written files: {len(written_paths)}')
741
+ return written_paths
742
+
743
+
744
+ def snapshot_schema_from_params(
745
+ *, mssql_params: dict, config: SnapshotConfig
746
+ ) -> list[Path]:
747
+ cfg = config
748
+ if not cfg.staging_database_name:
749
+ db_name = mssql_params.get('database')
750
+ if isinstance(db_name, str) and db_name.strip():
751
+ cfg = replace(cfg, staging_database_name=db_name.strip())
752
+
753
+ sql_client = UnifiedSQLClient(SQLDialect.MSSQL, mssql_params)
754
+ return snapshot_schema(sql_client=sql_client, config=cfg)