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 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,3 @@
1
+ from .codemod import APPLIED, REVIEW_REQUIRED, SKIPPED_COMPLEX, CodemodChange, apply_changes, plan_file
2
+
3
+ __all__ = ["APPLIED", "REVIEW_REQUIRED", "SKIPPED_COMPLEX", "CodemodChange", "apply_changes", "plan_file"]
@@ -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,3 @@
1
+ from .runtime import Connection, Cursor, Row, connect, translate_query
2
+
3
+ __all__ = ["connect", "Connection", "Cursor", "Row", "translate_query"]
@@ -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")
@@ -0,0 +1,2 @@
1
+ """Núcleo da IR (Intermediate Representation) — modelo comum entre dialetos, parser/renderer base,
2
+ e o planner que orquestra a pipeline completa. Nenhum código aqui conhece um dialeto específico."""