dbbridgekit 0.1.0__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.
@@ -0,0 +1,292 @@
1
+ """Dialeto PostgreSQL: parser (DDL Postgres -> IR) e renderer (IR -> DDL Postgres).
2
+
3
+ Mesmo escopo documentado em dialects/sqlite.py — subconjunto realista de DDL, não gramática
4
+ completa. Reaproveita o parser de tabela genérico (core/sql_lex.py) mudando só o mapeamento de
5
+ tipos e o reconhecimento de `GENERATED ... AS IDENTITY`/`SERIAL` como autoincrement.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from ..core.ir import (
12
+ CheckConstraint,
13
+ Column,
14
+ ForeignKey,
15
+ Index,
16
+ Schema,
17
+ Table,
18
+ UniqueConstraint,
19
+ View,
20
+ )
21
+ from ..core.parser import DialectParser, ParseFinding, ParseResult, register_parser
22
+ from ..core.renderer import DialectRenderer, RenderFinding, RenderResult, register_renderer
23
+ from ..core.sql_lex import split_statements, split_top_level_commas, strip_outer_parens
24
+ from ..core.types import Ambiguity, CanonicalType
25
+
26
+ _POSTGRES_TYPE_MAP = {
27
+ "INTEGER": CanonicalType.INTEGER, "INT": CanonicalType.INTEGER, "INT4": CanonicalType.INTEGER,
28
+ "SERIAL": CanonicalType.INTEGER, # auto_increment=True tratado à parte
29
+ "SMALLINT": CanonicalType.SMALLINT, "INT2": CanonicalType.SMALLINT, "SMALLSERIAL": CanonicalType.SMALLINT,
30
+ "BIGINT": CanonicalType.BIGINT, "INT8": CanonicalType.BIGINT, "BIGSERIAL": CanonicalType.BIGINT,
31
+ "REAL": CanonicalType.FLOAT, "FLOAT4": CanonicalType.FLOAT, "DOUBLE PRECISION": CanonicalType.FLOAT, "FLOAT8": CanonicalType.FLOAT,
32
+ "NUMERIC": CanonicalType.NUMERIC, "DECIMAL": CanonicalType.NUMERIC,
33
+ "BOOLEAN": CanonicalType.BOOLEAN, "BOOL": CanonicalType.BOOLEAN,
34
+ "TEXT": CanonicalType.TEXT,
35
+ "VARCHAR": CanonicalType.VARCHAR, "CHARACTER VARYING": CanonicalType.VARCHAR, "CHAR": CanonicalType.VARCHAR,
36
+ "DATE": CanonicalType.DATE,
37
+ "TIME": CanonicalType.TIME,
38
+ "TIMESTAMP": CanonicalType.DATETIME,
39
+ "TIMESTAMPTZ": CanonicalType.TIMESTAMP_TZ, "TIMESTAMP WITH TIME ZONE": CanonicalType.TIMESTAMP_TZ,
40
+ "JSON": CanonicalType.JSON, "JSONB": CanonicalType.JSON,
41
+ "UUID": CanonicalType.UUID,
42
+ "BYTEA": CanonicalType.BINARY,
43
+ }
44
+ _SERIAL_TYPES = {"SERIAL", "SMALLSERIAL", "BIGSERIAL"}
45
+
46
+ _CREATE_TABLE_RE = re.compile(
47
+ r"^\s*CREATE\s+TABLE\s+(IF\s+NOT\s+EXISTS\s+)?\"?(\w+)\"?\s*\((.*)\)\s*$",
48
+ re.IGNORECASE | re.DOTALL,
49
+ )
50
+ _CREATE_INDEX_RE = re.compile(
51
+ r"^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(IF\s+NOT\s+EXISTS\s+)?\"?(\w+)\"?\s+"
52
+ r"ON\s+\"?(\w+)\"?\s*\((.*)\)\s*$",
53
+ re.IGNORECASE | re.DOTALL,
54
+ )
55
+ _CREATE_VIEW_RE = re.compile(
56
+ r"^\s*CREATE\s+VIEW\s+(IF\s+NOT\s+EXISTS\s+)?\"?(\w+)\"?\s+AS\s+(.*)$",
57
+ re.IGNORECASE | re.DOTALL,
58
+ )
59
+ _TYPE_WITH_ARGS_RE = re.compile(r"^([\w\s]+?)\s*(?:\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\))?$")
60
+ _IDENTITY_RE = re.compile(r"GENERATED\s+(?:ALWAYS|BY\s+DEFAULT)\s+AS\s+IDENTITY", re.IGNORECASE)
61
+
62
+
63
+ def _parse_column(item: str) -> tuple[Column | None, list[ParseFinding]]:
64
+ findings: list[ParseFinding] = []
65
+ tokens = item.strip().split(None, 2)
66
+ if len(tokens) < 2:
67
+ return None, [ParseFinding(Ambiguity.UNRECOGNIZED_DDL, item, "Definição de coluna incompleta.")]
68
+ name = tokens[0].strip('"')
69
+ type_str = tokens[1]
70
+ rest = tokens[2] if len(tokens) > 2 else ""
71
+
72
+ # "DOUBLE PRECISION"/"TIMESTAMP WITH TIME ZONE"/"CHARACTER VARYING" são tipos de 2+ palavras —
73
+ # tenta casar contra o mapa antes de assumir que a 2ª palavra em diante já é constraint.
74
+ combined_upper = f"{type_str} {rest}".upper()
75
+ multi_word_type = next((t for t in _POSTGRES_TYPE_MAP if " " in t and combined_upper.startswith(t)), None)
76
+ if multi_word_type:
77
+ type_str = multi_word_type
78
+ # recupera o texto original (preserva caixa) removendo o prefixo do tipo já identificado
79
+ original_combined = f"{tokens[1]} {tokens[2] if len(tokens) > 2 else ''}"
80
+ rest = original_combined[len(multi_word_type):].strip()
81
+
82
+ base_match = _TYPE_WITH_ARGS_RE.match(type_str.strip())
83
+ if not base_match:
84
+ canonical, length, precision, scale, auto_increment = CanonicalType.TEXT, None, None, None, False
85
+ findings.append(ParseFinding(Ambiguity.UNSUPPORTED_TYPE, type_str, f"Tipo '{type_str}' não reconhecido — assumido TEXT."))
86
+ else:
87
+ base = base_match.group(1).strip().upper()
88
+ canonical = _POSTGRES_TYPE_MAP.get(base)
89
+ auto_increment = base in _SERIAL_TYPES
90
+ if canonical is None:
91
+ canonical = CanonicalType.TEXT
92
+ findings.append(ParseFinding(Ambiguity.UNSUPPORTED_TYPE, type_str, f"Tipo '{base}' não mapeado — assumido TEXT."))
93
+ length = int(base_match.group(2)) if (canonical == CanonicalType.VARCHAR and base_match.group(2)) else None
94
+ precision = int(base_match.group(2)) if (canonical == CanonicalType.NUMERIC and base_match.group(2)) else None
95
+ scale = int(base_match.group(3)) if (canonical == CanonicalType.NUMERIC and base_match.group(3)) else None
96
+
97
+ upper_rest = rest.upper()
98
+ nullable = "NOT NULL" not in upper_rest
99
+ primary_key = "PRIMARY KEY" in upper_rest
100
+ if _IDENTITY_RE.search(rest):
101
+ auto_increment = True
102
+ unique = bool(re.search(r"\bUNIQUE\b", upper_rest)) and "PRIMARY KEY" not in upper_rest[:upper_rest.find("UNIQUE") if "UNIQUE" in upper_rest else 0]
103
+
104
+ default = None
105
+ if not _IDENTITY_RE.search(rest):
106
+ # Checagem tem que ser no `rest` INTEIRO, não só no trecho casado por default_m: a frase
107
+ # "GENERATED BY DEFAULT AS IDENTITY" contém a substring "DEFAULT AS", que o regex de default
108
+ # casava sozinho (bug real encontrado testando: virava `DEFAULT AS` na coluna, lixo). Só
109
+ # tenta achar um DEFAULT de verdade se a coluna não for IDENTITY (que já cobre o "default"
110
+ # dela via auto_increment, sem precisar de um valor literal).
111
+ default_m = re.search(r"DEFAULT\s+('(?:[^']|'')*'|\S+)", rest, re.IGNORECASE)
112
+ if default_m:
113
+ default = default_m.group(1)
114
+
115
+ col = Column(name=name, type=canonical, nullable=nullable, primary_key=primary_key,
116
+ auto_increment=auto_increment, unique=unique, default=default,
117
+ length=length, precision=precision, scale=scale)
118
+ return col, findings
119
+
120
+
121
+ def _parse_table_body(body: str) -> tuple[list[Column], list[ForeignKey], list[UniqueConstraint], list[CheckConstraint], list[ParseFinding]]:
122
+ columns: list[Column] = []
123
+ fks: list[ForeignKey] = []
124
+ uniques: list[UniqueConstraint] = []
125
+ checks: list[CheckConstraint] = []
126
+ findings: list[ParseFinding] = []
127
+
128
+ for item in split_top_level_commas(body):
129
+ stripped = item.strip()
130
+ upper = stripped.upper()
131
+ if upper.startswith("PRIMARY KEY"):
132
+ cols_txt = strip_outer_parens(stripped[len("PRIMARY KEY"):].strip())
133
+ col_names = [c.strip(' "') for c in cols_txt.split(",")]
134
+ for cname in col_names:
135
+ col = next((c for c in columns if c.name == cname), None)
136
+ if col:
137
+ col.primary_key = True
138
+ elif upper.startswith("FOREIGN KEY"):
139
+ # Ações de FK são um conjunto FIXO da SQL padrão (CASCADE/RESTRICT/NO ACTION/SET NULL/SET
140
+ # DEFAULT) — enumeradas explicitamente em vez de `\w+(?:\s+\w+)?` genérico, que capturava
141
+ # greedy demais (bug real: "CASCADE ON UPDATE ..." virava on_delete="CASCADE ON" porque o
142
+ # 2º grupo opcional casava a palavra "ON" antes do literal "ON UPDATE" ter chance de bater).
143
+ _FK_ACTION = r"CASCADE|RESTRICT|NO\s+ACTION|SET\s+NULL|SET\s+DEFAULT"
144
+ m = re.match(r'FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+"?(\w+)"?\s*\(([^)]*)\)'
145
+ rf'(?:\s+ON\s+DELETE\s+({_FK_ACTION}))?(?:\s+ON\s+UPDATE\s+({_FK_ACTION}))?',
146
+ stripped, re.IGNORECASE)
147
+ if m:
148
+ cols = [c.strip(' "') for c in m.group(1).split(",")]
149
+ ref_cols = [c.strip(' "') for c in m.group(3).split(",")]
150
+ fks.append(ForeignKey(name=None, columns=cols, ref_table=m.group(2),
151
+ ref_columns=ref_cols, on_delete=m.group(4), on_update=m.group(5)))
152
+ else:
153
+ findings.append(ParseFinding(Ambiguity.UNRECOGNIZED_DDL, stripped, "FOREIGN KEY não reconhecida."))
154
+ elif upper.startswith("UNIQUE"):
155
+ cols_txt = strip_outer_parens(stripped[len("UNIQUE"):].strip())
156
+ col_names = [c.strip(' "') for c in cols_txt.split(",")]
157
+ uniques.append(UniqueConstraint(name=None, columns=col_names))
158
+ elif upper.startswith("CHECK"):
159
+ expr = strip_outer_parens(stripped[len("CHECK"):].strip())
160
+ checks.append(CheckConstraint(name=None, expression=expr))
161
+ else:
162
+ col, col_findings = _parse_column(stripped)
163
+ findings.extend(col_findings)
164
+ if col:
165
+ columns.append(col)
166
+
167
+ return columns, fks, uniques, checks, findings
168
+
169
+
170
+ class PostgresParser(DialectParser):
171
+ name = "postgres"
172
+
173
+ def parse(self, sql: str) -> ParseResult:
174
+ schema = Schema()
175
+ findings: list[ParseFinding] = []
176
+
177
+ for stmt in split_statements(sql):
178
+ m = _CREATE_TABLE_RE.match(stmt)
179
+ if m:
180
+ table_name = m.group(2)
181
+ columns, fks, uniques, checks, body_findings = _parse_table_body(m.group(3))
182
+ findings.extend(body_findings)
183
+ schema.tables.append(Table(
184
+ name=table_name, columns=columns, foreign_keys=fks,
185
+ unique_constraints=uniques, check_constraints=checks))
186
+ continue
187
+
188
+ m = _CREATE_INDEX_RE.match(stmt)
189
+ if m:
190
+ is_unique, _, idx_name, table_name, cols_txt = m.groups()
191
+ cols = [c.strip(' "') for c in cols_txt.split(",")]
192
+ table = schema.table(table_name)
193
+ if table:
194
+ table.indexes.append(Index(name=idx_name, columns=cols, unique=bool(is_unique)))
195
+ else:
196
+ findings.append(ParseFinding(Ambiguity.UNRECOGNIZED_DDL, stmt,
197
+ f"Índice '{idx_name}' referencia tabela '{table_name}' não vista ainda."))
198
+ continue
199
+
200
+ m = _CREATE_VIEW_RE.match(stmt)
201
+ if m:
202
+ schema.views.append(View(name=m.group(2), query=m.group(3).strip()))
203
+ continue
204
+
205
+ if re.match(r"^\s*CREATE\s+(OR\s+REPLACE\s+)?(TRIGGER|FUNCTION)", stmt, re.IGNORECASE):
206
+ findings.append(ParseFinding(Ambiguity.UNSUPPORTED_EXPRESSION, stmt,
207
+ "Trigger/função procedural é específica do dialeto — "
208
+ "não convertida automaticamente."))
209
+ continue
210
+
211
+ findings.append(ParseFinding(Ambiguity.UNRECOGNIZED_DDL, stmt, "Statement não reconhecido pelo parser Postgres."))
212
+
213
+ return ParseResult(schema=schema, findings=findings)
214
+
215
+
216
+ _CANONICAL_TO_POSTGRES = {
217
+ CanonicalType.INTEGER: "INTEGER", CanonicalType.SMALLINT: "SMALLINT", CanonicalType.BIGINT: "BIGINT",
218
+ CanonicalType.FLOAT: "DOUBLE PRECISION", CanonicalType.NUMERIC: "NUMERIC", CanonicalType.BOOLEAN: "BOOLEAN",
219
+ CanonicalType.TEXT: "TEXT", CanonicalType.VARCHAR: "VARCHAR", CanonicalType.DATE: "DATE",
220
+ CanonicalType.TIME: "TIME", CanonicalType.DATETIME: "TIMESTAMP", CanonicalType.TIMESTAMP_TZ: "TIMESTAMPTZ",
221
+ CanonicalType.JSON: "JSONB", CanonicalType.UUID: "UUID", CanonicalType.BINARY: "BYTEA",
222
+ CanonicalType.ENUM: "TEXT",
223
+ }
224
+
225
+
226
+ def _render_column(col: Column) -> str:
227
+ if col.primary_key and col.auto_increment:
228
+ # Ver docstring do módulo/README: IDENTITY é a forma moderna recomendada (SERIAL é legado,
229
+ # mantido só implicitamente compatível — nunca emitido pelo renderer).
230
+ type_sql = "INTEGER GENERATED BY DEFAULT AS IDENTITY" if col.type != CanonicalType.BIGINT else \
231
+ "BIGINT GENERATED BY DEFAULT AS IDENTITY"
232
+ elif col.type == CanonicalType.VARCHAR and col.length:
233
+ type_sql = f"VARCHAR({col.length})"
234
+ elif col.type == CanonicalType.NUMERIC and col.precision:
235
+ type_sql = f"NUMERIC({col.precision}{',' + str(col.scale) if col.scale else ''})"
236
+ else:
237
+ type_sql = _CANONICAL_TO_POSTGRES.get(col.type, "TEXT")
238
+
239
+ parts = [col.name, type_sql]
240
+ if col.primary_key:
241
+ parts.append("PRIMARY KEY")
242
+ if not col.nullable and not col.primary_key:
243
+ parts.append("NOT NULL")
244
+ if col.unique and not col.primary_key:
245
+ parts.append("UNIQUE")
246
+ if col.default is not None:
247
+ parts.append(f"DEFAULT {col.default}")
248
+ if col.type == CanonicalType.ENUM and col.enum_values:
249
+ values = ",".join(f"'{v}'" for v in col.enum_values)
250
+ parts.append(f"CHECK({col.name} IN ({values}))")
251
+ return " ".join(parts)
252
+
253
+
254
+ class PostgresRenderer(DialectRenderer):
255
+ name = "postgres"
256
+
257
+ def render_schema(self, schema: Schema) -> RenderResult:
258
+ statements: list[str] = []
259
+ findings: list[RenderFinding] = []
260
+
261
+ for table in schema.tables:
262
+ lines = [_render_column(c) for c in table.columns]
263
+ for fk in table.foreign_keys:
264
+ cols = ",".join(fk.columns)
265
+ ref_cols = ",".join(fk.ref_columns)
266
+ clause = f"FOREIGN KEY({cols}) REFERENCES {fk.ref_table}({ref_cols})"
267
+ if fk.on_delete:
268
+ clause += f" ON DELETE {fk.on_delete}"
269
+ if fk.on_update:
270
+ clause += f" ON UPDATE {fk.on_update}"
271
+ lines.append(clause)
272
+ for uq in table.unique_constraints:
273
+ lines.append(f"UNIQUE({','.join(uq.columns)})")
274
+ for chk in table.check_constraints:
275
+ lines.append(f"CHECK({chk.expression})")
276
+ statements.append(f"CREATE TABLE IF NOT EXISTS {table.name} (\n " + ",\n ".join(lines) + "\n)")
277
+ for idx in table.indexes:
278
+ kind = "UNIQUE INDEX" if idx.unique else "INDEX"
279
+ statements.append(f"CREATE {kind} IF NOT EXISTS {idx.name} ON {table.name}({','.join(idx.columns)})")
280
+
281
+ for view in schema.views:
282
+ statements.append(f"CREATE OR REPLACE VIEW {view.name} AS {view.query}")
283
+
284
+ for trigger in schema.triggers:
285
+ findings.append(RenderFinding(Ambiguity.UNSUPPORTED_EXPRESSION, trigger.name,
286
+ "Trigger não renderizado — corpo procedural é específico do dialeto de origem."))
287
+
288
+ return RenderResult(sql=";\n\n".join(statements) + (";" if statements else ""), findings=findings)
289
+
290
+
291
+ register_parser("postgres", PostgresParser())
292
+ register_renderer("postgres", PostgresRenderer())
@@ -0,0 +1,279 @@
1
+ """Dialeto SQLite: parser (DDL SQLite -> IR) e renderer (IR -> DDL SQLite).
2
+
3
+ Cobre o subconjunto de sintaxe que aplicações reais realmente geram (o mesmo padrão observado numa
4
+ base de código de produção real durante o desenvolvimento desta ferramenta: `CREATE TABLE
5
+ IF NOT EXISTS nome(col TIPO restrições, ..., PRIMARY KEY(...), FOREIGN KEY(...) REFERENCES...)`) —
6
+ não é um parser de gramática SQL completa. Qualquer coisa fora desse subconjunto vira uma
7
+ `ParseFinding`/`RenderFinding`, nunca uma adivinhação.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from ..core.ir import (
14
+ CheckConstraint,
15
+ Column,
16
+ ForeignKey,
17
+ Index,
18
+ Schema,
19
+ Table,
20
+ UniqueConstraint,
21
+ View,
22
+ )
23
+ from ..core.parser import DialectParser, ParseFinding, ParseResult, register_parser
24
+ from ..core.renderer import DialectRenderer, RenderFinding, RenderResult, register_renderer
25
+ from ..core.sql_lex import split_statements, split_top_level_commas, strip_outer_parens
26
+ from ..core.types import Ambiguity, CanonicalType
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Mapeamento de tipos: SQLite tem "afinidade de tipo" (nomes livres) — normaliza os nomes mais
30
+ # comuns pro vocabulário canônico. Qualquer nome não reconhecido cai em TEXT (afinidade padrão do
31
+ # SQLite pra tipo desconhecido) COM uma finding, nunca silenciosamente.
32
+ # ---------------------------------------------------------------------------
33
+ _SQLITE_TYPE_MAP = {
34
+ "INTEGER": CanonicalType.INTEGER, "INT": CanonicalType.INTEGER,
35
+ "SMALLINT": CanonicalType.SMALLINT,
36
+ "BIGINT": CanonicalType.BIGINT,
37
+ "REAL": CanonicalType.FLOAT, "FLOAT": CanonicalType.FLOAT, "DOUBLE": CanonicalType.FLOAT,
38
+ "NUMERIC": CanonicalType.NUMERIC, "DECIMAL": CanonicalType.NUMERIC,
39
+ "BOOLEAN": CanonicalType.BOOLEAN, "BOOL": CanonicalType.BOOLEAN,
40
+ "TEXT": CanonicalType.TEXT, "CLOB": CanonicalType.TEXT,
41
+ "VARCHAR": CanonicalType.VARCHAR, "CHAR": CanonicalType.VARCHAR,
42
+ "DATE": CanonicalType.DATE,
43
+ "TIME": CanonicalType.TIME,
44
+ "DATETIME": CanonicalType.DATETIME, "TIMESTAMP": CanonicalType.DATETIME,
45
+ "JSON": CanonicalType.JSON,
46
+ "BLOB": CanonicalType.BINARY,
47
+ }
48
+
49
+ _CREATE_TABLE_RE = re.compile(
50
+ r"^\s*CREATE\s+TABLE\s+(IF\s+NOT\s+EXISTS\s+)?[\"'`\[]?(\w+)[\"'`\]]?\s*\((.*)\)\s*$",
51
+ re.IGNORECASE | re.DOTALL,
52
+ )
53
+ _CREATE_INDEX_RE = re.compile(
54
+ r"^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+(IF\s+NOT\s+EXISTS\s+)?[\"'`\[]?(\w+)[\"'`\]]?\s+"
55
+ r"ON\s+[\"'`\[]?(\w+)[\"'`\]]?\s*\((.*)\)\s*$",
56
+ re.IGNORECASE | re.DOTALL,
57
+ )
58
+ _CREATE_VIEW_RE = re.compile(
59
+ r"^\s*CREATE\s+VIEW\s+(IF\s+NOT\s+EXISTS\s+)?[\"'`\[]?(\w+)[\"'`\]]?\s+AS\s+(.*)$",
60
+ re.IGNORECASE | re.DOTALL,
61
+ )
62
+ _TYPE_WITH_ARGS_RE = re.compile(r"^(\w+)\s*(?:\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\))?$")
63
+
64
+
65
+ def _parse_column(item: str) -> tuple[Column | None, list[ParseFinding]]:
66
+ findings: list[ParseFinding] = []
67
+ tokens = item.strip().split(None, 2)
68
+ if len(tokens) < 2:
69
+ return None, [ParseFinding(Ambiguity.UNRECOGNIZED_DDL, item, "Definição de coluna incompleta.")]
70
+ name = tokens[0].strip('"\'`[]')
71
+ type_str = tokens[1]
72
+ rest = tokens[2] if len(tokens) > 2 else ""
73
+
74
+ m = _TYPE_WITH_ARGS_RE.match(type_str)
75
+ if m and not m.group(2) and rest:
76
+ # tipo pode ter argumentos com espaço antes do parêntese (ex: "VARCHAR (120)") — tenta juntar
77
+ combined = f"{type_str} {rest}".strip()
78
+ m2 = re.match(r"^(\w+)\s*\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\)\s*(.*)$", combined, re.IGNORECASE)
79
+ if m2:
80
+ type_str = f"{m2.group(1)}({m2.group(2)}{',' + m2.group(3) if m2.group(3) else ''})"
81
+ rest = m2.group(4)
82
+
83
+ base_match = re.match(r"^(\w+)\s*(?:\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\))?$", type_str)
84
+ if not base_match:
85
+ canonical, length, precision, scale = CanonicalType.TEXT, None, None, None
86
+ findings.append(ParseFinding(Ambiguity.UNSUPPORTED_TYPE, type_str, f"Tipo '{type_str}' não reconhecido — assumido TEXT."))
87
+ else:
88
+ base = base_match.group(1).upper()
89
+ canonical = _SQLITE_TYPE_MAP.get(base)
90
+ if canonical is None:
91
+ canonical = CanonicalType.TEXT
92
+ findings.append(ParseFinding(Ambiguity.UNSUPPORTED_TYPE, type_str, f"Tipo '{base}' não mapeado — assumido TEXT."))
93
+ length = int(base_match.group(2)) if (canonical == CanonicalType.VARCHAR and base_match.group(2)) else None
94
+ precision = int(base_match.group(2)) if (canonical == CanonicalType.NUMERIC and base_match.group(2)) else None
95
+ scale = int(base_match.group(3)) if (canonical == CanonicalType.NUMERIC and base_match.group(3)) else None
96
+
97
+ upper_rest = rest.upper()
98
+ nullable = "NOT NULL" not in upper_rest
99
+ primary_key = "PRIMARY KEY" in upper_rest
100
+ auto_increment = "AUTOINCREMENT" in upper_rest
101
+ unique = bool(re.search(r"\bUNIQUE\b", upper_rest)) and "PRIMARY KEY" not in upper_rest[:upper_rest.find("UNIQUE")]
102
+
103
+ default = None
104
+ default_m = re.search(r"DEFAULT\s+('(?:[^']|'')*'|\S+)", rest, re.IGNORECASE)
105
+ if default_m:
106
+ default = default_m.group(1)
107
+
108
+ col = Column(name=name, type=canonical, nullable=nullable, primary_key=primary_key,
109
+ auto_increment=auto_increment, unique=unique, default=default,
110
+ length=length, precision=precision, scale=scale)
111
+ return col, findings
112
+
113
+
114
+ def _parse_table_body(body: str) -> tuple[list[Column], list[ForeignKey], list[UniqueConstraint], list[CheckConstraint], list[ParseFinding]]:
115
+ columns: list[Column] = []
116
+ fks: list[ForeignKey] = []
117
+ uniques: list[UniqueConstraint] = []
118
+ checks: list[CheckConstraint] = []
119
+ findings: list[ParseFinding] = []
120
+
121
+ for item in split_top_level_commas(body):
122
+ stripped = item.strip()
123
+ upper = stripped.upper()
124
+ if upper.startswith("PRIMARY KEY"):
125
+ cols_txt = strip_outer_parens(stripped[len("PRIMARY KEY"):].strip())
126
+ col_names = [c.strip(' "\'`[]') for c in cols_txt.split(",")]
127
+ for cname in col_names:
128
+ col = next((c for c in columns if c.name == cname), None)
129
+ if col:
130
+ col.primary_key = True
131
+ elif upper.startswith("FOREIGN KEY"):
132
+ # Ver dialects/postgres.py — mesmo conjunto fixo de ações de FK em vez de `\w+(?:\s+\w+)?`
133
+ # genérico (bug real: capturava "CASCADE ON" por engano).
134
+ _FK_ACTION = r"CASCADE|RESTRICT|NO\s+ACTION|SET\s+NULL|SET\s+DEFAULT"
135
+ m = re.match(r'FOREIGN\s+KEY\s*\(([^)]*)\)\s*REFERENCES\s+["\'`\[]?(\w+)["\'`\]]?\s*\(([^)]*)\)'
136
+ rf'(?:\s+ON\s+DELETE\s+({_FK_ACTION}))?(?:\s+ON\s+UPDATE\s+({_FK_ACTION}))?',
137
+ stripped, re.IGNORECASE)
138
+ if m:
139
+ cols = [c.strip(' "\'`[]') for c in m.group(1).split(",")]
140
+ ref_cols = [c.strip(' "\'`[]') for c in m.group(3).split(",")]
141
+ fks.append(ForeignKey(name=None, columns=cols, ref_table=m.group(2),
142
+ ref_columns=ref_cols, on_delete=m.group(4), on_update=m.group(5)))
143
+ else:
144
+ findings.append(ParseFinding(Ambiguity.UNRECOGNIZED_DDL, stripped, "FOREIGN KEY não reconhecida."))
145
+ elif upper.startswith("UNIQUE"):
146
+ cols_txt = strip_outer_parens(stripped[len("UNIQUE"):].strip())
147
+ col_names = [c.strip(' "\'`[]') for c in cols_txt.split(",")]
148
+ uniques.append(UniqueConstraint(name=None, columns=col_names))
149
+ elif upper.startswith("CHECK"):
150
+ expr = strip_outer_parens(stripped[len("CHECK"):].strip())
151
+ checks.append(CheckConstraint(name=None, expression=expr))
152
+ else:
153
+ col, col_findings = _parse_column(stripped)
154
+ findings.extend(col_findings)
155
+ if col:
156
+ columns.append(col)
157
+
158
+ return columns, fks, uniques, checks, findings
159
+
160
+
161
+ class SQLiteParser(DialectParser):
162
+ name = "sqlite"
163
+
164
+ def parse(self, sql: str) -> ParseResult:
165
+ schema = Schema()
166
+ findings: list[ParseFinding] = []
167
+
168
+ for stmt in split_statements(sql):
169
+ m = _CREATE_TABLE_RE.match(stmt)
170
+ if m:
171
+ table_name = m.group(2)
172
+ columns, fks, uniques, checks, body_findings = _parse_table_body(m.group(3))
173
+ findings.extend(body_findings)
174
+ schema.tables.append(Table(
175
+ name=table_name, columns=columns, foreign_keys=fks,
176
+ unique_constraints=uniques, check_constraints=checks))
177
+ continue
178
+
179
+ m = _CREATE_INDEX_RE.match(stmt)
180
+ if m:
181
+ is_unique, _, idx_name, table_name, cols_txt = m.groups()
182
+ cols = [c.strip(' "\'`[]') for c in cols_txt.split(",")]
183
+ table = schema.table(table_name)
184
+ if table:
185
+ table.indexes.append(Index(name=idx_name, columns=cols, unique=bool(is_unique)))
186
+ else:
187
+ findings.append(ParseFinding(Ambiguity.UNRECOGNIZED_DDL, stmt,
188
+ f"Índice '{idx_name}' referencia tabela '{table_name}' não vista ainda."))
189
+ continue
190
+
191
+ m = _CREATE_VIEW_RE.match(stmt)
192
+ if m:
193
+ schema.views.append(View(name=m.group(2), query=m.group(3).strip()))
194
+ continue
195
+
196
+ if re.match(r"^\s*CREATE\s+TRIGGER", stmt, re.IGNORECASE):
197
+ findings.append(ParseFinding(Ambiguity.UNSUPPORTED_EXPRESSION, stmt,
198
+ "Corpo de TRIGGER é código procedural específico do dialeto — "
199
+ "não convertido automaticamente."))
200
+ continue
201
+
202
+ findings.append(ParseFinding(Ambiguity.UNRECOGNIZED_DDL, stmt, "Statement não reconhecido pelo parser SQLite."))
203
+
204
+ return ParseResult(schema=schema, findings=findings)
205
+
206
+
207
+ _CANONICAL_TO_SQLITE = {
208
+ CanonicalType.INTEGER: "INTEGER", CanonicalType.SMALLINT: "INTEGER", CanonicalType.BIGINT: "INTEGER",
209
+ CanonicalType.FLOAT: "REAL", CanonicalType.NUMERIC: "NUMERIC", CanonicalType.BOOLEAN: "INTEGER",
210
+ CanonicalType.TEXT: "TEXT", CanonicalType.VARCHAR: "VARCHAR", CanonicalType.DATE: "TEXT",
211
+ CanonicalType.TIME: "TEXT", CanonicalType.DATETIME: "TEXT", CanonicalType.TIMESTAMP_TZ: "TEXT",
212
+ CanonicalType.JSON: "TEXT", CanonicalType.UUID: "TEXT", CanonicalType.BINARY: "BLOB",
213
+ CanonicalType.ENUM: "TEXT",
214
+ }
215
+
216
+
217
+ def _render_column(col: Column) -> str:
218
+ if col.type == CanonicalType.VARCHAR and col.length:
219
+ type_sql = f"VARCHAR({col.length})"
220
+ elif col.type == CanonicalType.NUMERIC and col.precision:
221
+ type_sql = f"NUMERIC({col.precision}{',' + str(col.scale) if col.scale else ''})"
222
+ else:
223
+ type_sql = _CANONICAL_TO_SQLITE.get(col.type, "TEXT")
224
+ parts = [col.name, type_sql]
225
+ if col.primary_key:
226
+ parts.append("PRIMARY KEY")
227
+ if col.auto_increment:
228
+ parts.append("AUTOINCREMENT")
229
+ if not col.nullable and not col.primary_key:
230
+ parts.append("NOT NULL")
231
+ if col.unique and not col.primary_key:
232
+ parts.append("UNIQUE")
233
+ if col.default is not None:
234
+ parts.append(f"DEFAULT {col.default}")
235
+ if col.type == CanonicalType.ENUM and col.enum_values:
236
+ values = ",".join(f"'{v}'" for v in col.enum_values)
237
+ parts.append(f"CHECK({col.name} IN ({values}))")
238
+ return " ".join(parts)
239
+
240
+
241
+ class SQLiteRenderer(DialectRenderer):
242
+ name = "sqlite"
243
+
244
+ def render_schema(self, schema: Schema) -> RenderResult:
245
+ statements: list[str] = []
246
+ findings: list[RenderFinding] = []
247
+
248
+ for table in schema.tables:
249
+ lines = [_render_column(c) for c in table.columns]
250
+ for fk in table.foreign_keys:
251
+ cols = ",".join(fk.columns)
252
+ ref_cols = ",".join(fk.ref_columns)
253
+ clause = f"FOREIGN KEY({cols}) REFERENCES {fk.ref_table}({ref_cols})"
254
+ if fk.on_delete:
255
+ clause += f" ON DELETE {fk.on_delete}"
256
+ if fk.on_update:
257
+ clause += f" ON UPDATE {fk.on_update}"
258
+ lines.append(clause)
259
+ for uq in table.unique_constraints:
260
+ lines.append(f"UNIQUE({','.join(uq.columns)})")
261
+ for chk in table.check_constraints:
262
+ lines.append(f"CHECK({chk.expression})")
263
+ statements.append(f"CREATE TABLE IF NOT EXISTS {table.name} (\n " + ",\n ".join(lines) + "\n)")
264
+ for idx in table.indexes:
265
+ kind = "UNIQUE INDEX" if idx.unique else "INDEX"
266
+ statements.append(f"CREATE {kind} IF NOT EXISTS {idx.name} ON {table.name}({','.join(idx.columns)})")
267
+
268
+ for view in schema.views:
269
+ statements.append(f"CREATE VIEW IF NOT EXISTS {view.name} AS {view.query}")
270
+
271
+ for trigger in schema.triggers:
272
+ findings.append(RenderFinding(Ambiguity.UNSUPPORTED_EXPRESSION, trigger.name,
273
+ "Trigger não renderizado — corpo procedural é específico do dialeto de origem."))
274
+
275
+ return RenderResult(sql=";\n\n".join(statements) + (";" if statements else ""), findings=findings)
276
+
277
+
278
+ register_parser("sqlite", SQLiteParser())
279
+ register_renderer("sqlite", SQLiteRenderer())
@@ -0,0 +1,15 @@
1
+ """Dialeto SQL Server — estrutura preparada (Fase 5 do roadmap), ainda não implementado.
2
+
3
+ Quando chegar a hora: `IDENTITY(1,1)` como autoincrement, `NVARCHAR`/`DATETIME2`/`UNIQUEIDENTIFIER`
4
+ no mapa de tipos, `[colchetes]` como quoting de identificador (já previsto nos regexes de
5
+ dialects/sqlite.py como referência de padrão a seguir), e `TOP n` no lugar de `LIMIT` (fora do
6
+ escopo de DDL, mas relevante pro scanner/codemod quando entrar em cena).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from ..core.parser import register_parser
11
+ from ..core.renderer import register_renderer
12
+ from ._stub import NotImplementedDialectParser, NotImplementedDialectRenderer
13
+
14
+ register_parser("sqlserver", NotImplementedDialectParser("sqlserver", "Fase 5"))
15
+ register_renderer("sqlserver", NotImplementedDialectRenderer("sqlserver", "Fase 5"))
@@ -0,0 +1,11 @@
1
+ from .data_migration import DataMigrationResult, TableMigrationResult, migrate_data, migrate_table_data
2
+ from .rollback import DatabaseSnapshot, rollback_code_changes
3
+ from .schema_migration import SchemaMigrationResult, apply_schema, migrate_schema, render_target_ddl
4
+ from .validator import TableValidation, ValidationReport, validate, validate_table
5
+
6
+ __all__ = [
7
+ "migrate_schema", "render_target_ddl", "apply_schema", "SchemaMigrationResult",
8
+ "migrate_data", "migrate_table_data", "DataMigrationResult", "TableMigrationResult",
9
+ "validate", "validate_table", "ValidationReport", "TableValidation",
10
+ "rollback_code_changes", "DatabaseSnapshot",
11
+ ]