core-framework 0.12.1__py3-none-any.whl → 0.12.3__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.
core/migrations/engine.py CHANGED
@@ -255,6 +255,71 @@ class MigrationEngine:
255
255
  index=col.index,
256
256
  )
257
257
 
258
+ def _topological_sort_tables(self, tables: list[TableState]) -> list[TableState]:
259
+ """
260
+ Bug #5 Fix: Ordena tabelas em ordem topológica baseada em FKs.
261
+
262
+ Tabelas referenciadas por FKs são criadas ANTES das que as referenciam.
263
+
264
+ Args:
265
+ tables: Lista de TableState a ordenar
266
+
267
+ Returns:
268
+ Lista ordenada topologicamente
269
+ """
270
+ if not tables:
271
+ return []
272
+
273
+ # Mapeia nome -> tabela
274
+ table_map = {t.name: t for t in tables}
275
+
276
+ # Constrói grafo de dependências
277
+ # dependencias[A] = {B, C} significa A depende de B e C (A tem FK para B e C)
278
+ dependencies: dict[str, set[str]] = {t.name: set() for t in tables}
279
+
280
+ for table in tables:
281
+ for fk in table.foreign_keys:
282
+ ref_table = fk.references_table
283
+ # Só adiciona dependência se a tabela referenciada também está sendo criada
284
+ # (tabelas já existentes não precisam ser consideradas)
285
+ if ref_table in table_map:
286
+ dependencies[table.name].add(ref_table)
287
+
288
+ # Algoritmo de Kahn para ordenação topológica
289
+ result = []
290
+
291
+ # Encontra tabelas sem dependências (grau de entrada 0)
292
+ no_deps = [name for name, deps in dependencies.items() if not deps]
293
+
294
+ while no_deps:
295
+ # Remove uma tabela sem dependências
296
+ name = no_deps.pop(0)
297
+ result.append(table_map[name])
298
+
299
+ # Remove esta tabela das dependências de outras
300
+ for other_name, deps in dependencies.items():
301
+ if name in deps:
302
+ deps.remove(name)
303
+ # Se não tem mais dependências, adiciona à fila
304
+ if not deps and other_name not in [t.name for t in result]:
305
+ no_deps.append(other_name)
306
+
307
+ # Verifica ciclo (se sobrou alguma tabela com dependências)
308
+ remaining = [t for t in tables if t not in result]
309
+ if remaining:
310
+ # Há um ciclo - adiciona as tabelas restantes no final
311
+ # (o banco vai falhar, mas pelo menos o erro será claro)
312
+ import warnings
313
+ cycle_tables = [t.name for t in remaining]
314
+ warnings.warn(
315
+ f"Circular FK dependency detected involving tables: {cycle_tables}. "
316
+ "Migration may fail. Consider breaking the cycle with nullable FKs.",
317
+ RuntimeWarning,
318
+ )
319
+ result.extend(remaining)
320
+
321
+ return result
322
+
258
323
  def _diff_to_operations(self, diff: SchemaDiff) -> list[Operation]:
259
324
  """Converte SchemaDiff em lista de operações."""
260
325
  from core.migrations.operations import CreateEnum, DropEnum, AlterEnum
@@ -284,8 +349,9 @@ class MigrationEngine:
284
349
  new_values=new_enum.values,
285
350
  ))
286
351
 
287
- # 3. Criar tabelas
288
- for table in diff.tables_to_create:
352
+ # 3. Criar tabelas (BUG #5 FIX: em ordem topológica)
353
+ sorted_tables = self._topological_sort_tables(diff.tables_to_create)
354
+ for table in sorted_tables:
289
355
  columns = [self._column_state_to_def(col) for col in table.columns.values()]
