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
dbbridge/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""DBBridge — plataforma genérica de conversão/análise/migração entre bancos de dados, baseada numa
|
|
2
|
+
Intermediate Representation (IR) canônica.
|
|
3
|
+
|
|
4
|
+
Banco origem -> Parser do dialeto origem -> IR -> Renderer do dialeto destino -> Banco destino
|
|
5
|
+
|
|
6
|
+
Não é uma evolução ad-hoc pareada (SQLite->Postgres, SQLite->MySQL, ...) — é uma arquitetura N-para-N
|
|
7
|
+
através de UM modelo comum. Ver core/ir.py pra IR, core/planner.py pro pipeline completo.
|
|
8
|
+
|
|
9
|
+
from dbbridge.core.planner import translate
|
|
10
|
+
result = translate(sql, source_dialect="sqlite", target_dialect="postgres")
|
|
11
|
+
print(result.sql)
|
|
12
|
+
|
|
13
|
+
Fase 1 (atual): IR completa + SQLite <-> PostgreSQL, scanner, codemod, CLI, rollback.
|
|
14
|
+
Dialetos MySQL/SQL Server: estrutura registrada, implementação nas Fases 2/5 (ver README.md).
|
|
15
|
+
"""
|
|
16
|
+
from . import dialects # noqa: F401 — registra sqlite/postgres/mysql(stub)/sqlserver(stub)
|
|
17
|
+
from .compat.runtime import connect
|
|
18
|
+
from .core.planner import TranslationResult, translate
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = ["translate", "TranslationResult", "connect"]
|
dbbridge/cli.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""CLI: dbbridge scan|report|translate|patch|apply|migrate-data|validate|rollback|doctor."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import dialects # noqa: F401 — garante que sqlite/postgres/mysql/sqlserver estão registrados
|
|
9
|
+
from .codemod.codemod import apply_changes, plan_file
|
|
10
|
+
from .core.parser import available_parsers
|
|
11
|
+
from .core.planner import translate as translate_ir
|
|
12
|
+
from .core.renderer import available_renderers
|
|
13
|
+
from .migration.rollback import rollback_code_changes
|
|
14
|
+
from .reports.report import render_report
|
|
15
|
+
from .scanner.scanner import scan_project
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _add_common(p: argparse.ArgumentParser):
|
|
19
|
+
p.add_argument("--from", dest="source", required=True, choices=available_parsers(), help="Dialeto de origem")
|
|
20
|
+
p.add_argument("--to", dest="target", required=True, choices=available_renderers(), help="Dialeto de destino")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv=None):
|
|
24
|
+
parser = argparse.ArgumentParser(prog="dbbridge", description="Conversão/análise/migração entre bancos de dados via IR canônica")
|
|
25
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
26
|
+
|
|
27
|
+
p_scan = sub.add_parser("scan", help="Varre um projeto Python procurando SQL incompatível entre os dois dialetos")
|
|
28
|
+
_add_common(p_scan)
|
|
29
|
+
p_scan.add_argument("path")
|
|
30
|
+
p_scan.add_argument("--json", action="store_true")
|
|
31
|
+
|
|
32
|
+
p_report = sub.add_parser("report", help="Gera relatório do último scan (texto ou JSON)")
|
|
33
|
+
p_report.add_argument("path")
|
|
34
|
+
p_report.add_argument("--from", dest="source", choices=available_parsers())
|
|
35
|
+
p_report.add_argument("--to", dest="target", choices=available_renderers())
|
|
36
|
+
p_report.add_argument("--json", action="store_true")
|
|
37
|
+
p_report.add_argument("--out", default=None)
|
|
38
|
+
|
|
39
|
+
p_translate = sub.add_parser("translate", help="Traduz um arquivo .sql (schema DDL) de um dialeto pro outro")
|
|
40
|
+
_add_common(p_translate)
|
|
41
|
+
p_translate.add_argument("sql_file")
|
|
42
|
+
p_translate.add_argument("--out", default=None)
|
|
43
|
+
|
|
44
|
+
p_patch = sub.add_parser("patch", help="Gera o plano de mudanças de código SEM escrever nada (dry-run)")
|
|
45
|
+
_add_common(p_patch)
|
|
46
|
+
p_patch.add_argument("path")
|
|
47
|
+
|
|
48
|
+
p_apply = sub.add_parser("apply", help="Aplica as mudanças de código seguras (com backup automático)")
|
|
49
|
+
_add_common(p_apply)
|
|
50
|
+
p_apply.add_argument("path")
|
|
51
|
+
|
|
52
|
+
p_migrate = sub.add_parser("migrate-data", help="Copia dados entre uma conexão origem e uma destino")
|
|
53
|
+
_add_common(p_migrate)
|
|
54
|
+
p_migrate.add_argument("--source-dsn", required=True)
|
|
55
|
+
p_migrate.add_argument("--target-dsn", required=True)
|
|
56
|
+
p_migrate.add_argument("--tables", nargs="+", required=True)
|
|
57
|
+
|
|
58
|
+
p_validate = sub.add_parser("validate", help="Valida contagens/checksums entre origem e destino após migrate-data")
|
|
59
|
+
_add_common(p_validate)
|
|
60
|
+
p_validate.add_argument("--source-dsn", required=True)
|
|
61
|
+
p_validate.add_argument("--target-dsn", required=True)
|
|
62
|
+
p_validate.add_argument("--tables", nargs="+", required=True)
|
|
63
|
+
|
|
64
|
+
p_rollback = sub.add_parser("rollback", help="Restaura arquivos de código a partir do backup mais recente")
|
|
65
|
+
p_rollback.add_argument("path")
|
|
66
|
+
|
|
67
|
+
sub.add_parser("doctor", help="Diagnóstico rápido: dialetos disponíveis, drivers instalados")
|
|
68
|
+
sub.add_parser("dialects", help="Lista os dialetos com parser/renderer disponíveis")
|
|
69
|
+
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
|
|
72
|
+
if args.command == "scan":
|
|
73
|
+
report = scan_project(Path(args.path), args.source, args.target)
|
|
74
|
+
print(render_report(f"DBBridge scan ({args.source} -> {args.target})", report, fmt="json" if args.json else "text"))
|
|
75
|
+
|
|
76
|
+
elif args.command == "report":
|
|
77
|
+
report = scan_project(Path(args.path), args.source or "sqlite", args.target or "postgres")
|
|
78
|
+
text = render_report(f"DBBridge report ({args.source} -> {args.target})", report, fmt="json" if args.json else "text")
|
|
79
|
+
if args.out:
|
|
80
|
+
Path(args.out).write_text(text, encoding="utf-8")
|
|
81
|
+
print(f"Relatório salvo em {args.out}")
|
|
82
|
+
else:
|
|
83
|
+
print(text)
|
|
84
|
+
|
|
85
|
+
elif args.command == "translate":
|
|
86
|
+
sql = Path(args.sql_file).read_text(encoding="utf-8")
|
|
87
|
+
result = translate_ir(sql, args.source, args.target)
|
|
88
|
+
if args.out:
|
|
89
|
+
Path(args.out).write_text(result.sql, encoding="utf-8")
|
|
90
|
+
print(f"SQL traduzido salvo em {args.out}")
|
|
91
|
+
else:
|
|
92
|
+
print(result.sql)
|
|
93
|
+
if result.review_required:
|
|
94
|
+
print("\n--- REVIEW_REQUIRED ---", file=sys.stderr)
|
|
95
|
+
for f in result.parse_findings:
|
|
96
|
+
print(f" [parse] {f.statement[:80]!r}: {f.reason}", file=sys.stderr)
|
|
97
|
+
# Nenhum parser atual produz Trigger/View no IR que gere RenderFinding (CREATE TRIGGER já
|
|
98
|
+
# vira ParseFinding antes de chegar aqui) — mantido para dialetos futuros que possam parsear
|
|
99
|
+
# esses casos com sucesso e só falhar na hora de renderizar pro destino.
|
|
100
|
+
for f in result.render_findings:
|
|
101
|
+
print(f" [render] {f.target}: {f.reason}", file=sys.stderr)
|
|
102
|
+
|
|
103
|
+
elif args.command == "patch":
|
|
104
|
+
total = 0
|
|
105
|
+
for path in sorted(Path(args.path).rglob("*.py")):
|
|
106
|
+
if any(part in ("venv", ".venv", "__pycache__") for part in path.parts):
|
|
107
|
+
continue
|
|
108
|
+
changes = plan_file(path, args.source, args.target)
|
|
109
|
+
if changes:
|
|
110
|
+
print(f"\n{path}:")
|
|
111
|
+
for c in changes:
|
|
112
|
+
print(f" [{c.status}] linha {c.line}: {c.reason}")
|
|
113
|
+
total += len(changes)
|
|
114
|
+
print(f"\nTotal planejado: {total} mudança(s). Nada foi escrito — rode 'apply' pra gravar.")
|
|
115
|
+
|
|
116
|
+
elif args.command == "apply":
|
|
117
|
+
applied_total = 0
|
|
118
|
+
touched = []
|
|
119
|
+
backup_dir = Path(args.path) / ".dbbridge_backups"
|
|
120
|
+
for path in sorted(Path(args.path).rglob("*.py")):
|
|
121
|
+
if any(part in ("venv", ".venv", "__pycache__", ".dbbridge_backups") for part in path.parts):
|
|
122
|
+
continue
|
|
123
|
+
changes = plan_file(path, args.source, args.target)
|
|
124
|
+
if not changes:
|
|
125
|
+
continue
|
|
126
|
+
ok = apply_changes(path, changes, backup_dir)
|
|
127
|
+
if ok:
|
|
128
|
+
n = sum(1 for c in changes if c.status == "APPLIED")
|
|
129
|
+
if n:
|
|
130
|
+
applied_total += n
|
|
131
|
+
touched.append(path)
|
|
132
|
+
print(f"{applied_total} mudança(s) aplicada(s) em {len(touched)} arquivo(s).")
|
|
133
|
+
|
|
134
|
+
elif args.command == "migrate-data":
|
|
135
|
+
from .compat.runtime import connect as db_connect
|
|
136
|
+
from .migration.data_migration import migrate_data
|
|
137
|
+
|
|
138
|
+
source_conn = db_connect(args.source, args.source, args.source_dsn)
|
|
139
|
+
target_conn = db_connect(args.source, args.target, args.target_dsn)
|
|
140
|
+
result = migrate_data(source_conn, target_conn, args.tables)
|
|
141
|
+
print(render_report(f"DBBridge migrate-data ({args.source} -> {args.target})", result))
|
|
142
|
+
if not result.all_counts_match:
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
|
|
145
|
+
elif args.command == "validate":
|
|
146
|
+
from .compat.runtime import connect as db_connect
|
|
147
|
+
from .migration.validator import validate
|
|
148
|
+
|
|
149
|
+
source_conn = db_connect(args.source, args.source, args.source_dsn)
|
|
150
|
+
target_conn = db_connect(args.source, args.target, args.target_dsn)
|
|
151
|
+
report = validate(source_conn, target_conn, {t: None for t in args.tables})
|
|
152
|
+
print(render_report(f"DBBridge validate ({args.source} -> {args.target})", report))
|
|
153
|
+
if not report.all_ok:
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
|
|
156
|
+
elif args.command == "rollback":
|
|
157
|
+
n = rollback_code_changes(Path(args.path))
|
|
158
|
+
print(f"{n} arquivo(s) restaurado(s) a partir do backup.")
|
|
159
|
+
|
|
160
|
+
elif args.command == "doctor":
|
|
161
|
+
print("Dialetos com parser:", available_parsers())
|
|
162
|
+
print("Dialetos com renderer:", available_renderers())
|
|
163
|
+
try:
|
|
164
|
+
import psycopg # noqa: F401
|
|
165
|
+
print("psycopg (Postgres driver): instalado")
|
|
166
|
+
except ImportError:
|
|
167
|
+
print("psycopg (Postgres driver): NÃO instalado")
|
|
168
|
+
import sqlite3 # noqa: F401
|
|
169
|
+
print("sqlite3 (stdlib): disponível")
|
|
170
|
+
|
|
171
|
+
elif args.command == "dialects":
|
|
172
|
+
print("Parsers disponíveis:", available_parsers())
|
|
173
|
+
print("Renderers disponíveis:", available_renderers())
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
main()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Codemod genérico: localiza via `ast` (nunca regex solto no arquivo inteiro) os argumentos de
|
|
2
|
+
`.execute()`/`.executemany()` e reescreve SÓ os casos sem ambiguidade — o mesmo princípio de
|
|
3
|
+
PostgresModel, generalizado pra qualquer par de dialetos suportado (a diferença real é qual
|
|
4
|
+
placeholder o destino espera: `?` pro SQLite/SQL Server, `%s` pro Postgres/MySQL).
|
|
5
|
+
|
|
6
|
+
Edição é cirúrgica por span de texto-fonte (via lineno/col_offset/end_lineno/end_col_offset) — nunca
|
|
7
|
+
um `ast.unparse()` do arquivo inteiro, que reformataria tudo.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
REVIEW_REQUIRED = "REVIEW_REQUIRED"
|
|
19
|
+
APPLIED = "APPLIED"
|
|
20
|
+
SKIPPED_COMPLEX = "SKIPPED_COMPLEX_EXPRESSION"
|
|
21
|
+
|
|
22
|
+
_PLACEHOLDER_STYLE = {"sqlite": "?", "postgres": "%s", "mysql": "%s", "sqlserver": "?"}
|
|
23
|
+
|
|
24
|
+
_AMBIGUOUS_PATTERNS = [
|
|
25
|
+
(re.compile(r"\bINSERT\s+OR\s+REPLACE\b", re.IGNORECASE), "INSERT OR REPLACE precisa da coluna de conflito."),
|
|
26
|
+
(re.compile(r"\bsqlite_master\b", re.IGNORECASE), "sqlite_master não tem equivalente direto fora do SQLite."),
|
|
27
|
+
(re.compile(r"\bPRAGMA\s+\w+", re.IGNORECASE), "PRAGMA é específico do SQLite."),
|
|
28
|
+
(re.compile(r'(=|,|\(|\|\|)\s*"[^"]*"'), "Aspas duplas podem ser string literal (SQLite) ou identificador — ambíguo sem revisão."),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CodemodChange:
|
|
34
|
+
file: Path
|
|
35
|
+
line: int
|
|
36
|
+
status: str
|
|
37
|
+
before: str
|
|
38
|
+
after: str | None
|
|
39
|
+
reason: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _translate_placeholder(sql: str, source: str, target: str) -> str:
|
|
43
|
+
src_ph = _PLACEHOLDER_STYLE.get(source, "?")
|
|
44
|
+
tgt_ph = _PLACEHOLDER_STYLE.get(target, "?")
|
|
45
|
+
if src_ph == tgt_ph:
|
|
46
|
+
return sql
|
|
47
|
+
if src_ph == "?":
|
|
48
|
+
return sql.replace("?", tgt_ph)
|
|
49
|
+
return sql.replace(src_ph, "?") if tgt_ph == "?" else sql
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _has_ambiguity(sql: str) -> str | None:
|
|
53
|
+
for pattern, reason in _AMBIGUOUS_PATTERNS:
|
|
54
|
+
if pattern.search(sql):
|
|
55
|
+
return reason
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _quote_style(raw: str) -> tuple[str, str]:
|
|
60
|
+
prefix = ""
|
|
61
|
+
while raw and raw[0].lower() in ("f", "r", "b", "u"):
|
|
62
|
+
prefix += raw[0]
|
|
63
|
+
raw = raw[1:]
|
|
64
|
+
for delim in ('"""', "'''", '"', "'"):
|
|
65
|
+
if raw.startswith(delim) and raw.endswith(delim) and len(raw) >= 2 * len(delim):
|
|
66
|
+
return prefix + delim, raw[len(delim):-len(delim)]
|
|
67
|
+
raise ValueError(f"Não reconheci o estilo de aspas de: {raw!r}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rewrap(inner: str, original_delim: str) -> str:
|
|
71
|
+
prefix = original_delim.rstrip("'\"")
|
|
72
|
+
bare_delim = original_delim[len(prefix):]
|
|
73
|
+
if bare_delim in ('"""', "'''"):
|
|
74
|
+
return prefix + bare_delim + inner + bare_delim
|
|
75
|
+
quote_char = bare_delim
|
|
76
|
+
if quote_char not in inner:
|
|
77
|
+
return prefix + quote_char + inner + quote_char
|
|
78
|
+
other = '"' if quote_char == "'" else "'"
|
|
79
|
+
if other not in inner:
|
|
80
|
+
return prefix + other + inner + other
|
|
81
|
+
raise ValueError("texto contém os dois tipos de aspas — precisa de escape manual")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _get_span_text(lines: list[str], lineno: int, col_offset: int, end_lineno: int, end_col_offset: int) -> str:
|
|
85
|
+
if lineno == end_lineno:
|
|
86
|
+
return lines[lineno - 1][col_offset:end_col_offset]
|
|
87
|
+
parts = [lines[lineno - 1][col_offset:]]
|
|
88
|
+
parts.extend(lines[lineno:end_lineno - 1])
|
|
89
|
+
parts.append(lines[end_lineno - 1][:end_col_offset])
|
|
90
|
+
return "".join(parts)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _ExecuteArgVisitor(ast.NodeVisitor):
|
|
94
|
+
def __init__(self):
|
|
95
|
+
self.simple_string_args: list[ast.Constant] = []
|
|
96
|
+
self.complex_args: list[ast.AST] = []
|
|
97
|
+
|
|
98
|
+
def visit_Call(self, node: ast.Call):
|
|
99
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ("execute", "executemany"):
|
|
100
|
+
if node.args:
|
|
101
|
+
arg = node.args[0]
|
|
102
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
103
|
+
self.simple_string_args.append(arg)
|
|
104
|
+
else:
|
|
105
|
+
self.complex_args.append(arg)
|
|
106
|
+
self.generic_visit(node)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def plan_file(path: Path, source: str, target: str) -> list[CodemodChange]:
|
|
110
|
+
source_text = path.read_text(encoding="utf-8")
|
|
111
|
+
lines = source_text.splitlines(keepends=True)
|
|
112
|
+
tree = ast.parse(source_text, filename=str(path))
|
|
113
|
+
visitor = _ExecuteArgVisitor()
|
|
114
|
+
visitor.visit(tree)
|
|
115
|
+
|
|
116
|
+
changes: list[CodemodChange] = []
|
|
117
|
+
|
|
118
|
+
for arg in visitor.complex_args:
|
|
119
|
+
changes.append(CodemodChange(
|
|
120
|
+
path, getattr(arg, "lineno", 0), SKIPPED_COMPLEX,
|
|
121
|
+
"<f-string, concatenação ou expressão dinâmica>", None,
|
|
122
|
+
"Codemod só reescreve literais de string simples."))
|
|
123
|
+
|
|
124
|
+
for arg in visitor.simple_string_args:
|
|
125
|
+
sql = arg.value
|
|
126
|
+
ambiguity_reason = _has_ambiguity(sql)
|
|
127
|
+
if ambiguity_reason:
|
|
128
|
+
changes.append(CodemodChange(path, arg.lineno, REVIEW_REQUIRED, sql, None, ambiguity_reason))
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
translated = _translate_placeholder(sql, source, target)
|
|
132
|
+
if translated == sql:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
original_span = _get_span_text(lines, arg.lineno, arg.col_offset, arg.end_lineno, arg.end_col_offset)
|
|
136
|
+
try:
|
|
137
|
+
delim, _inner = _quote_style(original_span)
|
|
138
|
+
new_literal = _rewrap(translated, delim)
|
|
139
|
+
except ValueError as e:
|
|
140
|
+
changes.append(CodemodChange(path, arg.lineno, REVIEW_REQUIRED, sql, None,
|
|
141
|
+
f"Não consegui re-serializar com segurança: {e}"))
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
changes.append(CodemodChange(path, arg.lineno, APPLIED, original_span, new_literal,
|
|
145
|
+
f"Placeholder '{_PLACEHOLDER_STYLE.get(source,'?')}' -> "
|
|
146
|
+
f"'{_PLACEHOLDER_STYLE.get(target,'?')}', sem ambiguidade."))
|
|
147
|
+
|
|
148
|
+
return changes
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def apply_changes(path: Path, changes: list[CodemodChange], backup_dir: Path) -> bool:
|
|
152
|
+
applied = [c for c in changes if c.status == APPLIED and c.after is not None]
|
|
153
|
+
if not applied:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
source_text = path.read_text(encoding="utf-8")
|
|
157
|
+
lines = source_text.splitlines(keepends=True)
|
|
158
|
+
|
|
159
|
+
tree = ast.parse(source_text, filename=str(path))
|
|
160
|
+
visitor = _ExecuteArgVisitor()
|
|
161
|
+
visitor.visit(tree)
|
|
162
|
+
by_line = {a.lineno: a for a in visitor.simple_string_args}
|
|
163
|
+
|
|
164
|
+
for change in sorted(applied, key=lambda c: c.line, reverse=True):
|
|
165
|
+
arg = by_line.get(change.line)
|
|
166
|
+
if arg is None:
|
|
167
|
+
continue
|
|
168
|
+
before = _get_span_text(lines, arg.lineno, arg.col_offset, arg.end_lineno, arg.end_col_offset)
|
|
169
|
+
if before != change.before:
|
|
170
|
+
continue
|
|
171
|
+
if arg.lineno == arg.end_lineno:
|
|
172
|
+
line = lines[arg.lineno - 1]
|
|
173
|
+
lines[arg.lineno - 1] = line[:arg.col_offset] + change.after + line[arg.end_col_offset:]
|
|
174
|
+
else:
|
|
175
|
+
first = lines[arg.lineno - 1][:arg.col_offset]
|
|
176
|
+
last = lines[arg.end_lineno - 1][arg.end_col_offset:]
|
|
177
|
+
lines[arg.lineno - 1:arg.end_lineno] = [first + change.after + last]
|
|
178
|
+
|
|
179
|
+
new_source = "".join(lines)
|
|
180
|
+
|
|
181
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
183
|
+
backup_path = backup_dir / f"{path.name}.{stamp}.bak"
|
|
184
|
+
shutil.copy2(path, backup_path)
|
|
185
|
+
|
|
186
|
+
path.write_text(new_source, encoding="utf-8")
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
ast.parse(new_source, filename=str(path))
|
|
190
|
+
except SyntaxError:
|
|
191
|
+
shutil.copy2(backup_path, path)
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
return True
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Compatibility Mode do DBBridge: código escrito contra a sintaxe de `source_dialect` roda de
|
|
2
|
+
verdade contra um banco `target_dialect`, sem reescrita — o mesmo papel que PostgresModel cumpre só
|
|
3
|
+
pro par SQLite->Postgres, generalizado (Fase 1: os dois sentidos SQLite<->Postgres já funcionam;
|
|
4
|
+
MySQL/SQL Server entram nas Fases 2/5 assim que os dialetos tiverem parser/renderer reais).
|
|
5
|
+
|
|
6
|
+
from dbbridge import connect
|
|
7
|
+
db = connect(source_dialect="sqlite", target_dialect="postgres", dsn="host=... dbname=...")
|
|
8
|
+
db.execute("SELECT * FROM users WHERE id=?", (1,)) # vira %s por baixo, de verdade no Postgres
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("dbbridge")
|
|
16
|
+
|
|
17
|
+
_PLACEHOLDER_STYLE = {"sqlite": "?", "postgres": "%s", "mysql": "%s", "sqlserver": "?"}
|
|
18
|
+
_SUPPORTED_TARGETS = ("sqlite", "postgres")
|
|
19
|
+
|
|
20
|
+
_LAST_INSERT_ROWID_RE = re.compile(r"last_insert_rowid\(\)", re.IGNORECASE)
|
|
21
|
+
_LASTVAL_RE = re.compile(r"lastval\(\)", re.IGNORECASE)
|
|
22
|
+
_INSERT_OR_IGNORE_RE = re.compile(r"INSERT\s+OR\s+IGNORE\s+INTO", re.IGNORECASE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def translate_query(sql: str, source_dialect: str, target_dialect: str) -> str:
|
|
26
|
+
"""Tradução pura texto->texto pro par (source, target) — mesma peça central que PostgresModel,
|
|
27
|
+
só que parametrizada pelos dois lados em vez de assumir sqlite->postgres sempre."""
|
|
28
|
+
out = sql
|
|
29
|
+
if source_dialect == target_dialect:
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
if source_dialect == "sqlite" and target_dialect == "postgres":
|
|
33
|
+
out = _LAST_INSERT_ROWID_RE.sub("lastval()", out)
|
|
34
|
+
if _INSERT_OR_IGNORE_RE.search(out):
|
|
35
|
+
out = _INSERT_OR_IGNORE_RE.sub("INSERT INTO", out)
|
|
36
|
+
out = out.rstrip().rstrip(";") + " ON CONFLICT DO NOTHING"
|
|
37
|
+
elif source_dialect == "postgres" and target_dialect == "sqlite":
|
|
38
|
+
out = _LASTVAL_RE.sub("last_insert_rowid()", out)
|
|
39
|
+
# "INSERT ... ON CONFLICT DO NOTHING" -> "INSERT OR IGNORE ..." só no caso simples (sem
|
|
40
|
+
# DO UPDATE, que não tem equivalente direto em INSERT OR IGNORE — fica como está, sem
|
|
41
|
+
# tradução perigosa, se detectarmos DO UPDATE presente).
|
|
42
|
+
if re.search(r"ON\s+CONFLICT.*DO\s+NOTHING", out, re.IGNORECASE | re.DOTALL) and \
|
|
43
|
+
not re.search(r"DO\s+UPDATE", out, re.IGNORECASE):
|
|
44
|
+
out = re.sub(r"INSERT\s+INTO", "INSERT OR IGNORE INTO", out, count=1, flags=re.IGNORECASE)
|
|
45
|
+
out = re.sub(r"\s*ON\s+CONFLICT.*DO\s+NOTHING\s*", " ", out, flags=re.IGNORECASE | re.DOTALL).rstrip()
|
|
46
|
+
|
|
47
|
+
src_ph = _PLACEHOLDER_STYLE.get(source_dialect, "?")
|
|
48
|
+
tgt_ph = _PLACEHOLDER_STYLE.get(target_dialect, "?")
|
|
49
|
+
if src_ph != tgt_ph:
|
|
50
|
+
out = out.replace(src_ph, tgt_ph)
|
|
51
|
+
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _log_sql_error(original_sql, translated_sql, params, source_dialect, target_dialect, exc):
|
|
56
|
+
logger.error(
|
|
57
|
+
"Falha ao executar SQL traduzido (%s -> %s).\n"
|
|
58
|
+
"--- SQL original ---\n%s\n--- SQL traduzido ---\n%s\n--- Parâmetros ---\n%r\n--- Erro ---\n%s",
|
|
59
|
+
source_dialect, target_dialect, original_sql, translated_sql, params, exc,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Row:
|
|
64
|
+
__slots__ = ("_keys", "_values")
|
|
65
|
+
|
|
66
|
+
def __init__(self, keys, values):
|
|
67
|
+
self._keys = keys
|
|
68
|
+
self._values = values
|
|
69
|
+
|
|
70
|
+
def __getitem__(self, key):
|
|
71
|
+
if isinstance(key, int):
|
|
72
|
+
return self._values[key]
|
|
73
|
+
try:
|
|
74
|
+
idx = self._keys.index(key)
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise KeyError(key)
|
|
77
|
+
return self._values[idx]
|
|
78
|
+
|
|
79
|
+
def keys(self):
|
|
80
|
+
return list(self._keys)
|
|
81
|
+
|
|
82
|
+
def __iter__(self):
|
|
83
|
+
return iter(self._values)
|
|
84
|
+
|
|
85
|
+
def __len__(self):
|
|
86
|
+
return len(self._values)
|
|
87
|
+
|
|
88
|
+
def __repr__(self):
|
|
89
|
+
return f"Row({dict(zip(self._keys, self._values))!r})"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Cursor:
|
|
93
|
+
def __init__(self, raw_cursor, source_dialect: str, target_dialect: str, row_factory=None):
|
|
94
|
+
self._cur = raw_cursor
|
|
95
|
+
self._source = source_dialect
|
|
96
|
+
self._target = target_dialect
|
|
97
|
+
self._row_factory = row_factory
|
|
98
|
+
|
|
99
|
+
def execute(self, sql: str, params=None):
|
|
100
|
+
translated = translate_query(sql, self._source, self._target)
|
|
101
|
+
try:
|
|
102
|
+
if params is None:
|
|
103
|
+
self._cur.execute(translated)
|
|
104
|
+
else:
|
|
105
|
+
self._cur.execute(translated, params)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
_log_sql_error(sql, translated, params, self._source, self._target, e)
|
|
108
|
+
raise
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def fetchone(self):
|
|
112
|
+
row = self._cur.fetchone()
|
|
113
|
+
return self._wrap(row) if row is not None else None
|
|
114
|
+
|
|
115
|
+
def fetchall(self):
|
|
116
|
+
return [self._wrap(r) for r in self._cur.fetchall()]
|
|
117
|
+
|
|
118
|
+
def _wrap(self, row):
|
|
119
|
+
if self._row_factory:
|
|
120
|
+
return self._row_factory(row)
|
|
121
|
+
return row
|
|
122
|
+
|
|
123
|
+
def __getattr__(self, name):
|
|
124
|
+
return getattr(self._cur, name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Connection:
|
|
128
|
+
def __init__(self, raw_conn, source_dialect: str, target_dialect: str, row_factory=None):
|
|
129
|
+
self._conn = raw_conn
|
|
130
|
+
self._source = source_dialect
|
|
131
|
+
self._target = target_dialect
|
|
132
|
+
self._row_factory = row_factory
|
|
133
|
+
|
|
134
|
+
def _new_cursor(self) -> Cursor:
|
|
135
|
+
return Cursor(self._conn.cursor(), self._source, self._target, row_factory=self._row_factory)
|
|
136
|
+
|
|
137
|
+
def execute(self, sql: str, params=None):
|
|
138
|
+
return self._new_cursor().execute(sql, params)
|
|
139
|
+
|
|
140
|
+
def cursor(self) -> Cursor:
|
|
141
|
+
return self._new_cursor()
|
|
142
|
+
|
|
143
|
+
def commit(self):
|
|
144
|
+
self._conn.commit()
|
|
145
|
+
|
|
146
|
+
def rollback(self):
|
|
147
|
+
self._conn.rollback()
|
|
148
|
+
|
|
149
|
+
def close(self):
|
|
150
|
+
self._conn.close()
|
|
151
|
+
|
|
152
|
+
def __enter__(self):
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
def __exit__(self, exc_type, exc, tb):
|
|
156
|
+
if exc_type is None:
|
|
157
|
+
self.commit()
|
|
158
|
+
else:
|
|
159
|
+
self.rollback()
|
|
160
|
+
self.close()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def connect(source_dialect: str, target_dialect: str, dsn: str) -> Connection:
|
|
164
|
+
"""Ponto de entrada público. `dsn` é a string de conexão no formato que o driver do
|
|
165
|
+
`target_dialect` espera (caminho de arquivo pro sqlite3, string psycopg pro postgres)."""
|
|
166
|
+
if target_dialect not in _SUPPORTED_TARGETS:
|
|
167
|
+
raise NotImplementedError(
|
|
168
|
+
f"Compatibility Mode ainda não implementado pro destino '{target_dialect}' "
|
|
169
|
+
f"(suportados nesta fase: {_SUPPORTED_TARGETS}).")
|
|
170
|
+
|
|
171
|
+
if target_dialect == "postgres":
|
|
172
|
+
import psycopg
|
|
173
|
+
from psycopg.rows import dict_row
|
|
174
|
+
|
|
175
|
+
raw = psycopg.connect(dsn, row_factory=dict_row)
|
|
176
|
+
|
|
177
|
+
def row_factory(row):
|
|
178
|
+
return Row(list(row.keys()), list(row.values())) if row else row
|
|
179
|
+
|
|
180
|
+
return Connection(raw, source_dialect, target_dialect, row_factory=row_factory)
|
|
181
|
+
|
|
182
|
+
if target_dialect == "sqlite":
|
|
183
|
+
import sqlite3
|
|
184
|
+
|
|
185
|
+
raw = sqlite3.connect(dsn)
|
|
186
|
+
raw.row_factory = sqlite3.Row
|
|
187
|
+
|
|
188
|
+
def row_factory(row):
|
|
189
|
+
return Row(row.keys(), tuple(row)) if row else row
|
|
190
|
+
|
|
191
|
+
return Connection(raw, source_dialect, target_dialect, row_factory=row_factory)
|
|
192
|
+
|
|
193
|
+
raise AssertionError("inalcançável — já validado por _SUPPORTED_TARGETS acima")
|