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.
- dbbridge/__init__.py +22 -0
- dbbridge/cli.py +177 -0
- dbbridge/codemod/__init__.py +3 -0
- dbbridge/codemod/codemod.py +194 -0
- dbbridge/compat/__init__.py +3 -0
- dbbridge/compat/runtime.py +193 -0
- dbbridge/core/__init__.py +2 -0
- dbbridge/core/ir.py +106 -0
- dbbridge/core/parser.py +60 -0
- dbbridge/core/planner.py +44 -0
- dbbridge/core/renderer.py +57 -0
- dbbridge/core/sql_lex.py +61 -0
- dbbridge/core/types.py +47 -0
- dbbridge/dialects/__init__.py +6 -0
- dbbridge/dialects/_stub.py +31 -0
- dbbridge/dialects/mysql.py +16 -0
- dbbridge/dialects/postgres.py +292 -0
- dbbridge/dialects/sqlite.py +279 -0
- dbbridge/dialects/sqlserver.py +15 -0
- dbbridge/migration/__init__.py +11 -0
- dbbridge/migration/data_migration.py +76 -0
- dbbridge/migration/rollback.py +57 -0
- dbbridge/migration/schema_migration.py +56 -0
- dbbridge/migration/validator.py +85 -0
- dbbridge/reports/__init__.py +3 -0
- dbbridge/reports/report.py +58 -0
- dbbridge/scanner/__init__.py +3 -0
- dbbridge/scanner/scanner.py +176 -0
- dbbridgekit-0.1.0.dist-info/METADATA +203 -0
- dbbridgekit-0.1.0.dist-info/RECORD +34 -0
- dbbridgekit-0.1.0.dist-info/WHEEL +5 -0
- dbbridgekit-0.1.0.dist-info/entry_points.txt +2 -0
- dbbridgekit-0.1.0.dist-info/licenses/LICENSE +21 -0
- dbbridgekit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|