fraiseql-confiture 0.1.0__cp311-cp311-manylinux_2_34_x86_64.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.
Potentially problematic release.
This version of fraiseql-confiture might be problematic. Click here for more details.
- confiture/__init__.py +45 -0
- confiture/_core.cpython-311-x86_64-linux-gnu.so +0 -0
- confiture/cli/__init__.py +0 -0
- confiture/cli/main.py +720 -0
- confiture/config/__init__.py +0 -0
- confiture/config/environment.py +190 -0
- confiture/core/__init__.py +0 -0
- confiture/core/builder.py +336 -0
- confiture/core/connection.py +120 -0
- confiture/core/differ.py +522 -0
- confiture/core/migration_generator.py +298 -0
- confiture/core/migrator.py +369 -0
- confiture/core/schema_to_schema.py +592 -0
- confiture/core/syncer.py +540 -0
- confiture/exceptions.py +141 -0
- confiture/integrations/__init__.py +0 -0
- confiture/models/__init__.py +0 -0
- confiture/models/migration.py +95 -0
- confiture/models/schema.py +203 -0
- fraiseql_confiture-0.1.0.dist-info/METADATA +350 -0
- fraiseql_confiture-0.1.0.dist-info/RECORD +24 -0
- fraiseql_confiture-0.1.0.dist-info/WHEEL +4 -0
- fraiseql_confiture-0.1.0.dist-info/entry_points.txt +2 -0
- fraiseql_confiture-0.1.0.dist-info/licenses/LICENSE +21 -0
confiture/core/differ.py
ADDED
|
@@ -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
|