fraiseql-confiture 0.3.7__cp311-cp311-macosx_11_0_arm64.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 (124) hide show
  1. confiture/__init__.py +48 -0
  2. confiture/_core.cpython-311-darwin.so +0 -0
  3. confiture/cli/__init__.py +0 -0
  4. confiture/cli/dry_run.py +116 -0
  5. confiture/cli/lint_formatter.py +193 -0
  6. confiture/cli/main.py +1893 -0
  7. confiture/config/__init__.py +0 -0
  8. confiture/config/environment.py +263 -0
  9. confiture/core/__init__.py +51 -0
  10. confiture/core/anonymization/__init__.py +0 -0
  11. confiture/core/anonymization/audit.py +485 -0
  12. confiture/core/anonymization/benchmarking.py +372 -0
  13. confiture/core/anonymization/breach_notification.py +652 -0
  14. confiture/core/anonymization/compliance.py +617 -0
  15. confiture/core/anonymization/composer.py +298 -0
  16. confiture/core/anonymization/data_subject_rights.py +669 -0
  17. confiture/core/anonymization/factory.py +319 -0
  18. confiture/core/anonymization/governance.py +737 -0
  19. confiture/core/anonymization/performance.py +1092 -0
  20. confiture/core/anonymization/profile.py +284 -0
  21. confiture/core/anonymization/registry.py +195 -0
  22. confiture/core/anonymization/security/kms_manager.py +547 -0
  23. confiture/core/anonymization/security/lineage.py +888 -0
  24. confiture/core/anonymization/security/token_store.py +686 -0
  25. confiture/core/anonymization/strategies/__init__.py +41 -0
  26. confiture/core/anonymization/strategies/address.py +359 -0
  27. confiture/core/anonymization/strategies/credit_card.py +374 -0
  28. confiture/core/anonymization/strategies/custom.py +161 -0
  29. confiture/core/anonymization/strategies/date.py +218 -0
  30. confiture/core/anonymization/strategies/differential_privacy.py +398 -0
  31. confiture/core/anonymization/strategies/email.py +141 -0
  32. confiture/core/anonymization/strategies/format_preserving_encryption.py +310 -0
  33. confiture/core/anonymization/strategies/hash.py +150 -0
  34. confiture/core/anonymization/strategies/ip_address.py +235 -0
  35. confiture/core/anonymization/strategies/masking_retention.py +252 -0
  36. confiture/core/anonymization/strategies/name.py +298 -0
  37. confiture/core/anonymization/strategies/phone.py +119 -0
  38. confiture/core/anonymization/strategies/preserve.py +85 -0
  39. confiture/core/anonymization/strategies/redact.py +101 -0
  40. confiture/core/anonymization/strategies/salted_hashing.py +322 -0
  41. confiture/core/anonymization/strategies/text_redaction.py +183 -0
  42. confiture/core/anonymization/strategies/tokenization.py +334 -0
  43. confiture/core/anonymization/strategy.py +241 -0
  44. confiture/core/anonymization/syncer_audit.py +357 -0
  45. confiture/core/blue_green.py +683 -0
  46. confiture/core/builder.py +500 -0
  47. confiture/core/checksum.py +358 -0
  48. confiture/core/connection.py +184 -0
  49. confiture/core/differ.py +522 -0
  50. confiture/core/drift.py +564 -0
  51. confiture/core/dry_run.py +182 -0
  52. confiture/core/health.py +313 -0
  53. confiture/core/hooks/__init__.py +87 -0
  54. confiture/core/hooks/base.py +232 -0
  55. confiture/core/hooks/context.py +146 -0
  56. confiture/core/hooks/execution_strategies.py +57 -0
  57. confiture/core/hooks/observability.py +220 -0
  58. confiture/core/hooks/phases.py +53 -0
  59. confiture/core/hooks/registry.py +295 -0
  60. confiture/core/large_tables.py +775 -0
  61. confiture/core/linting/__init__.py +70 -0
  62. confiture/core/linting/composer.py +192 -0
  63. confiture/core/linting/libraries/__init__.py +17 -0
  64. confiture/core/linting/libraries/gdpr.py +168 -0
  65. confiture/core/linting/libraries/general.py +184 -0
  66. confiture/core/linting/libraries/hipaa.py +144 -0
  67. confiture/core/linting/libraries/pci_dss.py +104 -0
  68. confiture/core/linting/libraries/sox.py +120 -0
  69. confiture/core/linting/schema_linter.py +491 -0
  70. confiture/core/linting/versioning.py +151 -0
  71. confiture/core/locking.py +389 -0
  72. confiture/core/migration_generator.py +298 -0
  73. confiture/core/migrator.py +882 -0
  74. confiture/core/observability/__init__.py +44 -0
  75. confiture/core/observability/audit.py +323 -0
  76. confiture/core/observability/logging.py +187 -0
  77. confiture/core/observability/metrics.py +174 -0
  78. confiture/core/observability/tracing.py +192 -0
  79. confiture/core/pg_version.py +418 -0
  80. confiture/core/pool.py +406 -0
  81. confiture/core/risk/__init__.py +39 -0
  82. confiture/core/risk/predictor.py +188 -0
  83. confiture/core/risk/scoring.py +248 -0
  84. confiture/core/rollback_generator.py +388 -0
  85. confiture/core/schema_analyzer.py +769 -0
  86. confiture/core/schema_to_schema.py +590 -0
  87. confiture/core/security/__init__.py +32 -0
  88. confiture/core/security/logging.py +201 -0
  89. confiture/core/security/validation.py +416 -0
  90. confiture/core/signals.py +371 -0
  91. confiture/core/syncer.py +540 -0
  92. confiture/exceptions.py +192 -0
  93. confiture/integrations/__init__.py +0 -0
  94. confiture/models/__init__.py +24 -0
  95. confiture/models/lint.py +193 -0
  96. confiture/models/migration.py +265 -0
  97. confiture/models/schema.py +203 -0
  98. confiture/models/sql_file_migration.py +225 -0
  99. confiture/scenarios/__init__.py +36 -0
  100. confiture/scenarios/compliance.py +586 -0
  101. confiture/scenarios/ecommerce.py +199 -0
  102. confiture/scenarios/financial.py +253 -0
  103. confiture/scenarios/healthcare.py +315 -0
  104. confiture/scenarios/multi_tenant.py +340 -0
  105. confiture/scenarios/saas.py +295 -0
  106. confiture/testing/FRAMEWORK_API.md +722 -0
  107. confiture/testing/__init__.py +100 -0
  108. confiture/testing/fixtures/__init__.py +11 -0
  109. confiture/testing/fixtures/data_validator.py +229 -0
  110. confiture/testing/fixtures/migration_runner.py +167 -0
  111. confiture/testing/fixtures/schema_snapshotter.py +352 -0
  112. confiture/testing/frameworks/__init__.py +10 -0
  113. confiture/testing/frameworks/mutation.py +587 -0
  114. confiture/testing/frameworks/performance.py +479 -0
  115. confiture/testing/loader.py +225 -0
  116. confiture/testing/pytest/__init__.py +38 -0
  117. confiture/testing/pytest_plugin.py +190 -0
  118. confiture/testing/sandbox.py +304 -0
  119. confiture/testing/utils/__init__.py +0 -0
  120. fraiseql_confiture-0.3.7.dist-info/METADATA +438 -0
  121. fraiseql_confiture-0.3.7.dist-info/RECORD +124 -0
  122. fraiseql_confiture-0.3.7.dist-info/WHEEL +4 -0
  123. fraiseql_confiture-0.3.7.dist-info/entry_points.txt +4 -0
  124. fraiseql_confiture-0.3.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,522 @@
