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,76 @@
|
|
|
1
|
+
"""Migração de DADOS: copia linhas de uma conexão origem pra uma conexão destino, tabela por tabela,
|
|
2
|
+
em lotes. Não infere schema aqui — recebe a lista de tabelas (normalmente as mesmas que
|
|
3
|
+
`schema_migration.py` acabou de criar no destino) e usa introspecção simples via SQL padrão
|
|
4
|
+
(SELECT * FROM tabela) pra descobrir as colunas de cada lado.
|
|
5
|
+
|
|
6
|
+
Nunca faz DROP/TRUNCATE sozinho — se a tabela destino já tem linha, ver `on_conflict` (default:
|
|
7
|
+
'append', nunca sobrescreve sem pedir)."""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TableMigrationResult:
|
|
15
|
+
table: str
|
|
16
|
+
rows_copied: int
|
|
17
|
+
source_count: int
|
|
18
|
+
target_count: int
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def counts_match(self) -> bool:
|
|
22
|
+
return self.source_count == self.target_count
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class DataMigrationResult:
|
|
27
|
+
tables: list[TableMigrationResult] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def total_rows(self) -> int:
|
|
31
|
+
return sum(t.rows_copied for t in self.tables)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def all_counts_match(self) -> bool:
|
|
35
|
+
return all(t.counts_match for t in self.tables)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _table_columns(connection, table: str) -> list[str]:
|
|
39
|
+
"""Introspecção genérica via SELECT com LIMIT 0 — funciona em qualquer dialeto que devolva
|
|
40
|
+
`cursor.description` (sqlite3 e psycopg fazem), sem precisar de PRAGMA/information_schema
|
|
41
|
+
específico de dialeto pra esta finalidade pontual."""
|
|
42
|
+
cur = connection.execute(f"SELECT * FROM {table} WHERE 1=0")
|
|
43
|
+
return [d[0] if not hasattr(d, "name") else d.name for d in cur.description]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def migrate_table_data(source_connection, target_connection, table: str, batch_size: int = 500) -> TableMigrationResult:
|
|
47
|
+
columns = _table_columns(source_connection, table)
|
|
48
|
+
col_list = ",".join(columns)
|
|
49
|
+
placeholders = ",".join(["?"] * len(columns))
|
|
50
|
+
|
|
51
|
+
cur = source_connection.execute(f"SELECT {col_list} FROM {table}")
|
|
52
|
+
copied = 0
|
|
53
|
+
while True:
|
|
54
|
+
rows = cur.fetchmany(batch_size) if hasattr(cur, "fetchmany") else cur.fetchall()
|
|
55
|
+
if not rows:
|
|
56
|
+
break
|
|
57
|
+
for row in rows:
|
|
58
|
+
values = tuple(row[c] if hasattr(row, "keys") else row[i] for i, c in enumerate(columns))
|
|
59
|
+
target_connection.execute(f"INSERT INTO {table}({col_list}) VALUES({placeholders})", values)
|
|
60
|
+
copied += 1
|
|
61
|
+
if not hasattr(cur, "fetchmany"):
|
|
62
|
+
break
|
|
63
|
+
if hasattr(target_connection, "commit"):
|
|
64
|
+
target_connection.commit()
|
|
65
|
+
|
|
66
|
+
source_count = source_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
67
|
+
target_count = target_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
68
|
+
|
|
69
|
+
return TableMigrationResult(table=table, rows_copied=copied, source_count=source_count, target_count=target_count)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def migrate_data(source_connection, target_connection, tables: list[str], batch_size: int = 500) -> DataMigrationResult:
|
|
73
|
+
result = DataMigrationResult()
|
|
74
|
+
for table in tables:
|
|
75
|
+
result.tables.append(migrate_table_data(source_connection, target_connection, table, batch_size))
|
|
76
|
+
return result
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Rollback: desfaz mudanças de CÓDIGO aplicadas pelo codemod (via backup automático — ver
|
|
2
|
+
codemod/codemod.py, que já salva uma cópia de cada arquivo ANTES de escrever). Rollback de DADOS/
|
|
3
|
+
SCHEMA é dialeto-específico de propósito — a forma correta de reverter um schema/dado já migrado é
|
|
4
|
+
restaurar o backup do BANCO ORIGEM (nunca tentar "desfazer" writes já commitados no destino via SQL
|
|
5
|
+
reverso, que é frágil e pode mascarar erro). Ver README.md pra orientação de backup do banco antes
|
|
6
|
+
de qualquer `migrate-data`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def rollback_code_changes(project_root: Path, backup_dir: Path | None = None) -> int:
|
|
15
|
+
"""Restaura cada arquivo Python a partir do backup MAIS ANTIGO salvo em `backup_dir` (o estado
|
|
16
|
+
de antes da primeira mudança aplicada nesta sessão de codemod) — mesma lógica e mesmo diretório
|
|
17
|
+
que `codemod.apply_changes()` já usa pra criar os backups."""
|
|
18
|
+
import shutil
|
|
19
|
+
|
|
20
|
+
backup_dir = backup_dir or (Path(project_root) / ".dbbridge_backups")
|
|
21
|
+
if not backup_dir.exists():
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
by_original: dict[str, list[Path]] = defaultdict(list)
|
|
25
|
+
for backup in backup_dir.glob("*.bak"):
|
|
26
|
+
original_name = backup.name.split(".")[0] + ".py"
|
|
27
|
+
by_original[original_name].append(backup)
|
|
28
|
+
|
|
29
|
+
restored = 0
|
|
30
|
+
for original_name, backups in by_original.items():
|
|
31
|
+
oldest = sorted(backups, key=lambda p: p.stat().st_mtime)[0]
|
|
32
|
+
matches = list(Path(project_root).rglob(original_name))
|
|
33
|
+
if matches:
|
|
34
|
+
shutil.copy2(oldest, matches[0])
|
|
35
|
+
restored += 1
|
|
36
|
+
return restored
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DatabaseSnapshot:
|
|
40
|
+
"""Ponto de extensão pra rollback de BANCO — deliberadamente sem implementação concreta de
|
|
41
|
+
dialeto aqui (backup de SQLite via `.backup()` nativo, backup de Postgres via `pg_dump`, cada
|
|
42
|
+
um com ferramentas/permissões próprias — ver scripts equivalentes já usados em produção nesta
|
|
43
|
+
mesma base de código, `backend/scripts/backup_db.py` e `migrate_to_postgres.py`, como
|
|
44
|
+
referência de padrão a seguir quando isso for implementado de verdade pra cada dialeto)."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, dialect: str):
|
|
47
|
+
self.dialect = dialect
|
|
48
|
+
|
|
49
|
+
def create(self, dsn: str, dest_path: Path) -> None:
|
|
50
|
+
raise NotImplementedError(
|
|
51
|
+
f"Snapshot de banco pro dialeto '{self.dialect}' ainda não implementado — "
|
|
52
|
+
"faça backup manual antes de rodar migrate-data (ver README.md).")
|
|
53
|
+
|
|
54
|
+
def restore(self, dsn: str, snapshot_path: Path) -> None:
|
|
55
|
+
raise NotImplementedError(
|
|
56
|
+
f"Restore de banco pro dialeto '{self.dialect}' ainda não implementado — "
|
|
57
|
+
"restaure o backup manual feito antes da migração.")
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Migração de SCHEMA: pega o Schema (IR) já parseado da origem, renderiza pro destino, e aplica
|
|
2
|
+
(executa o DDL gerado) contra uma conexão real do destino. Separado de data_migration.py de
|
|
3
|
+
propósito — schema primeiro (Fase 1), dados depois (Fase 4), sempre nessa ordem."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from ..core.parser import get_parser
|
|
9
|
+
from ..core.renderer import get_renderer
|
|
10
|
+
from ..core.sql_lex import split_statements
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SchemaMigrationResult:
|
|
15
|
+
sql: str
|
|
16
|
+
statements_executed: int
|
|
17
|
+
review_required_count: int
|
|
18
|
+
findings: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_target_ddl(source_sql: str, source_dialect: str, target_dialect: str) -> tuple[str, list[str]]:
|
|
22
|
+
"""Só a etapa de tradução (parser origem -> IR -> renderer destino) — não toca em banco nenhum.
|
|
23
|
+
Separado de `apply_schema` pra permitir revisar o SQL gerado antes de rodar de verdade."""
|
|
24
|
+
parser = get_parser(source_dialect)
|
|
25
|
+
renderer = get_renderer(target_dialect)
|
|
26
|
+
parse_result = parser.parse(source_sql)
|
|
27
|
+
render_result = renderer.render_schema(parse_result.schema)
|
|
28
|
+
|
|
29
|
+
findings = [f"parse: {f.reason}" for f in parse_result.findings]
|
|
30
|
+
findings += [f"render: {f.reason}" for f in render_result.findings]
|
|
31
|
+
return render_result.sql, findings
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def apply_schema(target_connection, target_sql: str) -> int:
|
|
35
|
+
"""Executa o DDL renderizado contra uma conexão de verdade do destino (objeto com `.execute()`
|
|
36
|
+
estilo sqlite3/psycopg/DBBridge compat). Devolve quantos statements rodaram. Nunca faz DROP
|
|
37
|
+
nem TRUNCATE — só CREATE (o schema_migration é aditivo, igual init_db() em qualquer app real)."""
|
|
38
|
+
count = 0
|
|
39
|
+
for stmt in split_statements(target_sql):
|
|
40
|
+
target_connection.execute(stmt)
|
|
41
|
+
count += 1
|
|
42
|
+
if hasattr(target_connection, "commit"):
|
|
43
|
+
target_connection.commit()
|
|
44
|
+
return count
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def migrate_schema(source_sql: str, source_dialect: str, target_dialect: str, target_connection=None) -> SchemaMigrationResult:
|
|
48
|
+
"""Fluxo completo: traduz e (se `target_connection` for passado) aplica. Sem conexão, só devolve
|
|
49
|
+
o SQL traduzido pra revisão (dry-run implícito)."""
|
|
50
|
+
target_sql, findings = render_target_ddl(source_sql, source_dialect, target_dialect)
|
|
51
|
+
executed = 0
|
|
52
|
+
if target_connection is not None:
|
|
53
|
+
executed = apply_schema(target_connection, target_sql)
|
|
54
|
+
return SchemaMigrationResult(
|
|
55
|
+
sql=target_sql, statements_executed=executed,
|
|
56
|
+
review_required_count=len(findings), findings=findings)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Validação pós-migração: contagem de linhas (sempre) e checksum por tabela (quando possível) —
|
|
2
|
+
prova que os dados migrados são os mesmos, não só "a mesma quantidade"."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TableValidation:
|
|
11
|
+
table: str
|
|
12
|
+
source_count: int
|
|
13
|
+
target_count: int
|
|
14
|
+
source_checksum: str | None
|
|
15
|
+
target_checksum: str | None
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def counts_match(self) -> bool:
|
|
19
|
+
return self.source_count == self.target_count
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def checksums_match(self) -> bool | None:
|
|
23
|
+
if self.source_checksum is None or self.target_checksum is None:
|
|
24
|
+
return None # não calculado (ex: tabela grande demais, ver skip_checksum_above)
|
|
25
|
+
return self.source_checksum == self.target_checksum
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def ok(self) -> bool:
|
|
29
|
+
return self.counts_match and (self.checksums_match is not False)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ValidationReport:
|
|
34
|
+
tables: list[TableValidation] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def all_ok(self) -> bool:
|
|
38
|
+
return all(t.ok for t in self.tables)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"all_ok": self.all_ok,
|
|
43
|
+
"tables": [
|
|
44
|
+
{"table": t.table, "source_count": t.source_count, "target_count": t.target_count,
|
|
45
|
+
"counts_match": t.counts_match, "checksums_match": t.checksums_match}
|
|
46
|
+
for t in self.tables
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _row_checksum(connection, table: str, columns: list[str]) -> str:
|
|
52
|
+
"""Concatena todas as linhas (ordenadas por todas as colunas, pra não depender de ordem física
|
|
53
|
+
de armazenamento) e faz hash — barato o bastante pra tabelas de tamanho moderado; ver
|
|
54
|
+
`skip_checksum_above` em `validate_table` pra tabelas grandes onde isso seria caro demais."""
|
|
55
|
+
col_list = ",".join(columns)
|
|
56
|
+
rows = connection.execute(f"SELECT {col_list} FROM {table} ORDER BY {col_list}").fetchall()
|
|
57
|
+
h = hashlib.sha256()
|
|
58
|
+
for row in rows:
|
|
59
|
+
values = [row[c] if hasattr(row, "keys") else row[i] for i, c in enumerate(columns)]
|
|
60
|
+
h.update("|".join(str(v) for v in values).encode("utf-8", errors="replace"))
|
|
61
|
+
h.update(b"\x00")
|
|
62
|
+
return h.hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_table(source_connection, target_connection, table: str, columns: list[str] | None = None,
|
|
66
|
+
skip_checksum_above: int = 100_000) -> TableValidation:
|
|
67
|
+
source_count = source_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
68
|
+
target_count = target_connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
69
|
+
|
|
70
|
+
source_checksum = target_checksum = None
|
|
71
|
+
if columns and source_count <= skip_checksum_above:
|
|
72
|
+
source_checksum = _row_checksum(source_connection, table, columns)
|
|
73
|
+
target_checksum = _row_checksum(target_connection, table, columns)
|
|
74
|
+
|
|
75
|
+
return TableValidation(table=table, source_count=source_count, target_count=target_count,
|
|
76
|
+
source_checksum=source_checksum, target_checksum=target_checksum)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate(source_connection, target_connection, tables: dict[str, list[str] | None],
|
|
80
|
+
skip_checksum_above: int = 100_000) -> ValidationReport:
|
|
81
|
+
"""`tables`: mapa nome_tabela -> lista de colunas (ou None pra pular checksum, só contar)."""
|
|
82
|
+
report = ValidationReport()
|
|
83
|
+
for table, columns in tables.items():
|
|
84
|
+
report.tables.append(validate_table(source_connection, target_connection, table, columns, skip_checksum_above))
|
|
85
|
+
return report
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Geração de relatório unificado — usado pelos comandos `scan`, `report`, `migrate-data`, `validate`
|
|
2
|
+
da CLI. Formato comum (texto/JSON) pra qualquer um dos resultados do pacote (ScanReport,
|
|
3
|
+
SchemaMigrationResult, DataMigrationResult, ValidationReport), sem cada comando reinventar a própria
|
|
4
|
+
formatação de saída.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import asdict, is_dataclass
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _to_jsonable(obj):
|
|
14
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
15
|
+
return {k: _to_jsonable(v) for k, v in asdict(obj).items()}
|
|
16
|
+
if isinstance(obj, dict):
|
|
17
|
+
return {k: _to_jsonable(v) for k, v in obj.items()}
|
|
18
|
+
if isinstance(obj, (list, tuple)):
|
|
19
|
+
return [_to_jsonable(v) for v in obj]
|
|
20
|
+
if hasattr(obj, "__fspath__"): # Path
|
|
21
|
+
return str(obj)
|
|
22
|
+
return obj
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_report(title: str, payload, fmt: str = "text") -> str:
|
|
26
|
+
"""`payload` pode ser um objeto com `.to_dict()` (ScanReport/ValidationReport) ou qualquer
|
|
27
|
+
dataclass (SchemaMigrationResult/DataMigrationResult) — normaliza os dois casos."""
|
|
28
|
+
data = payload.to_dict() if hasattr(payload, "to_dict") else _to_jsonable(payload)
|
|
29
|
+
|
|
30
|
+
if fmt == "json":
|
|
31
|
+
return json.dumps({
|
|
32
|
+
"title": title,
|
|
33
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
34
|
+
"data": data,
|
|
35
|
+
}, indent=2, ensure_ascii=False)
|
|
36
|
+
|
|
37
|
+
lines = [f"=== {title} ===", f"Gerado em: {datetime.now(timezone.utc).isoformat()}", ""]
|
|
38
|
+
lines.append(_dict_to_text(data))
|
|
39
|
+
return "\n".join(lines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _dict_to_text(data, indent: int = 0) -> str:
|
|
43
|
+
prefix = " " * indent
|
|
44
|
+
lines = []
|
|
45
|
+
if isinstance(data, dict):
|
|
46
|
+
for k, v in data.items():
|
|
47
|
+
if isinstance(v, (dict, list)):
|
|
48
|
+
lines.append(f"{prefix}{k}:")
|
|
49
|
+
lines.append(_dict_to_text(v, indent + 1))
|
|
50
|
+
else:
|
|
51
|
+
lines.append(f"{prefix}{k}: {v}")
|
|
52
|
+
elif isinstance(data, list):
|
|
53
|
+
for item in data:
|
|
54
|
+
lines.append(_dict_to_text(item, indent))
|
|
55
|
+
lines.append("")
|
|
56
|
+
else:
|
|
57
|
+
lines.append(f"{prefix}{data}")
|
|
58
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Scanner: percorre um projeto Python procurando SQL potencialmente incompatível entre o dialeto
|
|
2
|
+
origem e destino escolhidos. Generaliza o scanner do PostgresModel (que só conhecia SQLite->Postgres)
|
|
3
|
+
pra qualquer par suportado — a diferença de risco entre `?`->`%s` (Postgres) e `?`->`?` (MySQL, sem
|
|
4
|
+
mudança) só faz sentido sabendo os dois lados, por isso o scanner recebe `source`/`target`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
RISK_LOW = "LOW"
|
|
14
|
+
RISK_MEDIUM = "MEDIUM"
|
|
15
|
+
RISK_HIGH = "HIGH"
|
|
16
|
+
|
|
17
|
+
# Placeholder nativo de cada dialeto — usado pra saber se `?`/`%s`/etc precisa mudar entre origem/destino.
|
|
18
|
+
_PLACEHOLDER_STYLE = {
|
|
19
|
+
"sqlite": "?",
|
|
20
|
+
"postgres": "%s",
|
|
21
|
+
"mysql": "%s",
|
|
22
|
+
"sqlserver": "?",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_AUTOINCREMENT_KEYWORDS = {
|
|
26
|
+
"sqlite": "AUTOINCREMENT",
|
|
27
|
+
"postgres": "GENERATED",
|
|
28
|
+
"mysql": "AUTO_INCREMENT",
|
|
29
|
+
"sqlserver": "IDENTITY",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Finding:
|
|
35
|
+
file: Path
|
|
36
|
+
line: int
|
|
37
|
+
category: str
|
|
38
|
+
risk: str
|
|
39
|
+
snippet: str
|
|
40
|
+
suggestion: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ScanReport:
|
|
45
|
+
source: str
|
|
46
|
+
target: str
|
|
47
|
+
findings: list[Finding] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def by_risk(self, risk: str) -> list[Finding]:
|
|
50
|
+
return [f for f in self.findings if f.risk == risk]
|
|
51
|
+
|
|
52
|
+
def by_category(self) -> dict[str, list[Finding]]:
|
|
53
|
+
out: dict[str, list[Finding]] = {}
|
|
54
|
+
for f in self.findings:
|
|
55
|
+
out.setdefault(f.category, []).append(f)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
def to_text(self) -> str:
|
|
59
|
+
lines = [f"Origem: {self.source} -> Destino: {self.target}"]
|
|
60
|
+
for category, items in sorted(self.by_category().items()):
|
|
61
|
+
lines.append(f"\n=== {category} ({len(items)} ocorrência(s)) ===")
|
|
62
|
+
for it in items:
|
|
63
|
+
lines.append(f" [{it.risk}] {it.file}:{it.line} {it.snippet!r}")
|
|
64
|
+
lines.append(f" -> {it.suggestion}")
|
|
65
|
+
lines.append(f"\nTotal: {len(self.findings)} achado(s), "
|
|
66
|
+
f"{len(self.by_risk(RISK_HIGH))} de alto risco (REVIEW_REQUIRED).")
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict:
|
|
70
|
+
return {
|
|
71
|
+
"source": self.source, "target": self.target, "total": len(self.findings),
|
|
72
|
+
"by_risk": {r: len(self.by_risk(r)) for r in (RISK_LOW, RISK_MEDIUM, RISK_HIGH)},
|
|
73
|
+
"findings": [
|
|
74
|
+
{"file": str(f.file), "line": f.line, "category": f.category,
|
|
75
|
+
"risk": f.risk, "snippet": f.snippet, "suggestion": f.suggestion}
|
|
76
|
+
for f in self.findings
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _extract_sql_text(node: ast.AST) -> str | None:
|
|
82
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
83
|
+
return node.value
|
|
84
|
+
if isinstance(node, ast.JoinedStr):
|
|
85
|
+
parts = []
|
|
86
|
+
for v in node.values:
|
|
87
|
+
if isinstance(v, ast.Constant):
|
|
88
|
+
parts.append(str(v.value))
|
|
89
|
+
elif isinstance(v, ast.FormattedValue):
|
|
90
|
+
parts.append("{X}")
|
|
91
|
+
return "".join(parts)
|
|
92
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
93
|
+
left = _extract_sql_text(node.left)
|
|
94
|
+
right = _extract_sql_text(node.right)
|
|
95
|
+
if left is not None and right is not None:
|
|
96
|
+
return left + right
|
|
97
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "join":
|
|
98
|
+
return "{JOIN}"
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _classify(sql: str, file: Path, line: int, source: str, target: str) -> list[Finding]:
|
|
103
|
+
import re
|
|
104
|
+
findings: list[Finding] = []
|
|
105
|
+
|
|
106
|
+
src_ph = _PLACEHOLDER_STYLE.get(source, "?")
|
|
107
|
+
tgt_ph = _PLACEHOLDER_STYLE.get(target, "?")
|
|
108
|
+
if src_ph in sql and src_ph != tgt_ph:
|
|
109
|
+
findings.append(Finding(file, line, "PLACEHOLDER", RISK_LOW, src_ph,
|
|
110
|
+
f"'{src_ph}' -> '{tgt_ph}' — traduzido pelo compat runtime."))
|
|
111
|
+
|
|
112
|
+
src_autoinc = _AUTOINCREMENT_KEYWORDS.get(source)
|
|
113
|
+
if src_autoinc and re.search(rf"\b{src_autoinc}\b", sql, re.IGNORECASE) and "CREATE TABLE" in sql.upper():
|
|
114
|
+
findings.append(Finding(file, line, "AUTOINCREMENT", RISK_LOW, src_autoinc,
|
|
115
|
+
f"Sintaxe de autoincrement de {source} — traduzida automaticamente em DDL."))
|
|
116
|
+
|
|
117
|
+
if re.search(r"\bINSERT\s+OR\s+IGNORE\b", sql, re.IGNORECASE):
|
|
118
|
+
risk = RISK_LOW if target in ("postgres", "sqlite") else RISK_HIGH
|
|
119
|
+
findings.append(Finding(file, line, "INSERT_OR_IGNORE", risk, "INSERT OR IGNORE",
|
|
120
|
+
"ON CONFLICT DO NOTHING (Postgres/SQLite) — verificar equivalente pro destino."))
|
|
121
|
+
|
|
122
|
+
if re.search(r"\bINSERT\s+OR\s+REPLACE\b", sql, re.IGNORECASE):
|
|
123
|
+
findings.append(Finding(file, line, "INSERT_OR_REPLACE", RISK_HIGH, "INSERT OR REPLACE",
|
|
124
|
+
"Precisa saber a coluna de conflito — nunca traduzido automaticamente."))
|
|
125
|
+
|
|
126
|
+
if re.search(r"\bsqlite_master\b", sql, re.IGNORECASE) and target != "sqlite":
|
|
127
|
+
findings.append(Finding(file, line, "SQLITE_MASTER", RISK_HIGH, "sqlite_master",
|
|
128
|
+
"Sem equivalente direto fora do SQLite — trocar por catálogo do destino "
|
|
129
|
+
"(information_schema no Postgres/MySQL)."))
|
|
130
|
+
|
|
131
|
+
if re.search(r"\bPRAGMA\s+\w+", sql, re.IGNORECASE) and target != "sqlite":
|
|
132
|
+
findings.append(Finding(file, line, "PRAGMA", RISK_HIGH, "PRAGMA",
|
|
133
|
+
"Comando específico do SQLite — sem equivalente direto no destino."))
|
|
134
|
+
|
|
135
|
+
if re.search(r"\bLIKE\b", sql, re.IGNORECASE) and not re.search(r"\bILIKE\b", sql, re.IGNORECASE) and target == "postgres":
|
|
136
|
+
findings.append(Finding(file, line, "LIKE_CASE_SENSITIVITY", RISK_HIGH, "LIKE",
|
|
137
|
+
"Case-sensitivity muda entre dialetos — confirmar se ILIKE é necessário."))
|
|
138
|
+
|
|
139
|
+
return findings
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _ExecuteVisitor(ast.NodeVisitor):
|
|
143
|
+
def __init__(self, file: Path, source: str, target: str):
|
|
144
|
+
self.file = file
|
|
145
|
+
self.source = source
|
|
146
|
+
self.target = target
|
|
147
|
+
self.findings: list[Finding] = []
|
|
148
|
+
|
|
149
|
+
def visit_Call(self, node: ast.Call):
|
|
150
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in ("execute", "executemany"):
|
|
151
|
+
if node.args:
|
|
152
|
+
sql = _extract_sql_text(node.args[0])
|
|
153
|
+
if sql is not None:
|
|
154
|
+
self.findings.extend(_classify(sql, self.file, node.lineno, self.source, self.target))
|
|
155
|
+
self.generic_visit(node)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def scan_file(path: Path, source: str, target: str) -> list[Finding]:
|
|
159
|
+
try:
|
|
160
|
+
src = path.read_text(encoding="utf-8")
|
|
161
|
+
tree = ast.parse(src, filename=str(path))
|
|
162
|
+
except (SyntaxError, UnicodeDecodeError) as e:
|
|
163
|
+
return [Finding(path, 0, "PARSE_ERROR", RISK_HIGH, str(e), "Arquivo não pôde ser lido/parseado.")]
|
|
164
|
+
visitor = _ExecuteVisitor(path, source, target)
|
|
165
|
+
visitor.visit(tree)
|
|
166
|
+
return visitor.findings
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def scan_project(root: Path, source: str, target: str,
|
|
170
|
+
exclude: Iterable[str] = ("venv", ".venv", "__pycache__", "node_modules")) -> ScanReport:
|
|
171
|
+
report = ScanReport(source=source, target=target)
|
|
172
|
+
for path in sorted(Path(root).rglob("*.py")):
|
|
173
|
+
if any(part in exclude for part in path.parts):
|
|
174
|
+
continue
|
|
175
|
+
report.findings.extend(scan_file(path, source, target))
|
|
176
|
+
return report
|