290
356
  foreign_keys = [
291
357
  ForeignKeyDef(
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from abc import ABC, abstractmethod
11
11
  from dataclasses import dataclass, field
12
- from typing import Any, Callable, TYPE_CHECKING
12
+ from typing import Any, Callable, ClassVar, TYPE_CHECKING
13
13
  from collections.abc import Awaitable
14
14
 
15
15
  from sqlalchemy import text
@@ -31,9 +31,90 @@ class ColumnDef:
31
31
  unique: bool = False
32
32
  index: bool = False
33
33
 
34
+ # Mapeamento de tipos genéricos para tipos específicos de cada dialeto
35
+ # Bug #1: DATETIME → TIMESTAMP para PostgreSQL
36
+ TYPE_MAPPING: ClassVar[dict[str, dict[str, str]]] = {
37
+ "postgresql": {
38
+ "DATETIME": "TIMESTAMP WITH TIME ZONE",
39
+ "TIMESTAMP": "TIMESTAMP WITH TIME ZONE",
40
+ "BOOLEAN": "BOOLEAN",
41
+ "TINYINT": "SMALLINT",
42
+ "LONGTEXT": "TEXT",
43
+ "DOUBLE": "DOUBLE PRECISION",
44
+ },
45
+ "mysql": {
46
+ "DATETIME": "DATETIME",
47
+ "TIMESTAMP": "TIMESTAMP",
48
+ "BOOLEAN": "TINYINT(1)",
49
+ "TEXT": "LONGTEXT",
50
+ "UUID": "CHAR(36)",
51
+ },
52
+ "sqlite": {
53
+ "DATETIME": "DATETIME",
54
+ "TIMESTAMP": "DATETIME",
55
+ "BOOLEAN": "BOOLEAN",
56
+ "UUID": "TEXT",
57
+ },
58
+ }
59
+
60
+ def _get_dialect_type(self, dialect: str) -> str:
61
+ """
62
+ Converte tipo genérico para tipo específico do dialeto.
63
+
64
+ Args:
65
+ dialect: Nome do dialeto (postgresql, mysql, sqlite)
66
+
67
+ Returns:
68
+ Tipo SQL específico para o dialeto
69
+ """
70
+ # Extrai tipo base (sem parâmetros como VARCHAR(255))
71
+ base_type = self.type.split("(")[0].upper()
72
+
73
+ # Verifica se há mapeamento específico
74
+ dialect_mapping = self.TYPE_MAPPING.get(dialect, {})
75
+ mapped_type = dialect_mapping.get(base_type)
76
+
77
+ if mapped_type:
78
+ # Se o tipo original tinha parâmetros, tenta preservá-los
79
+ if "(" in self.type and "(" not in mapped_type:
80
+ # Tipo como VARCHAR(255) mantém os parâmetros
81
+ return self.type
82
+ return mapped_type
83
+
84
+ return self.type
85
+
86
+ def _get_default_sql(self, dialect: str) -> str | None:
87
+ """
88
+ Gera SQL para valor default considerando o dialeto.
89
+
90
+ Bug #2: Boolean defaults usando TRUE/FALSE para PostgreSQL
91
+
92
+ Args:
93
+ dialect: Nome do dialeto
94
+
95
+ Returns:
96
+ String SQL para o default ou None
97
+ """
98
+ if self.default is None:
99
+ return None
100
+
101
+ if isinstance(self.default, str):
102
+ return f"DEFAULT '{self.default}'"
103
+
104
+ if isinstance(self.default, bool):
105
+ # PostgreSQL exige TRUE/FALSE em vez de 1/0
106
+ if dialect == "postgresql":
107
+ return f"DEFAULT {'TRUE' if self.default else 'FALSE'}"
108
+ else:
109
+ return f"DEFAULT {1 if self.default else 0}"
110
+
111
+ return f"DEFAULT {self.default}"
112
+
34
113
  def to_sql(self, dialect: str = "sqlite") -> str:
35
- """Gera SQL para a coluna."""
36
- parts = [f'"{self.name}"', self.type]
114
+ """Gera SQL para a coluna, adaptado ao dialeto do banco."""
115
+ # Obtém tipo adaptado ao dialeto
116
+ col_type = self._get_dialect_type(dialect)
117
+ parts = [f'"{self.name}"', col_type]
37
118
 
38
119
  if self.primary_key:
39
120
  parts.append("PRIMARY KEY")
@@ -49,13 +130,10 @@ class ColumnDef:
49
130
  if not self.nullable and not self.primary_key:
50
131
  parts.append("NOT NULL")
51
132
 
52
- if self.default is not None:
53
- if isinstance(self.default, str):
54
- parts.append(f"DEFAULT '{self.default}'")
55
- elif isinstance(self.default, bool):
56
- parts.append(f"DEFAULT {1 if self.default else 0}")
57
- else:
58
- parts.append(f"DEFAULT {self.default}")
133
+ # Gera default adaptado ao dialeto
134
+ default_sql = self._get_default_sql(dialect)
135
+ if default_sql:
136
+ parts.append(default_sql)
59
137
 
60
138
  if self.unique and not self.primary_key:
61
139
  parts.append("UNIQUE")