1
+ """Schema differ for detecting database schema changes.
2
+
3
+ This module provides functionality to:
4
+ - Parse SQL DDL statements into structured schema models
5
+ - Compare two schemas and detect differences
6
+ - Generate migrations from schema diffs
7
+ """
8
+
9
+ import re
10
+
11
+ import sqlparse
12
+ from sqlparse.sql import Identifier, Parenthesis, Statement
13
+ from sqlparse.tokens import Keyword, Name
14
+
15
+ from confiture.models.schema import Column, ColumnType, SchemaChange, SchemaDiff, Table
16
+
17
+
18
+ class SchemaDiffer:
19
+ """Parses SQL and detects schema differences.
20
+
21
+ Example:
22
+ >>> differ = SchemaDiffer()
23
+ >>> tables = differ.parse_sql("CREATE TABLE users (id INT)")
24
+ >>> print(tables[0].name)
25
+ users
26
+ """
27
+
28
+ def parse_sql(self, sql: str) -> list[Table]:
29
+ """Parse SQL DDL into structured Table objects.
30
+
31
+ Args:
32
+ sql: SQL DDL string containing CREATE TABLE statements
33
+
34
+ Returns:
35
+ List of parsed Table objects
36
+
37
+ Example:
38
+ >>> differ = SchemaDiffer()
39
+ >>> sql = "CREATE TABLE users (id INT PRIMARY KEY, name TEXT)"
40
+ >>> tables = differ.parse_sql(sql)
41
+ >>> print(len(tables))
42
+ 1
43
+ """
44
+ if not sql or not sql.strip():
45
+ return []
46
+
47
+ # Parse SQL into statements
48
+ statements = sqlparse.parse(sql)
49
+
50
+ tables: list[Table] = []
51
+ for stmt in statements:
52
+ if self._is_create_table(stmt):
53
+ table = self._parse_create_table(stmt)
54
+ if table:
55
+ tables.append(table)
56
+
57
+ return tables
58
+
59
+ def compare(self, old_sql: str, new_sql: str) -> SchemaDiff:
60
+ """Compare two schemas and detect changes.
61
+
62
+ Args:
63
+ old_sql: SQL DDL for the old schema
64
+ new_sql: SQL DDL for the new schema
65
+
66
+ Returns:
67
+ SchemaDiff object containing list of changes
68
+
69
+ Example:
70
+ >>> differ = SchemaDiffer()
71
+ >>> old = "CREATE TABLE users (id INT);"
72
+ >>> new = "CREATE TABLE users (id INT, name TEXT);"
73
+ >>> diff = differ.compare(old, new)
74
+ >>> print(len(diff.changes))
75
+ 1
76
+ """
77
+ old_tables = self.parse_sql(old_sql)
78
+ new_tables = self.parse_sql(new_sql)
79
+
80
+ changes: list[SchemaChange] = []
81
+
82
+ # Build name-to-table maps for efficient lookup
83
+ old_table_map = {t.name: t for t in old_tables}
84
+ new_table_map = {t.name: t for t in new_tables}
85
+
86
+ # Detect table-level changes
87
+ old_table_names = set(old_table_map.keys())
88
+ new_table_names = set(new_table_map.keys())
89
+
90
+ # Check for renamed tables (fuzzy match before drop/add)
91
+ renamed_tables = self._detect_table_renames(
92
+ old_table_names - new_table_names, new_table_names - old_table_names
93
+ )
94
+
95
+ # Process renamed tables
96
+ for old_name, new_name in renamed_tables.items():
97
+ changes.append(
98
+ SchemaChange(type="RENAME_TABLE", old_value=old_name, new_value=new_name)
99
+ )
100
+ # Mark as processed
101
+ old_table_names.discard(old_name)
102
+ new_table_names.discard(new_name)
103
+
104
+ # Dropped tables (in old but not in new, and not renamed)
105
+ for table_name in old_table_names - new_table_names:
106
+ changes.append(SchemaChange(type="DROP_TABLE", table=table_name))
107
+
108
+ # New tables (in new but not in old, and not renamed)
109
+ for table_name in new_table_names - old_table_names:
110
+ changes.append(SchemaChange(type="ADD_TABLE", table=table_name))
111
+
112
+ # Compare columns in tables that exist in both schemas
113
+ for table_name in old_table_names & new_table_names:
114
+ old_table = old_table_map[table_name]
115
+ new_table = new_table_map[table_name]
116
+ table_changes = self._compare_table_columns(old_table, new_table)
117
+ changes.extend(table_changes)
118
+
119
+ return SchemaDiff(changes=changes)
120
+
121
+ def _detect_table_renames(self, old_names: set[str], new_names: set[str]) -> dict[str, str]:
122
+ """Detect renamed tables using fuzzy matching.
123
+
124
+ Args:
125
+ old_names: Set of table names that exist in old schema only
126
+ new_names: Set of table names that exist in new schema only
127
+
128
+ Returns:
129
+ Dictionary mapping old_name -> new_name for detected renames
130
+ """
131
+ renames: dict[str, str] = {}
132
+
133
+ for old_name in old_names:
134
+ # Look for similar names in new_names
135
+ best_match = self._find_best_match(old_name, new_names)
136
+ if best_match and self._similarity_score(old_name, best_match) > 0.5:
137
+ renames[old_name] = best_match
138
+
139
+ return renames
140
+
141
+ def _compare_table_columns(self, old_table: Table, new_table: Table) -> list[SchemaChange]:
142
+ """Compare columns between two versions of the same table.
143
+
144
+ Args:
145
+ old_table: Old version of table
146
+ new_table: New version of table
147
+
148
+ Returns:
149
+ List of SchemaChange objects for column-level changes
150
+ """
151
+ changes: list[SchemaChange] = []
152
+
153
+ old_col_map = {c.name: c for c in old_table.columns}
154
+ new_col_map = {c.name: c for c in new_table.columns}
155
+
156
+ old_col_names = set(old_col_map.keys())
157
+ new_col_names = set(new_col_map.keys())
158
+
159
+ # Detect renamed columns
160
+ renamed_columns = self._detect_column_renames(
161
+ old_col_names - new_col_names, new_col_names - old_col_names
162
+ )
163
+
164
+ # Process renamed columns
165
+ for old_name, new_name in renamed_columns.items():
166
+ changes.append(
167
+ SchemaChange(
168
+ type="RENAME_COLUMN",
169
+ table=old_table.name,
170
+ old_value=old_name,
171
+ new_value=new_name,
172
+ )
173
+ )
174
+ # Mark as processed
175
+ old_col_names.discard(old_name)
176
+ new_col_names.discard(new_name)
177
+
178
+ # Dropped columns
179
+ for col_name in old_col_names - new_col_names:
180
+ changes.append(SchemaChange(type="DROP_COLUMN", table=old_table.name, column=col_name))
181
+
182
+ # New columns
183
+ for col_name in new_col_names - old_col_names:
184
+ changes.append(SchemaChange(type="ADD_COLUMN", table=old_table.name, column=col_name))
185
+
186
+ # Compare columns that exist in both
187
+ for col_name in old_col_names & new_col_names:
188
+ old_col = old_col_map[col_name]
189
+ new_col = new_col_map[col_name]
190
+ col_changes = self._compare_column_properties(old_table.name, old_col, new_col)
191
+ changes.extend(col_changes)
192
+
193
+ return changes
194
+
195
+ def _detect_column_renames(self, old_names: set[str], new_names: set[str]) -> dict[str, str]:
196
+ """Detect renamed columns using fuzzy matching."""
197
+ renames: dict[str, str] = {}
198
+
199
+ for old_name in old_names:
200
+ best_match = self._find_best_match(old_name, new_names)
201
+ if best_match and self._similarity_score(old_name, best_match) > 0.5:
202
+ renames[old_name] = best_match
203
+
204
+ return renames
205
+
206
+ def _compare_column_properties(
207
+ self, table_name: str, old_col: Column, new_col: Column
208
+ ) -> list[SchemaChange]:
209
+ """Compare properties of a column."""
210
+ changes: list[SchemaChange] = []
211
+
212
+ # Type change
213
+ if old_col.type != new_col.type:
214
+ changes.append(
215
+ SchemaChange(
216
+ type="CHANGE_COLUMN_TYPE",
217
+ table=table_name,
218
+ column=old_col.name,
219
+ old_value=old_col.type.value,
220
+ new_value=new_col.type.value,
221
+ )
222
+ )
223
+
224
+ # Nullable change
225
+ if old_col.nullable != new_col.nullable:
226
+ changes.append(
227
+ SchemaChange(
228
+ type="CHANGE_COLUMN_NULLABLE",
229
+ table=table_name,
230
+ column=old_col.name,
231
+ old_value="true" if old_col.nullable else "false",
232
+ new_value="true" if new_col.nullable else "false",
233
+ )
234
+ )
235
+
236
+ # Default change
237
+ if old_col.default != new_col.default:
238
+ changes.append(
239
+ SchemaChange(
240
+ type="CHANGE_COLUMN_DEFAULT",
241
+ table=table_name,
242
+ column=old_col.name,
243
+ old_value=str(old_col.default) if old_col.default else None,
244
+ new_value=str(new_col.default) if new_col.default else None,
245
+ )
246
+ )
247
+
248
+ return changes
249
+
250
+ def _find_best_match(self, name: str, candidates: set[str]) -> str | None:
251
+ """Find best matching name from candidates."""
252
+ if not candidates:
253
+ return None
254
+
255
+ best_match = None
256
+ best_score = 0.0
257
+
258
+ for candidate in candidates:
259
+ score = self._similarity_score(name, candidate)
260
+ if score > best_score:
261
+ best_score = score
262
+ best_match = candidate
263
+
264
+ return best_match
265
+
266
+ def _similarity_score(self, name1: str, name2: str) -> float:
267
+ """Calculate similarity score between two names (0.0 to 1.0).
268
+
269
+ Uses multiple heuristics to detect renames:
270
+ 1. Common suffix/prefix patterns (e.g., "full_name" -> "display_name" = 0.5)
271
+ 2. Word-based similarity (e.g., "user_accounts" -> "user_profiles" = 0.5)
272
+ 3. Character-based Jaccard similarity
273
+ """
274
+ name1 = name1.lower()
275
+ name2 = name2.lower()
276
+
277
+ # Exact match
278
+ if name1 == name2:
279
+ return 1.0
280
+
281
+ # Split on underscores to get word parts
282
+ name1_parts = name1.split("_")
283
+ name2_parts = name2.split("_")
284
+
285
+ # Check for common suffix/prefix patterns
286
+ # e.g., "full_name" and "display_name" share "_name" suffix
287
+ if len(name1_parts) > 1 or len(name2_parts) > 1:
288
+ # Check suffix
289
+ if name1_parts[-1] == name2_parts[-1]:
290
+ # Same suffix, different prefix -> likely rename
291
+ return 0.6
292
+
293
+ # Check prefix
294
+ if name1_parts[0] == name2_parts[0]:
295
+ # Same prefix, different suffix -> likely rename
296
+ return 0.6
297
+
298
+ # Word-level similarity
299
+ name1_words = set(name1_parts)
300
+ name2_words = set(name2_parts)
301
+ common_words = name1_words & name2_words
302
+
303
+ if common_words:
304
+ # Jaccard similarity for words
305
+ return len(common_words) / len(name1_words | name2_words)
306
+
307
+ # Character-level Jaccard similarity
308
+ name1_chars = set(name1)
309
+ name2_chars = set(name2)
310
+ common_chars = name1_chars & name2_chars
311
+
312
+ if common_chars:
313
+ return len(common_chars) / len(name1_chars | name2_chars)
314
+
315
+ return 0.0
316
+
317
+ def _is_create_table(self, stmt: Statement) -> bool:
318
+ """Check if statement is a CREATE TABLE statement."""
319
+ # Check if statement type is CREATE
320
+ stmt_type: str | None = stmt.get_type()
321
+ return bool(stmt_type == "CREATE")
322
+
323
+ def _parse_create_table(self, stmt: Statement) -> Table | None:
324
+ """Parse a CREATE TABLE statement."""
325
+ try:
326
+ # Extract table name
327
+ table_name = self._extract_table_name(stmt)
328
+ if not table_name:
329
+ return None
330
+
331
+ # Extract column definitions
332
+ columns = self._extract_columns(stmt)
333
+
334
+ return Table(name=table_name, columns=columns)
335
+
336
+ except Exception:
337
+ # Skip malformed statements
338
+ return None
339
+
340
+ def _extract_table_name(self, stmt: Statement) -> str | None:
341
+ """Extract table name from CREATE TABLE statement."""
342
+ # Find the table name after CREATE TABLE keywords
343
+ found_create = False
344
+ found_table = False
345
+
346
+ for token in stmt.tokens:
347
+ if token.is_whitespace:
348
+ continue
349
+
350
+ # Check for CREATE keyword
351
+ if token.ttype is Keyword.DDL and token.value.upper() == "CREATE":
352
+ found_create = True
353
+ continue
354
+
355
+ # Check for TABLE keyword
356
+ if found_create and token.ttype is Keyword and token.value.upper() == "TABLE":
357
+ found_table = True
358
+ continue
359
+
360
+ # Next identifier is the table name
361
+ if found_table:
362
+ if isinstance(token, Identifier):
363
+ return str(token.get_real_name())
364
+ if token.ttype is Name:
365
+ return str(token.value)
366
+
367
+ return None
368
+
369
+ def _extract_columns(self, stmt: Statement) -> list[Column]:
370
+ """Extract column definitions from CREATE TABLE statement."""
371
+ columns: list[Column] = []
372
+
373
+ # Find the parenthesis containing column definitions
374
+ column_def_parens = None
375
+ for token in stmt.tokens:
376
+ if isinstance(token, Parenthesis):
377
+ column_def_parens = token
378
+ break
379
+
380
+ if not column_def_parens:
381
+ return columns
382
+
383
+ # Parse column definitions
384
+ # Split on commas to get individual columns
385
+ column_text = str(column_def_parens.value)[1:-1] # Remove outer parens
386
+ column_parts = self._split_columns(column_text)
387
+
388
+ for part in column_parts:
389
+ column = self._parse_column_definition(part.strip())
390
+ if column:
391
+ columns.append(column)
392
+
393
+ return columns
394
+
395
+ def _split_columns(self, text: str) -> list[str]:
396
+ """Split column definitions by comma, respecting nested parentheses."""
397
+ parts: list[str] = []
398
+ current = []
399
+ paren_depth = 0
400
+
401
+ for char in text:
402
+ if char == "(":
403
+ paren_depth += 1
404
+ current.append(char)
405
+ elif char == ")":
406
+ paren_depth -= 1
407
+ current.append(char)
408
+ elif char == "," and paren_depth == 0:
409
+ parts.append("".join(current))
410
+ current = []
411
+ else:
412
+ current.append(char)
413
+
414
+ if current:
415
+ parts.append("".join(current))
416
+
417
+ return parts
418
+
419
+ def _parse_column_definition(self, col_def: str) -> Column | None:
420
+ """Parse a single column definition string."""
421
+ try:
422
+ parts = col_def.split()
423
+ if len(parts) < 2:
424
+ return None
425
+
426
+ col_name = parts[0].strip("\"'")
427
+ col_type_str = parts[1].upper()
428
+
429
+ # Extract column type and length
430
+ col_type, length = self._parse_column_type(col_type_str)
431
+
432
+ # Parse constraints
433
+ upper_def = col_def.upper()
434
+ nullable = "NOT NULL" not in upper_def
435
+ primary_key = "PRIMARY KEY" in upper_def
436
+ unique = "UNIQUE" in upper_def and not primary_key
437
+
438
+ # Extract default value
439
+ default = self._extract_default(col_def)
440
+
441
+ return Column(
442
+ name=col_name,
443
+ type=col_type,
444
+ nullable=nullable,
445
+ default=default,
446
+ primary_key=primary_key,
447
+ unique=unique,
448
+ length=length,
449
+ )
450
+
451
+ except Exception:
452
+ return None
453
+
454
+ def _parse_column_type(self, type_str: str) -> tuple[ColumnType, int | None]:
455
+ """Parse column type string into ColumnType and optional length.
456
+
457
+ Args:
458
+ type_str: Column type string (e.g., "VARCHAR(255)", "INT", "TIMESTAMP")
459
+
460
+ Returns:
461
+ Tuple of (ColumnType, length)
462
+ """
463
+ # Extract length from types like VARCHAR(255)
464
+ length = None
465
+ match = re.match(r"([A-Z]+)\((\d+)\)", type_str)
466
+ if match:
467
+ type_str = match.group(1)
468
+ length = int(match.group(2))
469
+
470
+ # Map SQL type to ColumnType enum
471
+ type_mapping = {
472
+ "SMALLINT": ColumnType.SMALLINT,
473
+ "INT": ColumnType.INTEGER,
474
+ "INTEGER": ColumnType.INTEGER,
475
+ "BIGINT": ColumnType.BIGINT,
476
+ "SERIAL": ColumnType.SERIAL,
477
+ "BIGSERIAL": ColumnType.BIGSERIAL,
478
+ "NUMERIC": ColumnType.NUMERIC,
479
+ "DECIMAL": ColumnType.DECIMAL,
480
+ "REAL": ColumnType.REAL,
481
+ "DOUBLE": ColumnType.DOUBLE_PRECISION,
482
+ "VARCHAR": ColumnType.VARCHAR,
483
+ "CHAR": ColumnType.CHAR,
484
+ "TEXT": ColumnType.TEXT,
485
+ "BOOLEAN": ColumnType.BOOLEAN,
486
+ "BOOL": ColumnType.BOOLEAN,
487
+ "DATE": ColumnType.DATE,
488
+ "TIME": ColumnType.TIME,
489
+ "TIMESTAMP": ColumnType.TIMESTAMP,
490
+ "TIMESTAMPTZ": ColumnType.TIMESTAMPTZ,
491
+ "UUID": ColumnType.UUID,
492
+ "JSON": ColumnType.JSON,
493
+ "JSONB": ColumnType.JSONB,
494
+ "BYTEA": ColumnType.BYTEA,
495
+ }
496
+
497
+ col_type = type_mapping.get(type_str, ColumnType.UNKNOWN)
498
+ return col_type, length
499
+
500
+ def _extract_default(self, col_def: str) -> str | None:
501
+ """Extract DEFAULT value from column definition."""
502
+ match = re.search(r"DEFAULT\s+([^\s,]+)", col_def, re.IGNORECASE)
503
+ if match:
504
+ default_val = match.group(1)
505
+ # Handle function calls like NOW()
506
+ if "(" in default_val:
507
+ # Find the matching closing paren
508
+ start = match.start(1)
509
+ text = col_def[start:]
510
+ paren_count = 0
511
+ end_idx = 0
512
+ for i, char in enumerate(text):
513
+ if char == "(":
514
+ paren_count += 1
515
+ elif char == ")":
516
+ paren_count -= 1
517
+ if paren_count == 0:
518
+ end_idx = i + 1
519
+ break
520
+ return text[:end_idx] if end_idx > 0 else default_val
521
+ return default_val
522
+ return None