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/core/ir.py ADDED
@@ -0,0 +1,106 @@
1
+ """IR (Intermediate Representation) canônica — o modelo comum que todo dialeto converge PRA (parser)
2
+ e converge DE (renderer). Nenhuma classe aqui sabe nada sobre SQLite/Postgres/MySQL especificamente;
3
+ esse é o ponto inteiro da arquitetura (ver core/parser.py e core/renderer.py pros pontos de extensão).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ from .types import CanonicalType
10
+
11
+
12
+ @dataclass
13
+ class Column:
14
+ name: str
15
+ type: CanonicalType
16
+ nullable: bool = True
17
+ primary_key: bool = False
18
+ auto_increment: bool = False
19
+ unique: bool = False
20
+ default: str | None = None # texto do literal/expressão de default, já em forma canônica
21
+ length: int | None = None # pra VARCHAR
22
+ precision: int | None = None # pra NUMERIC (dígitos totais)
23
+ scale: int | None = None # pra NUMERIC (dígitos após a vírgula)
24
+ enum_values: list[str] = field(default_factory=list) # pra CanonicalType.ENUM
25
+ comment: str | None = None
26
+
27
+
28
+ @dataclass
29
+ class ForeignKey:
30
+ name: str | None
31
+ columns: list[str]
32
+ ref_table: str
33
+ ref_columns: list[str]
34
+ on_delete: str | None = None # 'CASCADE' | 'SET NULL' | 'RESTRICT' | None
35
+ on_update: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class UniqueConstraint:
40
+ name: str | None
41
+ columns: list[str]
42
+
43
+
44
+ @dataclass
45
+ class CheckConstraint:
46
+ name: str | None
47
+ expression: str # expressão booleana em forma canônica simples (ex: "price >= 0")
48
+
49
+
50
+ @dataclass
51
+ class Index:
52
+ name: str
53
+ columns: list[str]
54
+ unique: bool = False
55
+
56
+
57
+ @dataclass
58
+ class Table:
59
+ name: str
60
+ columns: list[Column] = field(default_factory=list)
61
+ foreign_keys: list[ForeignKey] = field(default_factory=list)
62
+ unique_constraints: list[UniqueConstraint] = field(default_factory=list)
63
+ check_constraints: list[CheckConstraint] = field(default_factory=list)
64
+ indexes: list[Index] = field(default_factory=list)
65
+
66
+ def column(self, name: str) -> Column | None:
67
+ return next((c for c in self.columns if c.name == name), None)
68
+
69
+ @property
70
+ def primary_key_columns(self) -> list[str]:
71
+ return [c.name for c in self.columns if c.primary_key]
72
+
73
+
74
+ @dataclass
75
+ class View:
76
+ name: str
77
+ query: str # texto do SELECT em forma canônica simples (sem tradução de sintaxe do dialeto origem)
78
+
79
+
80
+ @dataclass
81
+ class Trigger:
82
+ name: str
83
+ table: str
84
+ timing: str # 'BEFORE' | 'AFTER' | 'INSTEAD OF'
85
+ event: str # 'INSERT' | 'UPDATE' | 'DELETE'
86
+ body: str # corpo bruto — trigger é o caso mais dialect-specific, quase sempre REVIEW_REQUIRED
87
+
88
+
89
+ @dataclass
90
+ class Enum:
91
+ name: str
92
+ values: list[str]
93
+
94
+
95
+ @dataclass
96
+ class Schema:
97
+ """Container de tudo que um dialeto consegue extrair de um schema — a unidade que passa pela
98
+ pipeline parser -> IR -> renderer inteira de uma vez."""
99
+
100
+ tables: list[Table] = field(default_factory=list)
101
+ views: list[View] = field(default_factory=list)
102
+ triggers: list[Trigger] = field(default_factory=list)
103
+ enums: list[Enum] = field(default_factory=list)
104
+
105
+ def table(self, name: str) -> Table | None:
106
+ return next((t for t in self.tables if t.name == name), None)
@@ -0,0 +1,60 @@
1
+ """Contrato base do parser: todo dialeto implementa `DialectParser.parse(sql) -> ParseResult`,
2
+ convertendo DDL do dialeto pra `Schema` (IR). Não é um parser SQL genérico de propósito — cada
3
+ dialeto reconhece SÓ o subconjunto de sintaxe que ele mesmo produz/aceita, e qualquer coisa que não
4
+ reconheça vira uma `Ambiguity` reportada, nunca uma adivinhação.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+
11
+ from .ir import Schema
12
+ from .types import Ambiguity
13
+
14
+
15
+ @dataclass
16
+ class ParseFinding:
17
+ kind: Ambiguity
18
+ statement: str
19
+ reason: str
20
+
21
+
22
+ @dataclass
23
+ class ParseResult:
24
+ schema: Schema
25
+ findings: list[ParseFinding] = field(default_factory=list)
26
+
27
+ @property
28
+ def ok(self) -> bool:
29
+ return len(self.findings) == 0
30
+
31
+
32
+ class DialectParser(ABC):
33
+ """Uma instância por dialeto (ver dialects/sqlite.py, dialects/postgres.py)."""
34
+
35
+ name: str
36
+
37
+ @abstractmethod
38
+ def parse(self, sql: str) -> ParseResult:
39
+ """Recebe um script SQL (1+ statements) e devolve o Schema (IR) reconhecido, mais qualquer
40
+ finding de statement não reconhecido (nunca lançar exceção pra DDL desconhecido — reportar)."""
41
+ raise NotImplementedError
42
+
43
+
44
+ _PARSERS: dict[str, DialectParser] = {}
45
+
46
+
47
+ def register_parser(dialect: str, parser: DialectParser) -> None:
48
+ _PARSERS[dialect] = parser
49
+
50
+
51
+ def get_parser(dialect: str) -> DialectParser:
52
+ try:
53
+ return _PARSERS[dialect]
54
+ except KeyError:
55
+ raise ValueError(f"Dialeto sem parser registrado: {dialect!r}. "
56
+ f"Disponíveis: {sorted(_PARSERS)}") from None
57
+
58
+
59
+ def available_parsers() -> list[str]:
60
+ return sorted(_PARSERS)
@@ -0,0 +1,44 @@
1
+ """Orquestra o pipeline inteiro: SQL origem -> parser do dialeto origem -> IR -> renderer do dialeto
2
+ destino -> SQL destino. É o módulo que `cli.py translate`/`scan`/`patch` chamam — a única peça que
3
+ precisa saber que a pipeline tem essas 2 etapas; parsers/renderers continuam sem se conhecerem.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ from .ir import Schema
10
+ from .parser import ParseFinding, get_parser
11
+ from .renderer import RenderFinding, get_renderer
12
+
13
+
14
+ @dataclass
15
+ class TranslationResult:
16
+ source_dialect: str
17
+ target_dialect: str
18
+ schema: Schema
19
+ sql: str
20
+ parse_findings: list[ParseFinding] = field(default_factory=list)
21
+ render_findings: list[RenderFinding] = field(default_factory=list)
22
+
23
+ @property
24
+ def review_required(self) -> bool:
25
+ return bool(self.parse_findings or self.render_findings)
26
+
27
+
28
+ def translate(sql: str, source_dialect: str, target_dialect: str) -> TranslationResult:
29
+ """Ponto de entrada único da pipeline completa. Nunca lança por DDL não reconhecido — reporta via
30
+ `parse_findings`/`render_findings` e devolve o que CONSEGUIU converter, marcando o resto."""
31
+ parser = get_parser(source_dialect)
32
+ renderer = get_renderer(target_dialect)
33
+
34
+ parse_result = parser.parse(sql)
35
+ render_result = renderer.render_schema(parse_result.schema)
36
+
37
+ return TranslationResult(
38
+ source_dialect=source_dialect,
39
+ target_dialect=target_dialect,
40
+ schema=parse_result.schema,
41
+ sql=render_result.sql,
42
+ parse_findings=parse_result.findings,
43
+ render_findings=render_result.findings,
44
+ )
@@ -0,0 +1,57 @@
1
+ """Contrato base do renderer: todo dialeto implementa `DialectRenderer.render_schema(schema) -> str`,
2
+ convertendo a IR de volta pra DDL do dialeto destino. Onde a IR não tem informação suficiente pra
3
+ gerar algo seguro (ex: Trigger — corpo é texto bruto do dialeto ORIGEM, quase sempre incompatível
4
+ com o destino), o renderer devolve isso como uma `RenderFinding` REVIEW_REQUIRED em vez de copiar
5
+ o texto original cegamente (isso seria "inventar tradução perigosa").
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass, field
11
+
12
+ from .ir import Schema
13
+ from .types import Ambiguity
14
+
15
+
16
+ @dataclass
17
+ class RenderFinding:
18
+ kind: Ambiguity
19
+ target: str # nome da tabela/view/trigger afetada
20
+ reason: str
21
+
22
+
23
+ @dataclass
24
+ class RenderResult:
25
+ sql: str
26
+ findings: list[RenderFinding] = field(default_factory=list)
27
+
28
+ @property
29
+ def ok(self) -> bool:
30
+ return len(self.findings) == 0
31
+
32
+
33
+ class DialectRenderer(ABC):
34
+ name: str
35
+
36
+ @abstractmethod
37
+ def render_schema(self, schema: Schema) -> RenderResult:
38
+ raise NotImplementedError
39
+
40
+
41
+ _RENDERERS: dict[str, DialectRenderer] = {}
42
+
43
+
44
+ def register_renderer(dialect: str, renderer: DialectRenderer) -> None:
45
+ _RENDERERS[dialect] = renderer
46
+
47
+
48
+ def get_renderer(dialect: str) -> DialectRenderer:
49
+ try:
50
+ return _RENDERERS[dialect]
51
+ except KeyError:
52
+ raise ValueError(f"Dialeto sem renderer registrado: {dialect!r}. "
53
+ f"Disponíveis: {sorted(_RENDERERS)}") from None
54
+
55
+
56
+ def available_renderers() -> list[str]:
57
+ return sorted(_RENDERERS)
@@ -0,0 +1,61 @@
1
+ """Utilitários de split de texto SQL respeitando parênteses e aspas — usados por TODO dialeto pra
2
+ separar statements (`;`) e itens de coluna dentro de um `CREATE TABLE(...)` (`,`). Não é um
3
+ tokenizer/parser SQL completo (não entende gramática, só profundidade de parênteses e strings) —
4
+ o suficiente pra dividir texto corretamente sem cortar no meio de uma expressão como
5
+ `CHECK(price >= 0 AND price <= 1000)` ou um default `DEFAULT 'a, b'`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+
10
+ def _split_respecting_parens_and_quotes(text: str, sep: str) -> list[str]:
11
+ parts: list[str] = []
12
+ depth = 0
13
+ quote_char: str | None = None
14
+ current: list[str] = []
15
+ i = 0
16
+ while i < len(text):
17
+ ch = text[i]
18
+ if quote_char:
19
+ current.append(ch)
20
+ if ch == quote_char:
21
+ # aspa duplicada dentro de string ('' ou "") = escape, não fecha a string
22
+ if i + 1 < len(text) and text[i + 1] == quote_char:
23
+ current.append(text[i + 1])
24
+ i += 1
25
+ else:
26
+ quote_char = None
27
+ elif ch in ("'", '"'):
28
+ quote_char = ch
29
+ current.append(ch)
30
+ elif ch == "(":
31
+ depth += 1
32
+ current.append(ch)
33
+ elif ch == ")":
34
+ depth -= 1
35
+ current.append(ch)
36
+ elif ch == sep and depth == 0:
37
+ parts.append("".join(current))
38
+ current = []
39
+ else:
40
+ current.append(ch)
41
+ i += 1
42
+ if current:
43
+ parts.append("".join(current))
44
+ return [p.strip() for p in parts if p.strip()]
45
+
46
+
47
+ def split_statements(sql: str) -> list[str]:
48
+ """Separa um script em statements individuais por `;` no nível 0 de parênteses/aspas."""
49
+ return _split_respecting_parens_and_quotes(sql, ";")
50
+
51
+
52
+ def split_top_level_commas(text: str) -> list[str]:
53
+ """Separa por `,` no nível 0 — usado pra dividir os itens dentro de `CREATE TABLE nome(...)`."""
54
+ return _split_respecting_parens_and_quotes(text, ",")
55
+
56
+
57
+ def strip_outer_parens(text: str) -> str:
58
+ text = text.strip()
59
+ if text.startswith("(") and text.endswith(")"):
60
+ return text[1:-1].strip()
61
+ return text
dbbridge/core/types.py ADDED
@@ -0,0 +1,47 @@
1
+ """Sistema de tipos canônico — o vocabulário comum que todo dialeto mapeia PARA (no parser) e DE
2
+ (no renderer). Isso é o que permite N dialetos se conectarem através de UM ponto central em vez de
3
+ N×N conversores diretos: cada dialeto só precisa saber conversar com o `CanonicalType`, nunca com
4
+ os outros dialetos diretamente.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from enum import Enum
9
+
10
+
11
+ class CanonicalType(str, Enum):
12
+ """Vocabulário de tipos que a IR entende. Deliberadamente um conjunto PEQUENO e bem definido —
13
+ cobre o que os dialetos suportados (Fase 1: SQLite/Postgres) realmente precisam representar,
14
+ não uma tentativa de modelar todo tipo de coluna que já existiu em algum banco."""
15
+
16
+ INTEGER = "integer"
17
+ SMALLINT = "smallint"
18
+ BIGINT = "bigint"
19
+ FLOAT = "float"
20
+ NUMERIC = "numeric" # precisão/escala arbitrária (DECIMAL/NUMERIC)
21
+ BOOLEAN = "boolean"
22
+ TEXT = "text"
23
+ VARCHAR = "varchar" # string de tamanho limitado (tem `length`)
24
+ DATE = "date"
25
+ TIME = "time"
26
+ DATETIME = "datetime" # sem timezone
27
+ TIMESTAMP_TZ = "timestamp_tz"
28
+ JSON = "json"
29
+ UUID = "uuid"
30
+ BINARY = "binary" # BLOB/BYTEA
31
+ ENUM = "enum" # valores fixos — ver Column.enum_values
32
+
33
+
34
+ # Tipos que exigem um parâmetro extra pra renderizar direito (tamanho, precisão/escala, valores de enum).
35
+ TYPES_WITH_LENGTH = {CanonicalType.VARCHAR}
36
+ TYPES_WITH_PRECISION = {CanonicalType.NUMERIC}
37
+
38
+
39
+ class Ambiguity(str, Enum):
40
+ """Por que uma tradução NÃO foi feita automaticamente — usado tanto pelo parser (DDL não
41
+ reconhecido) quanto pelo scanner (padrão de código arriscado)."""
42
+
43
+ UNRECOGNIZED_DDL = "unrecognized_ddl"
44
+ UNSUPPORTED_TYPE = "unsupported_type"
45
+ AMBIGUOUS_DEFAULT = "ambiguous_default"
46
+ UNSUPPORTED_EXPRESSION = "unsupported_expression"
47
+ MULTIPLE_STATEMENTS_IN_ONE = "multiple_statements_in_one"
@@ -0,0 +1,6 @@
1
+ """Implementações por dialeto. Importar este pacote registra todos os dialetos disponíveis nos
2
+ registries de core/parser.py e core/renderer.py (via register_parser/register_renderer chamados no
3
+ import de cada módulo de dialeto). Dialetos "estrutura preparada" (mysql, sqlserver — Fases 2/5)
4
+ aparecem em `available_parsers()`/`available_renderers()` mas levantam `NotImplementedError` com
5
+ mensagem clara SE alguém tentar usá-los de verdade — nunca na importação."""
6
+ from . import mysql, postgres, sqlite, sqlserver # noqa: F401 — o import já registra os dialetos
@@ -0,0 +1,31 @@
1
+ """Base compartilhada dos dialetos "estrutura preparada, não implementados ainda" (mysql, sqlserver
2
+ nesta fase — ver README.md/ENTREGA do projeto pra fases). Registrar um stub (em vez de simplesmente
3
+ não registrar nada) permite que `available_parsers()`/`available_renderers()` e a CLI mostrem esses
4
+ dialetos como "conhecidos, ainda não implementados" com uma mensagem clara — melhor experiência que
5
+ um erro genérico de "dialeto desconhecido"."""
6
+ from __future__ import annotations
7
+
8
+ from ..core.parser import DialectParser, ParseResult
9
+ from ..core.renderer import DialectRenderer, RenderResult
10
+
11
+
12
+ class NotImplementedDialectParser(DialectParser):
13
+ def __init__(self, name: str, phase: str):
14
+ self.name = name
15
+ self._phase = phase
16
+
17
+ def parse(self, sql: str) -> ParseResult:
18
+ raise NotImplementedError(
19
+ f"Parser do dialeto '{self.name}' ainda não implementado (planejado pra {self._phase}). "
20
+ f"Estrutura do módulo já existe em dialects/{self.name}.py — ver README.md/ROADMAP.")
21
+
22
+
23
+ class NotImplementedDialectRenderer(DialectRenderer):
24
+ def __init__(self, name: str, phase: str):
25
+ self.name = name
26
+ self._phase = phase
27
+
28
+ def render_schema(self, schema) -> RenderResult:
29
+ raise NotImplementedError(
30
+ f"Renderer do dialeto '{self.name}' ainda não implementado (planejado pra {self._phase}). "
31
+ f"Estrutura do módulo já existe em dialects/{self.name}.py — ver README.md/ROADMAP.")
@@ -0,0 +1,16 @@
1
+ """Dialeto MySQL — estrutura preparada (Fase 2 do roadmap), ainda não implementado.
2
+
3
+ Quando chegar a hora: mapa de tipos (INT/TINYINT/AUTO_INCREMENT/ENUM nativo/JSON/DATETIME), parser
4
+ de CREATE TABLE no mesmo padrão de dialects/sqlite.py e dialects/postgres.py (reaproveitando
5
+ core/sql_lex.py pra split de statements/colunas), tratamento de `ON UPDATE CURRENT_TIMESTAMP`
6
+ (peculiaridade MySQL sem equivalente direto) e `ENGINE=InnoDB`/charset (ignorados na IR — são
7
+ metadados de armazenamento, não de schema lógico).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from ..core.parser import register_parser
12
+ from ..core.renderer import register_renderer
13
+ from ._stub import NotImplementedDialectParser, NotImplementedDialectRenderer
14
+
15
+ register_parser("mysql", NotImplementedDialectParser("mysql", "Fase 2"))
16
+ register_renderer("mysql", NotImplementedDialectRenderer("mysql", "Fase 2"))