mtpk-postgres 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.
- mtpk_postgres/__init__.py +5 -0
- mtpk_postgres/async_adapter.py +333 -0
- mtpk_postgres/core_sync.py +1688 -0
- mtpk_postgres/crud.py +333 -0
- mtpk_postgres/excepciones.py +46 -0
- mtpk_postgres/interface.py +69 -0
- mtpk_postgres/utils.py +294 -0
- mtpk_postgres-0.1.0.dist-info/METADATA +47 -0
- mtpk_postgres-0.1.0.dist-info/RECORD +12 -0
- mtpk_postgres-0.1.0.dist-info/WHEEL +5 -0
- mtpk_postgres-0.1.0.dist-info/licenses/LICENSE +21 -0
- mtpk_postgres-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1688 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#-*- coding: UTF-8 -*-
|
|
3
|
+
# ----------------------------------------
|
|
4
|
+
# jjandres 2025
|
|
5
|
+
# Adaptación a Postgres (psycopg) de mtpk_mariadb.core_sync
|
|
6
|
+
# ----------------------------------------
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Esta librería es un wrapper (envolvente) del conector psycopg para manejo de Postgres
|
|
10
|
+
|
|
11
|
+
import psycopg
|
|
12
|
+
from psycopg.rows import dict_row
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
import traceback
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Optional, Union, Literal, List, Dict
|
|
18
|
+
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SQLLiteral:
|
|
24
|
+
valor: str
|
|
25
|
+
def __str__(self):
|
|
26
|
+
return self.valor
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Columna:
|
|
31
|
+
"""
|
|
32
|
+
Representa una columna SQL para crear tablas en Postgres.
|
|
33
|
+
|
|
34
|
+
Esta clase define los atributos necesarios para generar la declaración SQL de una columna,
|
|
35
|
+
incluyendo tipos de datos, claves primarias, restricciones, valores por defecto y soporte
|
|
36
|
+
para columnas calculadas (GENERATED).
|
|
37
|
+
|
|
38
|
+
Atributos:
|
|
39
|
+
- `nombre` (str): Nombre de la columna.
|
|
40
|
+
- `tipo` (Literal): Tipo de dato del modelo (se traduce al tipo equivalente en Postgres).
|
|
41
|
+
- `longitud` (int, opcional): Longitud para tipos como VARCHAR(n), CHAR(n).
|
|
42
|
+
- `precision` (int, opcional): Precisión para tipos numéricos como DECIMAL(p,s).
|
|
43
|
+
- `escala` (int, opcional): Escala para tipos numéricos como DECIMAL(p,s).
|
|
44
|
+
- `not_null` (bool): Indica si la columna es NOT NULL.
|
|
45
|
+
- `primary_key` (bool): Indica si la columna es clave primaria.
|
|
46
|
+
- `unique` (bool): Indica si la columna tiene restricción UNIQUE.
|
|
47
|
+
- `auto_increment` (bool): Si es True, la columna se crea como GENERATED ALWAYS AS IDENTITY.
|
|
48
|
+
- `default` (str|int|float, opcional): Valor por defecto de la columna.
|
|
49
|
+
- `comentario` (str, opcional): Comentario asociado a la columna (se emite como COMMENT ON COLUMN aparte).
|
|
50
|
+
- `enum_opciones` (list[str], opcional): Valores válidos si el tipo es ENUM o SET (se traduce a TEXT + CHECK).
|
|
51
|
+
- `protegido_insertar` (bool): Si está protegida contra inserciones vía CRUD.
|
|
52
|
+
- `protegido_actualizar` (bool): Si está protegida contra actualizaciones vía CRUD.
|
|
53
|
+
- `generado` (str, opcional): Expresión SQL para columnas calculadas (GENERATED ALWAYS AS (...) STORED).
|
|
54
|
+
- `generado_tipo` (str, opcional): Se conserva por compatibilidad, pero Postgres solo admite columnas
|
|
55
|
+
calculadas STORED, así que siempre se emite como STORED independientemente de este valor.
|
|
56
|
+
- `tabla` (Tabla, opcional): Referencia a la tabla que contiene esta columna.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
nombre: str
|
|
60
|
+
"""Nombre de la columna"""
|
|
61
|
+
|
|
62
|
+
tipo: Literal[
|
|
63
|
+
"TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE",
|
|
64
|
+
"CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", "BINARY", "VARBINARY", "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB",
|
|
65
|
+
"DATE", "TIME", "YEAR", "DATETIME", "TIMESTAMP", "ENUM", "SET", "BOOLEAN", "GEOMETRY", "POINT", "LINESTRING", "POLYGON"
|
|
66
|
+
]
|
|
67
|
+
"""Tipo de dato del modelo (mismo vocabulario que mtpk-mariadb, traducido a Postgres en to_sql())"""
|
|
68
|
+
|
|
69
|
+
longitud: Optional[int] = None
|
|
70
|
+
"""Longitud para tipos como VARCHAR(n), CHAR(n)."""
|
|
71
|
+
|
|
72
|
+
precision: Optional[int] = None
|
|
73
|
+
"""Precisión para DECIMAL(p, s), etc."""
|
|
74
|
+
|
|
75
|
+
escala: Optional[int] = None
|
|
76
|
+
"""Escala para DECIMAL(p, s), opcional"""
|
|
77
|
+
|
|
78
|
+
not_null: bool = False
|
|
79
|
+
"""Si la columna debe ser NOT NULL"""
|
|
80
|
+
|
|
81
|
+
primary_key: bool = False
|
|
82
|
+
"""Si esta columna es clave primaria"""
|
|
83
|
+
|
|
84
|
+
unique: bool = False
|
|
85
|
+
"""Si esta columna tiene restricción UNIQUE"""
|
|
86
|
+
|
|
87
|
+
auto_increment: bool = False
|
|
88
|
+
"""Si es autoincremental (GENERATED ALWAYS AS IDENTITY en Postgres)"""
|
|
89
|
+
|
|
90
|
+
default: Optional[Union[str, int, float, Literal["CURRENT_TIMESTAMP"]]] = None
|
|
91
|
+
"""Valor por defecto. Usar CURRENT_TIMESTAMP como literal string si aplica."""
|
|
92
|
+
|
|
93
|
+
comentario: Optional[str] = None
|
|
94
|
+
"""Comentario asociado a la columna"""
|
|
95
|
+
|
|
96
|
+
enum_opciones: Optional[list[str]] = None
|
|
97
|
+
"""Lista de valores posibles si tipo es ENUM o SET"""
|
|
98
|
+
|
|
99
|
+
protegido_insertar: bool = False
|
|
100
|
+
"""Si esta columna está protegida contra inserciones (Solo cuando se indique al método CRUD)"""
|
|
101
|
+
|
|
102
|
+
protegido_actualizar: bool = False
|
|
103
|
+
"""Si esta columna está protegida contra actualizaciones (Solo cuando se indique al método CRUD)"""
|
|
104
|
+
|
|
105
|
+
generado: Optional[str] = None
|
|
106
|
+
"""Expresión SQL para campos calculados (GENERATED ALWAYS AS (...) STORED)"""
|
|
107
|
+
|
|
108
|
+
generado_tipo: Optional[Literal["STORED", "VIRTUAL"]] = None
|
|
109
|
+
"""Se conserva por compatibilidad; Postgres siempre emite STORED."""
|
|
110
|
+
|
|
111
|
+
tabla: Optional["Tabla"] = field(default=None, repr=False)
|
|
112
|
+
|
|
113
|
+
_MAPA_TIPOS = {
|
|
114
|
+
"TINYINT": "SMALLINT", "SMALLINT": "SMALLINT", "MEDIUMINT": "SMALLINT",
|
|
115
|
+
"INT": "INTEGER", "INTEGER": "INTEGER", "BIGINT": "BIGINT",
|
|
116
|
+
"DECIMAL": "DECIMAL", "NUMERIC": "NUMERIC",
|
|
117
|
+
"FLOAT": "REAL", "DOUBLE": "DOUBLE PRECISION",
|
|
118
|
+
"CHAR": "CHAR", "VARCHAR": "VARCHAR",
|
|
119
|
+
"TEXT": "TEXT", "TINYTEXT": "TEXT", "MEDIUMTEXT": "TEXT", "LONGTEXT": "TEXT",
|
|
120
|
+
"BINARY": "BYTEA", "VARBINARY": "BYTEA", "BLOB": "BYTEA",
|
|
121
|
+
"TINYBLOB": "BYTEA", "MEDIUMBLOB": "BYTEA", "LONGBLOB": "BYTEA",
|
|
122
|
+
"DATE": "DATE", "TIME": "TIME", "DATETIME": "TIMESTAMP", "TIMESTAMP": "TIMESTAMP",
|
|
123
|
+
"YEAR": "SMALLINT",
|
|
124
|
+
"BOOLEAN": "BOOLEAN",
|
|
125
|
+
"ENUM": "TEXT", "SET": "TEXT",
|
|
126
|
+
}
|
|
127
|
+
_SIN_SOPORTE = {"GEOMETRY", "POINT", "LINESTRING", "POLYGON"}
|
|
128
|
+
|
|
129
|
+
def _tipo_sql(self) -> str:
|
|
130
|
+
"""Traduce self.tipo (+ longitud/precision/escala) al tipo SQL de Postgres."""
|
|
131
|
+
tipo_upper = self.tipo.upper()
|
|
132
|
+
if tipo_upper in self._SIN_SOPORTE:
|
|
133
|
+
raise NotImplementedError(
|
|
134
|
+
f"El tipo '{tipo_upper}' requiere la extensión PostGIS y no está soportado."
|
|
135
|
+
)
|
|
136
|
+
base = self._MAPA_TIPOS.get(tipo_upper, tipo_upper)
|
|
137
|
+
|
|
138
|
+
if base in {"VARCHAR", "CHAR"}:
|
|
139
|
+
return f"{base}({self.longitud})"
|
|
140
|
+
if base in {"DECIMAL", "NUMERIC"}:
|
|
141
|
+
if self.precision is not None and self.escala is not None:
|
|
142
|
+
return f"{base}({self.precision},{self.escala})"
|
|
143
|
+
elif self.precision is not None:
|
|
144
|
+
return f"{base}({self.precision})"
|
|
145
|
+
return base
|
|
146
|
+
return base
|
|
147
|
+
|
|
148
|
+
def to_sql(self) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Genera la definición SQL de esta columna para Postgres.
|
|
151
|
+
Los comentarios (COMMENT ON COLUMN) no van aquí: se generan aparte en Tabla.to_sql_comentarios().
|
|
152
|
+
"""
|
|
153
|
+
partes = [f'"{self.nombre}"']
|
|
154
|
+
tipo_upper = self.tipo.upper()
|
|
155
|
+
tipo_sql = self._tipo_sql()
|
|
156
|
+
|
|
157
|
+
if self.generado:
|
|
158
|
+
partes.append(f"{tipo_sql} GENERATED ALWAYS AS ({self.generado}) STORED")
|
|
159
|
+
else:
|
|
160
|
+
partes.append(tipo_sql)
|
|
161
|
+
|
|
162
|
+
if self.auto_increment:
|
|
163
|
+
partes.append("GENERATED ALWAYS AS IDENTITY")
|
|
164
|
+
|
|
165
|
+
partes.append("NOT NULL" if self.not_null or self.primary_key else "NULL")
|
|
166
|
+
|
|
167
|
+
if self.default is not None:
|
|
168
|
+
if isinstance(self.default, SQLLiteral):
|
|
169
|
+
partes.append(f"DEFAULT {self.default}")
|
|
170
|
+
elif isinstance(self.default, str) and not self.default.upper().startswith("CURRENT_TIMESTAMP"):
|
|
171
|
+
partes.append(f"DEFAULT '{self.default}'")
|
|
172
|
+
else:
|
|
173
|
+
partes.append(f"DEFAULT {self.default}")
|
|
174
|
+
|
|
175
|
+
if self.unique:
|
|
176
|
+
partes.append("UNIQUE")
|
|
177
|
+
|
|
178
|
+
if self.primary_key:
|
|
179
|
+
partes.append("PRIMARY KEY")
|
|
180
|
+
|
|
181
|
+
if tipo_upper in {"ENUM", "SET"} and self.enum_opciones:
|
|
182
|
+
opciones = ", ".join(f"'{o}'" for o in self.enum_opciones)
|
|
183
|
+
partes.append(f'CHECK ("{self.nombre}" IN ({opciones}))')
|
|
184
|
+
|
|
185
|
+
return " ".join(partes)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class ForeignKey:
|
|
190
|
+
"""
|
|
191
|
+
Representa una clave foránea (FOREIGN KEY) en una tabla SQL.
|
|
192
|
+
|
|
193
|
+
- Atributos:
|
|
194
|
+
- `columna` (str): Nombre de la columna local en esta tabla que actúa como clave foránea.
|
|
195
|
+
- `referencia_tabla` (str): Nombre de la tabla referenciada.
|
|
196
|
+
- `referencia_columna` (str): Columna de la tabla referenciada a la que apunta esta clave foránea.
|
|
197
|
+
- `nombre` (str, opcional): Nombre explícito del constraint. Si no se proporciona, Postgres generará uno automáticamente.
|
|
198
|
+
- `on_delete` (str, opcional): Acción a realizar al eliminar la fila referenciada ('CASCADE', 'RESTRICT', 'SET NULL', etc.).
|
|
199
|
+
- `on_update` (str, opcional): Acción a realizar al actualizar la fila referenciada.
|
|
200
|
+
- `tabla` (Tabla, opcional): Referencia a la tabla actual que contiene esta clave. Se asigna automáticamente.
|
|
201
|
+
"""
|
|
202
|
+
columna: str
|
|
203
|
+
referencia_tabla: str
|
|
204
|
+
referencia_columna: str
|
|
205
|
+
nombre: Optional[str] = None
|
|
206
|
+
on_delete: Optional[str] = None
|
|
207
|
+
on_update: Optional[str] = None
|
|
208
|
+
tabla: Optional["Tabla"] = field(default=None, repr=False)
|
|
209
|
+
|
|
210
|
+
def to_sql(self) -> str:
|
|
211
|
+
sql = ""
|
|
212
|
+
if self.nombre:
|
|
213
|
+
sql += f'CONSTRAINT "{self.nombre}" '
|
|
214
|
+
sql += f'FOREIGN KEY ("{self.columna}") REFERENCES "{self.referencia_tabla}"("{self.referencia_columna}")'
|
|
215
|
+
if self.on_delete:
|
|
216
|
+
sql += f" ON DELETE {self.on_delete}"
|
|
217
|
+
if self.on_update:
|
|
218
|
+
sql += f" ON UPDATE {self.on_update}"
|
|
219
|
+
return sql
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class Index:
|
|
224
|
+
"""
|
|
225
|
+
Representa un índice (normal o único) sobre una o más columnas de una tabla SQL.
|
|
226
|
+
|
|
227
|
+
Postgres no admite índices dentro de CREATE TABLE: to_sql() genera aquí la sentencia
|
|
228
|
+
CREATE INDEX / CREATE UNIQUE INDEX completa (necesita conocer la tabla a la que pertenece).
|
|
229
|
+
|
|
230
|
+
- Atributos:
|
|
231
|
+
- `columnas` (List[str]): Lista de nombres de columnas incluidas en el índice. Pueden ser una o varias (índice compuesto).
|
|
232
|
+
- `nombre` (str, opcional): Nombre personalizado del índice. Si no se indica, se generará automáticamente combinando los nombres de las columnas.
|
|
233
|
+
- `unico` (bool): Indica si el índice es único (`UNIQUE`). Por defecto es `False`.
|
|
234
|
+
- `tabla` (Tabla, opcional): Referencia a la tabla que contiene este índice. Se asigna automáticamente al añadir el índice a la tabla.
|
|
235
|
+
"""
|
|
236
|
+
columnas: List[str]
|
|
237
|
+
nombre: Optional[str] = None
|
|
238
|
+
unico: bool = False
|
|
239
|
+
tabla: Optional["Tabla"] = field(default=None, repr=False)
|
|
240
|
+
|
|
241
|
+
def to_sql(self) -> str:
|
|
242
|
+
if not self.tabla:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"El índice sobre {self.columnas} no está asociado a ninguna tabla; no se puede generar el CREATE INDEX."
|
|
245
|
+
)
|
|
246
|
+
cols = ", ".join(f'"{c}"' for c in self.columnas)
|
|
247
|
+
nombre = self.nombre or f"{'_'.join(self.columnas)}_{'uk' if self.unico else 'idx'}"
|
|
248
|
+
tipo_sql = "UNIQUE INDEX" if self.unico else "INDEX"
|
|
249
|
+
return f'CREATE {tipo_sql} IF NOT EXISTS "{nombre}" ON "{self.tabla.nombre}" ({cols})'
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class Tabla:
|
|
254
|
+
"""
|
|
255
|
+
Representa la definición estructural de una tabla SQL para Postgres.
|
|
256
|
+
|
|
257
|
+
- Atributos:
|
|
258
|
+
- `nombre` (str): Nombre de la tabla en la base de datos.
|
|
259
|
+
- `columnas` (List[Columna]): Lista de objetos `Columna` que definen los campos de la tabla.
|
|
260
|
+
- `indices` (List[Index]): Lista de índices adicionales (simples o compuestos), excluyendo claves primarias y claves foráneas.
|
|
261
|
+
- `foreign_keys` (List[ForeignKey]): Lista de claves foráneas que definen relaciones con otras tablas.
|
|
262
|
+
- `claves_primarias` (List[str]): Lista de nombres de columnas que componen la clave primaria (si es compuesta).
|
|
263
|
+
- `comentario` (str, opcional): Comentario descriptivo opcional para la tabla.
|
|
264
|
+
- `registros_iniciales` (Optional[List[Dict[str, Union[str, int, float]]]]):
|
|
265
|
+
Lista de diccionarios con registros que deben insertarse automáticamente tras crear la tabla.
|
|
266
|
+
Cada diccionario representa un registro, con las claves como nombres de columnas.
|
|
267
|
+
- `vistas` (Optional[List[str]]): Lista de vistas asociadas a esta tabla, que serán insertadas en la base de datos.
|
|
268
|
+
- `triggers` (list[str], opcional): Sentencias SQL de triggers asociados.
|
|
269
|
+
- `db_name` (str, opcional): Nombre de la base de datos asociada a esta tabla. Solo se usa para clasificar la tabla y saber a que base de datos pertenece.
|
|
270
|
+
- `database` (Database, opcional): Referencia a la instancia de `Database` a la que pertenece esta tabla. Se asigna automáticamente al añadir la tabla a una base de datos.
|
|
271
|
+
|
|
272
|
+
"""
|
|
273
|
+
nombre: str
|
|
274
|
+
columnas: List[Columna]
|
|
275
|
+
indices: List[Index] = field(default_factory=list)
|
|
276
|
+
foreign_keys: List[ForeignKey] = field(default_factory=list)
|
|
277
|
+
claves_primarias: List[str] = field(default_factory=list)
|
|
278
|
+
comentario: Optional[str] = None
|
|
279
|
+
registros_iniciales: Optional[List[Dict[str, Union[str, int, float]]]] = field(default_factory=list)
|
|
280
|
+
vistas: Optional[List[str]] = field(default_factory=list)
|
|
281
|
+
triggers: Optional[list[str]] = None
|
|
282
|
+
procedimientos: Optional[list[str]] = None
|
|
283
|
+
database: Optional["Database"] = None
|
|
284
|
+
db_name: Optional[str] = None # ← nuevo campo, sin conflicto con Database
|
|
285
|
+
|
|
286
|
+
def add_columna(self, columna: Columna):
|
|
287
|
+
"""
|
|
288
|
+
Añade una columna a la tabla si no existe ya.
|
|
289
|
+
|
|
290
|
+
- Args:
|
|
291
|
+
- `columna` (Columna): Objeto columna a añadir.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
ValueError: Si ya existe una columna con el mismo nombre.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
if any(col.nombre == columna.nombre for col in self.columnas):
|
|
298
|
+
raise ValueError(f"La columna '{columna.nombre}' ya está definida en la tabla '{self.nombre}'.")
|
|
299
|
+
columna.tabla = self
|
|
300
|
+
self.columnas.append(columna)
|
|
301
|
+
|
|
302
|
+
def set_columnas(self, columnas: List[Columna]):
|
|
303
|
+
"""
|
|
304
|
+
Reemplaza la lista completa de columnas con una nueva, validando que no haya duplicados.
|
|
305
|
+
|
|
306
|
+
- Args:
|
|
307
|
+
- `columnas` (List[Columna]): Lista de objetos Columna.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
ValueError: Si hay columnas con nombres duplicados.
|
|
311
|
+
"""
|
|
312
|
+
nombres = [col.nombre for col in columnas]
|
|
313
|
+
duplicados = {nombre for nombre in nombres if nombres.count(nombre) > 1}
|
|
314
|
+
if duplicados:
|
|
315
|
+
raise ValueError(f"Columnas duplicadas encontradas: {', '.join(duplicados)}")
|
|
316
|
+
for col in columnas:
|
|
317
|
+
col.tabla = self
|
|
318
|
+
self.columnas = columnas
|
|
319
|
+
|
|
320
|
+
def get_nombres_columnas(self):
|
|
321
|
+
"""Devuelve una lista con los nombre de las columnas de la tabla"""
|
|
322
|
+
return [c.nombre for c in self.columnas]
|
|
323
|
+
|
|
324
|
+
def add_index(self, index: Index):
|
|
325
|
+
if any(set(index.columnas) == set(idx.columnas) for idx in self.indices):
|
|
326
|
+
raise ValueError(f"Ya existe un índice para las columnas {index.columnas} en la tabla '{self.nombre}'.")
|
|
327
|
+
index.tabla = self
|
|
328
|
+
self.indices.append(index)
|
|
329
|
+
|
|
330
|
+
def set_indices(self, indices: List[Index]):
|
|
331
|
+
seen = set()
|
|
332
|
+
for idx in indices:
|
|
333
|
+
key = tuple(sorted(idx.columnas))
|
|
334
|
+
if key in seen:
|
|
335
|
+
raise ValueError(f"Índice duplicado con columnas: {key}")
|
|
336
|
+
seen.add(key)
|
|
337
|
+
idx.tabla = self
|
|
338
|
+
self.indices = indices
|
|
339
|
+
|
|
340
|
+
def add_foreign_key(self, fk: ForeignKey):
|
|
341
|
+
if any((fk.columna == f.columna and fk.referencia_tabla == f.referencia_tabla and fk.referencia_columna == f.referencia_columna) for f in self.foreign_keys):
|
|
342
|
+
raise ValueError(f"Ya existe una clave foránea similar en la tabla '{self.nombre}'.")
|
|
343
|
+
fk.tabla = self
|
|
344
|
+
self.foreign_keys.append(fk)
|
|
345
|
+
|
|
346
|
+
def set_foreign_keys(self, foreign_keys: List[ForeignKey]):
|
|
347
|
+
"""
|
|
348
|
+
Asigna la lista completa de claves foráneas, validando duplicados y asignando la tabla padre.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
foreign_keys (List[ForeignKey]): Lista de claves foráneas.
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
ValueError: Si hay duplicados por columna/referencia.
|
|
355
|
+
"""
|
|
356
|
+
claves_unicas = set()
|
|
357
|
+
for fk in foreign_keys:
|
|
358
|
+
clave = (fk.columna, fk.referencia_tabla, fk.referencia_columna)
|
|
359
|
+
if clave in claves_unicas:
|
|
360
|
+
raise ValueError(f"Clave foránea duplicada: {clave}")
|
|
361
|
+
claves_unicas.add(clave)
|
|
362
|
+
fk.tabla = self
|
|
363
|
+
self.foreign_keys = foreign_keys
|
|
364
|
+
|
|
365
|
+
def set_database(self, db):
|
|
366
|
+
"""
|
|
367
|
+
Establece la base de datos a la que pertenece esta tabla.
|
|
368
|
+
"""
|
|
369
|
+
self.database = db
|
|
370
|
+
|
|
371
|
+
def to_sql(self) -> str:
|
|
372
|
+
"""
|
|
373
|
+
Genera la sentencia SQL CREATE TABLE IF NOT EXISTS con columnas, clave primaria compuesta y claves foráneas.
|
|
374
|
+
Los índices y los comentarios van aparte: ver `to_sql_indices()` y `to_sql_comentarios()`.
|
|
375
|
+
"""
|
|
376
|
+
elementos_sql = []
|
|
377
|
+
|
|
378
|
+
for col in self.columnas:
|
|
379
|
+
elementos_sql.append(col.to_sql())
|
|
380
|
+
|
|
381
|
+
if self.claves_primarias:
|
|
382
|
+
pk = ", ".join(f'"{c}"' for c in self.claves_primarias)
|
|
383
|
+
elementos_sql.append(f"PRIMARY KEY ({pk})")
|
|
384
|
+
|
|
385
|
+
for fk in self.foreign_keys:
|
|
386
|
+
elementos_sql.append(fk.to_sql())
|
|
387
|
+
|
|
388
|
+
cuerpo = ",\n ".join(elementos_sql)
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
f'CREATE TABLE IF NOT EXISTS "{self.nombre}" (\n'
|
|
392
|
+
f" {cuerpo}\n"
|
|
393
|
+
f");"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def to_sql_indices(self) -> List[str]:
|
|
397
|
+
"""Sentencias CREATE INDEX / CREATE UNIQUE INDEX de esta tabla (van fuera del CREATE TABLE en Postgres)."""
|
|
398
|
+
return [f"{idx.to_sql()};" for idx in self.indices]
|
|
399
|
+
|
|
400
|
+
def to_sql_comentarios(self) -> List[str]:
|
|
401
|
+
"""Sentencias COMMENT ON TABLE / COMMENT ON COLUMN de esta tabla (Postgres no admite COMMENT inline)."""
|
|
402
|
+
sentencias = []
|
|
403
|
+
if self.comentario:
|
|
404
|
+
texto = self.comentario.replace("'", "''")
|
|
405
|
+
sentencias.append(f"COMMENT ON TABLE \"{self.nombre}\" IS '{texto}';")
|
|
406
|
+
for col in self.columnas:
|
|
407
|
+
if col.comentario:
|
|
408
|
+
texto = col.comentario.replace("'", "''")
|
|
409
|
+
sentencias.append(f"COMMENT ON COLUMN \"{self.nombre}\".\"{col.nombre}\" IS '{texto}';")
|
|
410
|
+
return sentencias
|
|
411
|
+
|
|
412
|
+
_PG_A_MODELO = {
|
|
413
|
+
"smallint": "SMALLINT",
|
|
414
|
+
"integer": "INTEGER",
|
|
415
|
+
"bigint": "BIGINT",
|
|
416
|
+
"numeric": "DECIMAL",
|
|
417
|
+
"real": "FLOAT",
|
|
418
|
+
"double precision": "DOUBLE",
|
|
419
|
+
"character varying": "VARCHAR",
|
|
420
|
+
"character": "CHAR",
|
|
421
|
+
"text": "TEXT",
|
|
422
|
+
"bytea": "BLOB",
|
|
423
|
+
"date": "DATE",
|
|
424
|
+
"time without time zone": "TIME",
|
|
425
|
+
"timestamp without time zone": "TIMESTAMP",
|
|
426
|
+
"boolean": "BOOLEAN",
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
def obtener_columnas_postgres(self, conexion, dbname=None) -> List[Columna]:
|
|
430
|
+
"""
|
|
431
|
+
Obtiene la definición de columnas de esta tabla desde Postgres.
|
|
432
|
+
Consulta information_schema.columns (más las claves primarias/únicas de
|
|
433
|
+
information_schema.table_constraints) para reconstruir cada Columna.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
- `conexion`: Conexión psycopg abierta.
|
|
437
|
+
- `dbname`: (Opcional) Nombre de la base de datos; si se omite se toma de
|
|
438
|
+
self.database.db. Postgres solo ve el catálogo de la base conectada,
|
|
439
|
+
así que este valor no se usa para filtrar, se mantiene por compatibilidad
|
|
440
|
+
de firma con la versión mariadb.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
- `columnas`: Lista de objetos Columna con la definición de cada columna.
|
|
444
|
+
"""
|
|
445
|
+
if not dbname:
|
|
446
|
+
if not self.database or not hasattr(self.database, 'db'):
|
|
447
|
+
raise ValueError("No se ha especificado el nombre de la base de datos y la tabla no está asociada a una instancia Database.")
|
|
448
|
+
dbname = self.database.db
|
|
449
|
+
|
|
450
|
+
columnas = []
|
|
451
|
+
with conexion.cursor(row_factory=dict_row) as cursor:
|
|
452
|
+
cursor.execute(
|
|
453
|
+
"""
|
|
454
|
+
SELECT
|
|
455
|
+
c.column_name, c.data_type, c.character_maximum_length,
|
|
456
|
+
c.numeric_precision, c.numeric_scale, c.is_nullable,
|
|
457
|
+
c.column_default, c.is_identity,
|
|
458
|
+
COALESCE(pk.es_pk, false) AS es_pk,
|
|
459
|
+
COALESCE(uq.es_unique, false) AS es_unique
|
|
460
|
+
FROM information_schema.columns c
|
|
461
|
+
LEFT JOIN (
|
|
462
|
+
SELECT kcu.column_name, true AS es_pk
|
|
463
|
+
FROM information_schema.table_constraints tc
|
|
464
|
+
JOIN information_schema.key_column_usage kcu
|
|
465
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
466
|
+
AND tc.table_schema = kcu.table_schema
|
|
467
|
+
WHERE tc.table_schema = 'public' AND tc.table_name = %s
|
|
468
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
469
|
+
) pk ON pk.column_name = c.column_name
|
|
470
|
+
LEFT JOIN (
|
|
471
|
+
SELECT kcu.column_name, true AS es_unique
|
|
472
|
+
FROM information_schema.table_constraints tc
|
|
473
|
+
JOIN information_schema.key_column_usage kcu
|
|
474
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
475
|
+
AND tc.table_schema = kcu.table_schema
|
|
476
|
+
WHERE tc.table_schema = 'public' AND tc.table_name = %s
|
|
477
|
+
AND tc.constraint_type = 'UNIQUE'
|
|
478
|
+
) uq ON uq.column_name = c.column_name
|
|
479
|
+
WHERE c.table_schema = 'public' AND c.table_name = %s
|
|
480
|
+
ORDER BY c.ordinal_position
|
|
481
|
+
""",
|
|
482
|
+
(self.nombre, self.nombre, self.nombre)
|
|
483
|
+
)
|
|
484
|
+
filas = cursor.fetchall()
|
|
485
|
+
|
|
486
|
+
checks_por_columna = self._obtener_opciones_check(conexion)
|
|
487
|
+
|
|
488
|
+
for fila in filas:
|
|
489
|
+
tipo_base = self._PG_A_MODELO.get(fila["data_type"], fila["data_type"].upper())
|
|
490
|
+
enum_opciones = None
|
|
491
|
+
|
|
492
|
+
if tipo_base == "TEXT" and fila["column_name"] in checks_por_columna:
|
|
493
|
+
# Postgres no distingue ENUM de SET tras reconstruir desde el CHECK: ambos
|
|
494
|
+
# se guardan como TEXT + CHECK (ver Columna.to_sql), así que se asume ENUM.
|
|
495
|
+
tipo_base = "ENUM"
|
|
496
|
+
enum_opciones = checks_por_columna[fila["column_name"]]
|
|
497
|
+
|
|
498
|
+
col = Columna(
|
|
499
|
+
nombre=fila["column_name"],
|
|
500
|
+
tipo=tipo_base,
|
|
501
|
+
longitud=fila["character_maximum_length"],
|
|
502
|
+
precision=fila["numeric_precision"],
|
|
503
|
+
escala=fila["numeric_scale"],
|
|
504
|
+
not_null=(fila["is_nullable"] == "NO"),
|
|
505
|
+
default=fila["column_default"],
|
|
506
|
+
auto_increment=(fila["is_identity"] == "YES"),
|
|
507
|
+
primary_key=fila["es_pk"],
|
|
508
|
+
unique=fila["es_unique"],
|
|
509
|
+
enum_opciones=enum_opciones,
|
|
510
|
+
)
|
|
511
|
+
columnas.append(col)
|
|
512
|
+
|
|
513
|
+
return columnas
|
|
514
|
+
|
|
515
|
+
def _obtener_opciones_check(self, conexion) -> Dict[str, List[str]]:
|
|
516
|
+
"""
|
|
517
|
+
Reconstruye, de forma best-effort, las opciones de las columnas ENUM/SET (TEXT + CHECK)
|
|
518
|
+
a partir de las restricciones CHECK existentes en la tabla.
|
|
519
|
+
"""
|
|
520
|
+
resultado: Dict[str, List[str]] = {}
|
|
521
|
+
with conexion.cursor(row_factory=dict_row) as cursor:
|
|
522
|
+
cursor.execute(
|
|
523
|
+
"""
|
|
524
|
+
SELECT pg_get_constraintdef(con.oid) AS definicion
|
|
525
|
+
FROM pg_constraint con
|
|
526
|
+
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
527
|
+
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
|
|
528
|
+
WHERE con.contype = 'c' AND nsp.nspname = 'public' AND rel.relname = %s
|
|
529
|
+
""",
|
|
530
|
+
(self.nombre,)
|
|
531
|
+
)
|
|
532
|
+
for fila in cursor.fetchall():
|
|
533
|
+
definicion = fila["definicion"] or ""
|
|
534
|
+
m = re.match(r'CHECK\s*\(\("?(\w+)"?\s*=\s*ANY\s*\(ARRAY\[(.*?)\]', definicion, re.IGNORECASE)
|
|
535
|
+
if not m:
|
|
536
|
+
m = re.match(r'CHECK\s*\(\("?(\w+)"?\s+IN\s*\((.*?)\)\)', definicion, re.IGNORECASE)
|
|
537
|
+
if m:
|
|
538
|
+
columna = m.group(1)
|
|
539
|
+
valores = re.findall(r"'([^']*)'", m.group(2))
|
|
540
|
+
if valores:
|
|
541
|
+
resultado[columna] = valores
|
|
542
|
+
return resultado
|
|
543
|
+
|
|
544
|
+
def obtener_indices_postgres(self, conexion, dbname: str = None) -> List["Index"]:
|
|
545
|
+
"""
|
|
546
|
+
Obtiene los índices no primarios definidos en esta tabla en Postgres,
|
|
547
|
+
a partir de los catálogos pg_index/pg_class/pg_attribute.
|
|
548
|
+
"""
|
|
549
|
+
with conexion.cursor(row_factory=dict_row) as cursor:
|
|
550
|
+
cursor.execute(
|
|
551
|
+
"""
|
|
552
|
+
SELECT ix.relname AS nombre_indice, a.attname AS columna,
|
|
553
|
+
i.indisunique AS es_unico, i.indisprimary AS es_primaria,
|
|
554
|
+
array_position(i.indkey::int2[], a.attnum::int2) AS posicion
|
|
555
|
+
FROM pg_index i
|
|
556
|
+
JOIN pg_class t ON t.oid = i.indrelid
|
|
557
|
+
JOIN pg_class ix ON ix.oid = i.indexrelid
|
|
558
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
559
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(i.indkey)
|
|
560
|
+
WHERE t.relname = %s AND n.nspname = 'public'
|
|
561
|
+
ORDER BY ix.relname, posicion
|
|
562
|
+
""",
|
|
563
|
+
(self.nombre,)
|
|
564
|
+
)
|
|
565
|
+
filas = cursor.fetchall()
|
|
566
|
+
|
|
567
|
+
agrupados: Dict[str, dict] = {}
|
|
568
|
+
for fila in filas:
|
|
569
|
+
if fila["es_primaria"]:
|
|
570
|
+
continue
|
|
571
|
+
info = agrupados.setdefault(fila["nombre_indice"], {"columnas": [], "unico": fila["es_unico"]})
|
|
572
|
+
info["columnas"].append(fila["columna"])
|
|
573
|
+
|
|
574
|
+
return [Index(nombre=nombre, columnas=info["columnas"], unico=info["unico"]) for nombre, info in agrupados.items()]
|
|
575
|
+
|
|
576
|
+
def obtener_fks_postgres(self, conexion, dbname: str = None) -> List["ForeignKey"]:
|
|
577
|
+
"""
|
|
578
|
+
Recupera las claves foráneas existentes en esta tabla desde Postgres.
|
|
579
|
+
"""
|
|
580
|
+
with conexion.cursor(row_factory=dict_row) as cursor:
|
|
581
|
+
cursor.execute(
|
|
582
|
+
"""
|
|
583
|
+
SELECT
|
|
584
|
+
kcu.constraint_name, kcu.column_name,
|
|
585
|
+
ccu.table_name AS referenced_table_name,
|
|
586
|
+
ccu.column_name AS referenced_column_name,
|
|
587
|
+
rc.update_rule, rc.delete_rule
|
|
588
|
+
FROM information_schema.key_column_usage kcu
|
|
589
|
+
JOIN information_schema.referential_constraints rc
|
|
590
|
+
ON kcu.constraint_name = rc.constraint_name
|
|
591
|
+
AND kcu.constraint_schema = rc.constraint_schema
|
|
592
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
593
|
+
ON ccu.constraint_name = rc.unique_constraint_name
|
|
594
|
+
AND ccu.constraint_schema = rc.unique_constraint_schema
|
|
595
|
+
WHERE kcu.table_schema = 'public' AND kcu.table_name = %s
|
|
596
|
+
""",
|
|
597
|
+
(self.nombre,)
|
|
598
|
+
)
|
|
599
|
+
filas = cursor.fetchall()
|
|
600
|
+
|
|
601
|
+
resultado = []
|
|
602
|
+
for fila in filas:
|
|
603
|
+
fk = ForeignKey(
|
|
604
|
+
columna=fila["column_name"],
|
|
605
|
+
referencia_tabla=fila["referenced_table_name"],
|
|
606
|
+
referencia_columna=fila["referenced_column_name"],
|
|
607
|
+
nombre=fila["constraint_name"],
|
|
608
|
+
on_update=fila["update_rule"],
|
|
609
|
+
on_delete=fila["delete_rule"]
|
|
610
|
+
)
|
|
611
|
+
resultado.append(fk)
|
|
612
|
+
|
|
613
|
+
return resultado
|
|
614
|
+
|
|
615
|
+
def comparar_generar_alter(self, conexion, dbname=None, permitir_drop=False) -> List[str]:
|
|
616
|
+
"""
|
|
617
|
+
Compara la definición de esta tabla en los modelos contra su estado real en Postgres
|
|
618
|
+
y genera las sentencias necesarias para sincronizarla: ALTER TABLE (columnas nuevas o
|
|
619
|
+
modificadas mediante ALTER COLUMN, ya que Postgres no tiene MODIFY COLUMN), sentencias
|
|
620
|
+
CREATE/DROP INDEX sueltas (no van dentro de ALTER TABLE) y ADD/DROP CONSTRAINT para FKs.
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
- `conexion`: Conexión psycopg abierta.
|
|
624
|
+
- `dbname`: (Opcional) Nombre de la base de datos, si se deja vacío se toma de
|
|
625
|
+
self.database.db emparentado a la tabla.
|
|
626
|
+
- `permitir_drop`: Si se permiten DROP INDEX y DROP CONSTRAINT (FK).
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
List[str]: Sentencias SQL a ejecutar, en un orden seguro.
|
|
630
|
+
"""
|
|
631
|
+
|
|
632
|
+
if not dbname:
|
|
633
|
+
if not self.database or not hasattr(self.database, 'db'):
|
|
634
|
+
raise ValueError("No se ha especificado el nombre de la base de datos y la tabla no está asociada a Database.")
|
|
635
|
+
dbname = self.database.db
|
|
636
|
+
|
|
637
|
+
col_alteraciones = []
|
|
638
|
+
idx_adds = []
|
|
639
|
+
idx_drops = []
|
|
640
|
+
fk_adds = []
|
|
641
|
+
fk_drops = []
|
|
642
|
+
|
|
643
|
+
reales = {col.nombre: col for col in self.obtener_columnas_postgres(conexion, dbname)}
|
|
644
|
+
definidas = {col.nombre: col for col in self.columnas}
|
|
645
|
+
|
|
646
|
+
def norm(val):
|
|
647
|
+
if val is None:
|
|
648
|
+
return None
|
|
649
|
+
texto = str(val).strip().lower()
|
|
650
|
+
texto = re.sub(r"::\w+(\[\])?$", "", texto)
|
|
651
|
+
return texto.replace("'", "").replace("()", "").strip()
|
|
652
|
+
|
|
653
|
+
def default_eq(real, definido):
|
|
654
|
+
if isinstance(definido, SQLLiteral):
|
|
655
|
+
definido = definido.valor
|
|
656
|
+
val_real = norm(real)
|
|
657
|
+
val_def = norm(definido)
|
|
658
|
+
null_equivalentes = {None, "null"}
|
|
659
|
+
if val_real in null_equivalentes and val_def in null_equivalentes:
|
|
660
|
+
return True
|
|
661
|
+
equivalentes = {("now", "current_timestamp")}
|
|
662
|
+
return (val_real, val_def) in equivalentes or (val_def, val_real) in equivalentes or val_real == val_def
|
|
663
|
+
|
|
664
|
+
def tipos_iguales(col_real, col_def):
|
|
665
|
+
return (
|
|
666
|
+
norm(col_real.tipo) == norm(col_def.tipo) and
|
|
667
|
+
col_real.longitud == col_def.longitud and
|
|
668
|
+
col_real.precision == col_def.precision and
|
|
669
|
+
(col_real.escala or None) == (col_def.escala or None)
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
def iguales(col_real, col_def):
|
|
673
|
+
return (
|
|
674
|
+
tipos_iguales(col_real, col_def) and
|
|
675
|
+
(col_real.not_null == col_def.not_null or col_def.primary_key) and
|
|
676
|
+
default_eq(col_real.default, col_def.default) and
|
|
677
|
+
bool(col_real.auto_increment) == bool(col_def.auto_increment) and
|
|
678
|
+
bool(col_real.primary_key) == bool(col_def.primary_key) and
|
|
679
|
+
bool(col_real.unique) == bool(col_def.unique) and
|
|
680
|
+
set(col_real.enum_opciones or []) == set(col_def.enum_opciones or [])
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# --- COLUMNAS ---
|
|
684
|
+
for nombre, col_def in definidas.items():
|
|
685
|
+
col_real = reales.get(nombre)
|
|
686
|
+
if not col_real:
|
|
687
|
+
col_alteraciones.append(f"ADD COLUMN {col_def.to_sql()}")
|
|
688
|
+
elif not iguales(col_real, col_def):
|
|
689
|
+
if not tipos_iguales(col_real, col_def):
|
|
690
|
+
tipo_sql = col_def._tipo_sql()
|
|
691
|
+
col_alteraciones.append(f'ALTER COLUMN "{nombre}" TYPE {tipo_sql} USING "{nombre}"::{tipo_sql}')
|
|
692
|
+
if bool(col_real.not_null) != bool(col_def.not_null) and not col_def.primary_key:
|
|
693
|
+
col_alteraciones.append(f'ALTER COLUMN "{nombre}" {"SET" if col_def.not_null else "DROP"} NOT NULL')
|
|
694
|
+
if not default_eq(col_real.default, col_def.default):
|
|
695
|
+
if col_def.default is None:
|
|
696
|
+
col_alteraciones.append(f'ALTER COLUMN "{nombre}" DROP DEFAULT')
|
|
697
|
+
elif isinstance(col_def.default, SQLLiteral):
|
|
698
|
+
col_alteraciones.append(f'ALTER COLUMN "{nombre}" SET DEFAULT {col_def.default}')
|
|
699
|
+
elif isinstance(col_def.default, str) and not col_def.default.upper().startswith("CURRENT_TIMESTAMP"):
|
|
700
|
+
col_alteraciones.append(f"ALTER COLUMN \"{nombre}\" SET DEFAULT '{col_def.default}'")
|
|
701
|
+
else:
|
|
702
|
+
col_alteraciones.append(f'ALTER COLUMN "{nombre}" SET DEFAULT {col_def.default}')
|
|
703
|
+
|
|
704
|
+
# --- ÍNDICES ---
|
|
705
|
+
reales_idx = {tuple(i.columnas): i for i in self.obtener_indices_postgres(conexion, dbname)}
|
|
706
|
+
definidos_idx = {tuple(i.columnas): i for i in self.indices}
|
|
707
|
+
|
|
708
|
+
for clave, idx_def in definidos_idx.items():
|
|
709
|
+
if clave not in reales_idx:
|
|
710
|
+
idx_adds.append(f"{idx_def.to_sql()};")
|
|
711
|
+
|
|
712
|
+
if permitir_drop:
|
|
713
|
+
for clave, idx_real in reales_idx.items():
|
|
714
|
+
if clave not in definidos_idx:
|
|
715
|
+
# Evitar eliminar índices implícitos de FKs
|
|
716
|
+
if any(
|
|
717
|
+
fk.columna in idx_real.columnas and fk.nombre == idx_real.nombre
|
|
718
|
+
for fk in self.foreign_keys
|
|
719
|
+
):
|
|
720
|
+
continue
|
|
721
|
+
idx_drops.append(f'DROP INDEX IF EXISTS "{idx_real.nombre}";')
|
|
722
|
+
|
|
723
|
+
# --- CLAVES FORÁNEAS ---
|
|
724
|
+
reales_fk_set = {
|
|
725
|
+
(fk.columna, fk.referencia_tabla, fk.referencia_columna): fk
|
|
726
|
+
for fk in self.obtener_fks_postgres(conexion, dbname)
|
|
727
|
+
}
|
|
728
|
+
definidos_fk_set = {
|
|
729
|
+
(fk.columna, fk.referencia_tabla, fk.referencia_columna): fk
|
|
730
|
+
for fk in self.foreign_keys
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
for clave, fk_def in definidos_fk_set.items():
|
|
734
|
+
if clave not in reales_fk_set:
|
|
735
|
+
fk_adds.append(f"ADD {fk_def.to_sql()}")
|
|
736
|
+
|
|
737
|
+
if permitir_drop:
|
|
738
|
+
for clave, fk_real in reales_fk_set.items():
|
|
739
|
+
if clave not in definidos_fk_set:
|
|
740
|
+
fk_drops.append(f'DROP CONSTRAINT "{fk_real.nombre}"')
|
|
741
|
+
|
|
742
|
+
# Orden seguro: columnas -> drop FK -> drop índices -> add índices -> add FK
|
|
743
|
+
# (los índices son sentencias independientes en Postgres, no cláusulas de ALTER TABLE)
|
|
744
|
+
resultado = []
|
|
745
|
+
if col_alteraciones:
|
|
746
|
+
resultado.append(f'ALTER TABLE "{self.nombre}"\n ' + ",\n ".join(col_alteraciones) + ";")
|
|
747
|
+
if fk_drops:
|
|
748
|
+
resultado.append(f'ALTER TABLE "{self.nombre}"\n ' + ",\n ".join(fk_drops) + ";")
|
|
749
|
+
resultado.extend(idx_drops)
|
|
750
|
+
resultado.extend(idx_adds)
|
|
751
|
+
if fk_adds:
|
|
752
|
+
resultado.append(f'ALTER TABLE "{self.nombre}"\n ' + ",\n ".join(fk_adds) + ";")
|
|
753
|
+
return resultado
|
|
754
|
+
|
|
755
|
+
def extraer_estructura(self, db_name: str) -> list[tuple[str, str, str, str | None, str]]:
|
|
756
|
+
"""
|
|
757
|
+
Devuelve la estructura de la tabla, según definiciones, como una lista de tuplas:
|
|
758
|
+
(db_name, tabla, tipo, nombre, definicion)
|
|
759
|
+
"""
|
|
760
|
+
est = [(db_name, self.nombre, 'tabla', None, self.to_sql())]
|
|
761
|
+
|
|
762
|
+
for col in self.columnas:
|
|
763
|
+
est.append((db_name, self.nombre, 'columna', col.nombre, col.to_sql()))
|
|
764
|
+
|
|
765
|
+
for idx in self.indices:
|
|
766
|
+
est.append((db_name, self.nombre, 'indice', idx.nombre, idx.to_sql()))
|
|
767
|
+
|
|
768
|
+
for fk in self.foreign_keys:
|
|
769
|
+
est.append((db_name, self.nombre, 'fk', fk.nombre, fk.to_sql()))
|
|
770
|
+
|
|
771
|
+
return est
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
class Database:
|
|
775
|
+
def __init__(self, host: str, user: str, password: str, db: str, port: int = 5432, logger: Optional[logging.Logger] = None):
|
|
776
|
+
"""clase Database que:
|
|
777
|
+
Almacena los parámetros de conexión.
|
|
778
|
+
Mantiene una lista (o diccionario) de objetos Tabla.
|
|
779
|
+
Genere una única conexión compartida (con psycopg.connect()).
|
|
780
|
+
Permita añadir tablas y reutilizar esa conexión para leer o sincronizar.
|
|
781
|
+
Args:
|
|
782
|
+
- `host`: Host de la base de datos.
|
|
783
|
+
- `user`: Usuario de la base de datos.
|
|
784
|
+
- `password`: Contraseña del usuario.
|
|
785
|
+
- `db`: Nombre de la base de datos.
|
|
786
|
+
- `port` (int): Puerto de conexión (default 5432).
|
|
787
|
+
- `logger`: (opcional) Instancia de logger para registrar eventos .
|
|
788
|
+
"""
|
|
789
|
+
self.host = host
|
|
790
|
+
self.user = user
|
|
791
|
+
self.password = password
|
|
792
|
+
self.db = db
|
|
793
|
+
self.port = port
|
|
794
|
+
self.tablas: Dict[str, Tabla] = {}
|
|
795
|
+
self._conexion = None # connection
|
|
796
|
+
self.logger = logger # logger
|
|
797
|
+
self.ultimo_insert_id: Optional[int] = None
|
|
798
|
+
|
|
799
|
+
@contextmanager
|
|
800
|
+
def transaccion(self):
|
|
801
|
+
"""
|
|
802
|
+
Context manager para ejecutar múltiples operaciones dentro de una transacción (modo síncrono).
|
|
803
|
+
"""
|
|
804
|
+
conn = self._conexion
|
|
805
|
+
try:
|
|
806
|
+
yield conn
|
|
807
|
+
conn.commit()
|
|
808
|
+
except Exception as e:
|
|
809
|
+
conn.rollback()
|
|
810
|
+
raise e
|
|
811
|
+
|
|
812
|
+
def conectar(self, autocommit=False):
|
|
813
|
+
"""
|
|
814
|
+
Abre una única conexión reutilizable.
|
|
815
|
+
Args:
|
|
816
|
+
- `autocommit`: Si se debe activar el autocommit (por defecto False, las transacciones se controlan manualmente).
|
|
817
|
+
returns:
|
|
818
|
+
- `conexion`: Conexión psycopg abierta.
|
|
819
|
+
"""
|
|
820
|
+
if self._conexion is None or self._conexion.closed:
|
|
821
|
+
self._conexion = psycopg.connect(
|
|
822
|
+
host=self.host,
|
|
823
|
+
user=self.user,
|
|
824
|
+
password=self.password,
|
|
825
|
+
dbname=self.db,
|
|
826
|
+
port=self.port,
|
|
827
|
+
autocommit=autocommit,
|
|
828
|
+
)
|
|
829
|
+
return self._conexion
|
|
830
|
+
|
|
831
|
+
def cerrar(self):
|
|
832
|
+
"""cierra la conexión si está abierta. No interviene en la transacción activa."""
|
|
833
|
+
if self._conexion and not self._conexion.closed:
|
|
834
|
+
self._conexion.close()
|
|
835
|
+
|
|
836
|
+
def commit(self):
|
|
837
|
+
"""
|
|
838
|
+
Confirma la transacción activa si existe y cierra la conexión.
|
|
839
|
+
Si no hay conexión abierta o está en autocommit, no hace nada salvo cerrar.
|
|
840
|
+
"""
|
|
841
|
+
if self._conexion and not self._conexion.closed:
|
|
842
|
+
if not self._conexion.autocommit:
|
|
843
|
+
try:
|
|
844
|
+
self._conexion.commit()
|
|
845
|
+
except Exception as e:
|
|
846
|
+
raise RuntimeError(f"Error al hacer commit: {e}") from e
|
|
847
|
+
self.cerrar()
|
|
848
|
+
|
|
849
|
+
def rollback(self):
|
|
850
|
+
"""
|
|
851
|
+
Revierte la transacción activa si existe y cierra la conexión.
|
|
852
|
+
Si no hay conexión abierta o está en autocommit, no hace nada salvo cerrar.
|
|
853
|
+
"""
|
|
854
|
+
if self._conexion and not self._conexion.closed:
|
|
855
|
+
if not self._conexion.autocommit:
|
|
856
|
+
try:
|
|
857
|
+
self._conexion.rollback()
|
|
858
|
+
except Exception as e:
|
|
859
|
+
raise RuntimeError(f"Error al hacer rollback: {e}") from e
|
|
860
|
+
self.cerrar()
|
|
861
|
+
|
|
862
|
+
def add_tabla(self, tabla: Tabla):
|
|
863
|
+
"""
|
|
864
|
+
Añade un objeto Tabla al conjunto de esta base de datos.
|
|
865
|
+
Args:
|
|
866
|
+
- `tabla`: Objeto Tabla a añadir.
|
|
867
|
+
"""
|
|
868
|
+
self.tablas[tabla.nombre] = tabla
|
|
869
|
+
tabla.set_database(self) # Establece la referencia a la base de datos
|
|
870
|
+
|
|
871
|
+
def get_tabla(self, nombre: str) -> Tabla:
|
|
872
|
+
"""
|
|
873
|
+
Devuelve la tabla del nombre indicado. Si nombre no existe lanza un KeyError.
|
|
874
|
+
Args:
|
|
875
|
+
- `nombre`: Nombre de la tabla a devolver.
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
if nombre not in self.tablas:
|
|
879
|
+
raise KeyError(f"La tabla '{nombre}' no está registrada en esta base de datos.")
|
|
880
|
+
return self.tablas[nombre]
|
|
881
|
+
|
|
882
|
+
def _query_select(self, sql: str, params=None, conexion=None, uno: bool = False) -> List[Dict]:
|
|
883
|
+
"""
|
|
884
|
+
Ejecuta una consulta SELECT y devuelve los registros como lista de diccionarios.
|
|
885
|
+
|
|
886
|
+
Args:
|
|
887
|
+
- `sql` (str): Consulta SQL.
|
|
888
|
+
- `params` (tuple or dict, optional): Parámetros para el SQL.
|
|
889
|
+
- `conexion` (psycopg.Connection, optional): Conexión externa abierta. Si no se indica, se abre una.
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
List[Dict]: Lista de registros obtenidos.
|
|
893
|
+
"""
|
|
894
|
+
propia = False
|
|
895
|
+
if conexion is None:
|
|
896
|
+
conexion = self.conectar()
|
|
897
|
+
propia = True
|
|
898
|
+
|
|
899
|
+
try:
|
|
900
|
+
with conexion.cursor(row_factory=dict_row) as cursor:
|
|
901
|
+
cursor.execute(sql, params)
|
|
902
|
+
if uno:
|
|
903
|
+
return cursor.fetchone()
|
|
904
|
+
return cursor.fetchall()
|
|
905
|
+
finally:
|
|
906
|
+
if propia:
|
|
907
|
+
self.cerrar()
|
|
908
|
+
|
|
909
|
+
def _query_accion(self, sql: str, params=None, conexion=None) -> int:
|
|
910
|
+
"""
|
|
911
|
+
Ejecuta una acción tipo INSERT, UPDATE, DELETE.
|
|
912
|
+
|
|
913
|
+
Postgres no tiene `lastrowid`: si la sentencia incluye una cláusula RETURNING,
|
|
914
|
+
el primer valor de la primera fila devuelta se guarda en `self.ultimo_insert_id`.
|
|
915
|
+
|
|
916
|
+
- Args:
|
|
917
|
+
- `sql` - (str): Sentencia SQL.
|
|
918
|
+
- `params` - (tuple or dict, optional): Parámetros para la consulta.
|
|
919
|
+
- `conexion` - (psycopg.Connection, optional): Conexión externa abierta. Si no se indica, se abre una.
|
|
920
|
+
|
|
921
|
+
Returns:
|
|
922
|
+
int: Número de filas afectadas.
|
|
923
|
+
"""
|
|
924
|
+
propia = False
|
|
925
|
+
if conexion is None:
|
|
926
|
+
conexion = self.conectar()
|
|
927
|
+
propia = True
|
|
928
|
+
try:
|
|
929
|
+
with conexion.cursor() as cursor:
|
|
930
|
+
cursor.execute(sql, params)
|
|
931
|
+
filas = cursor.rowcount
|
|
932
|
+
if cursor.description:
|
|
933
|
+
fila = cursor.fetchone()
|
|
934
|
+
if fila:
|
|
935
|
+
self.ultimo_insert_id = fila[0]
|
|
936
|
+
if propia:
|
|
937
|
+
conexion.commit()
|
|
938
|
+
return filas
|
|
939
|
+
except Exception as e:
|
|
940
|
+
if propia:
|
|
941
|
+
conexion.rollback()
|
|
942
|
+
if self.logger:
|
|
943
|
+
self.logger.error(f"Error al ejecutar acción: {e}")
|
|
944
|
+
raise
|
|
945
|
+
finally:
|
|
946
|
+
if propia:
|
|
947
|
+
self.cerrar()
|
|
948
|
+
|
|
949
|
+
def query(self, sql: str, params=None, conexion=None, uno: bool = False):
|
|
950
|
+
"""
|
|
951
|
+
Ejecuta automáticamente una consulta SQL detectando si es de lectura (SELECT)
|
|
952
|
+
o de acción (INSERT, UPDATE, DELETE).
|
|
953
|
+
|
|
954
|
+
- Args:
|
|
955
|
+
- `sql` - (str): Sentencia SQL.
|
|
956
|
+
- `params` - (tuple or dict, optional): Parámetros para la consulta.
|
|
957
|
+
- `conexion` - (psycopg.Connection, optional): Conexión externa abierta. Si no se indica, se abre una.
|
|
958
|
+
- `uno` - (bool): Si True, se espera un único resultado (SELECT).
|
|
959
|
+
|
|
960
|
+
Returns:
|
|
961
|
+
Resultado de la operación:
|
|
962
|
+
- List[Dict] si es SELECT, SHOW, EXPLAIN, etc.
|
|
963
|
+
- int (nº de filas afectadas) si es INSERT/UPDATE/DELETE
|
|
964
|
+
"""
|
|
965
|
+
sql_limpia = sql.strip().lstrip("/*- ").upper()
|
|
966
|
+
comando = sql_limpia.split()[0]
|
|
967
|
+
|
|
968
|
+
if comando in {"SELECT", "SHOW", "EXPLAIN"}:
|
|
969
|
+
return self._query_select(sql, params=params, conexion=conexion, uno=uno)
|
|
970
|
+
else:
|
|
971
|
+
return self._query_accion(sql, params=params, conexion=conexion)
|
|
972
|
+
|
|
973
|
+
def call_proc(self, nombre: str, parametros: tuple, conexion=None, uno: bool = True) -> Optional[Union[dict, list[dict]]]:
|
|
974
|
+
"""
|
|
975
|
+
Llama a un procedimiento almacenado y devuelve los resultados si existen.
|
|
976
|
+
|
|
977
|
+
- Args:
|
|
978
|
+
- `nombre` (str): Nombre del procedimiento.
|
|
979
|
+
- `parametros` (tuple): Parámetros a pasar.
|
|
980
|
+
- `conexion` (psycopg.Connection, opcional): Conexión externa si se gestiona desde fuera.
|
|
981
|
+
- `uno` (bool): Si True, devuelve una fila (dict); si False, todas las filas (list[dict]).
|
|
982
|
+
|
|
983
|
+
- Returns:
|
|
984
|
+
dict | list[dict] | None: Resultado del procedimiento si devuelve SELECT. None si no hay resultado.
|
|
985
|
+
"""
|
|
986
|
+
propia = False
|
|
987
|
+
if conexion is None:
|
|
988
|
+
conexion = self.conectar()
|
|
989
|
+
propia = True
|
|
990
|
+
|
|
991
|
+
try:
|
|
992
|
+
placeholders = ', '.join(['%s'] * len(parametros))
|
|
993
|
+
sql = f"CALL {nombre}({placeholders})"
|
|
994
|
+
with conexion.cursor(row_factory=dict_row) as cursor:
|
|
995
|
+
cursor.execute(sql, parametros)
|
|
996
|
+
try:
|
|
997
|
+
return cursor.fetchone() if uno else cursor.fetchall()
|
|
998
|
+
except Exception:
|
|
999
|
+
return None
|
|
1000
|
+
except Exception as e:
|
|
1001
|
+
if propia:
|
|
1002
|
+
conexion.rollback()
|
|
1003
|
+
if self.logger:
|
|
1004
|
+
self.logger.error(f"Error en call_proc('{nombre}'): {e}")
|
|
1005
|
+
raise
|
|
1006
|
+
finally:
|
|
1007
|
+
if propia:
|
|
1008
|
+
self.cerrar()
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
class ManagerDB(Database):
|
|
1012
|
+
"""
|
|
1013
|
+
Extensión de la clase Database que implementa lógica avanzada de verificación,
|
|
1014
|
+
simulación y aplicación de cambios estructurales, comparando contra la tabla 'estructura_actual'.
|
|
1015
|
+
"""
|
|
1016
|
+
|
|
1017
|
+
def crear_tablas_si_no_existen(self) -> dict:
|
|
1018
|
+
"""
|
|
1019
|
+
Crea las tablas registradas en la base de datos usando CREATE TABLE IF NOT EXISTS,
|
|
1020
|
+
seguido de sus índices y comentarios. No aplica ALTERs.
|
|
1021
|
+
|
|
1022
|
+
Returns:
|
|
1023
|
+
dict: Diccionario con:
|
|
1024
|
+
- clave: nombre de la tabla
|
|
1025
|
+
- valor: lista de sentencias SQL ejecutadas
|
|
1026
|
+
"""
|
|
1027
|
+
resultados = {}
|
|
1028
|
+
try:
|
|
1029
|
+
conexion = self.conectar()
|
|
1030
|
+
with conexion.cursor() as cursor:
|
|
1031
|
+
for nombre_tabla, tabla in self.tablas.items():
|
|
1032
|
+
sentencias = [tabla.to_sql()] + tabla.to_sql_indices() + tabla.to_sql_comentarios()
|
|
1033
|
+
for sentencia in sentencias:
|
|
1034
|
+
cursor.execute(sentencia)
|
|
1035
|
+
resultados[nombre_tabla] = sentencias
|
|
1036
|
+
if self.logger:
|
|
1037
|
+
self.logger.info(f"Intento crear \"{nombre_tabla}\".")
|
|
1038
|
+
# Postgres no autocommitea DDL como MariaDB: hay que confirmar explícitamente.
|
|
1039
|
+
conexion.commit()
|
|
1040
|
+
except Exception as e:
|
|
1041
|
+
if self.logger:
|
|
1042
|
+
self.logger.error(f"Error al crear tablas: {e}")
|
|
1043
|
+
raise RuntimeError(f"Error al crear tablas: {e}") from e
|
|
1044
|
+
finally:
|
|
1045
|
+
self.cerrar()
|
|
1046
|
+
|
|
1047
|
+
return resultados
|
|
1048
|
+
|
|
1049
|
+
def simular_cambios(self, permitir_drop=False) -> dict:
|
|
1050
|
+
"""
|
|
1051
|
+
Simula los cambios que aplicarían ALTER TABLE o CREATE TABLE (no ejecuta nada).
|
|
1052
|
+
Devuelve un diccionario con las sentencias que se generarían por tabla.
|
|
1053
|
+
"""
|
|
1054
|
+
self.tablas = {t.nombre: t for t in self._ordenar_tablas_por_dependencias()}
|
|
1055
|
+
resultados = {}
|
|
1056
|
+
try:
|
|
1057
|
+
conexion = self.conectar()
|
|
1058
|
+
cursor = conexion.cursor()
|
|
1059
|
+
|
|
1060
|
+
for nombre_tabla, tabla in self.tablas.items():
|
|
1061
|
+
cursor.execute(
|
|
1062
|
+
"SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = %s",
|
|
1063
|
+
(nombre_tabla,)
|
|
1064
|
+
)
|
|
1065
|
+
existe = cursor.fetchone() is not None
|
|
1066
|
+
|
|
1067
|
+
if not existe:
|
|
1068
|
+
resultados[nombre_tabla] = [tabla.to_sql()] + tabla.to_sql_indices() + tabla.to_sql_comentarios()
|
|
1069
|
+
else:
|
|
1070
|
+
alter = tabla.comparar_generar_alter(conexion, permitir_drop=permitir_drop)
|
|
1071
|
+
resultados[nombre_tabla] = alter if alter else []
|
|
1072
|
+
|
|
1073
|
+
except Exception:
|
|
1074
|
+
resultados["error"] = traceback.format_exc()
|
|
1075
|
+
if self.logger:
|
|
1076
|
+
self.logger.error("Error al simular cambios:\n" + resultados["error"])
|
|
1077
|
+
finally:
|
|
1078
|
+
self.cerrar()
|
|
1079
|
+
|
|
1080
|
+
return resultados
|
|
1081
|
+
|
|
1082
|
+
def aplicar_cambios(self, permitir_drop: bool = True) -> dict:
|
|
1083
|
+
"""
|
|
1084
|
+
Aplica todos los cambios estructurales en la base de datos:
|
|
1085
|
+
- Crea tablas que no existen (con CREATE TABLE IF NOT EXISTS + índices + comentarios)
|
|
1086
|
+
- Aplica ALTERs para sincronizar columnas, índices y claves foráneas
|
|
1087
|
+
- Registra cada cambio aplicado en la tabla _migraciones
|
|
1088
|
+
|
|
1089
|
+
Todo dentro de una única transacción. Si ocurre un error, se revierte todo.
|
|
1090
|
+
|
|
1091
|
+
Args:
|
|
1092
|
+
- `permitir_drop` (bool): Si se permiten DROP INDEX y DROP CONSTRAINT (FK).
|
|
1093
|
+
|
|
1094
|
+
Returns:
|
|
1095
|
+
dict: Resultados por tabla (sentencias ejecutadas o errores).
|
|
1096
|
+
"""
|
|
1097
|
+
self.tablas = {t.nombre: t for t in self._ordenar_tablas_por_dependencias()}
|
|
1098
|
+
resultados = {}
|
|
1099
|
+
try:
|
|
1100
|
+
conexion = self.conectar(autocommit=False)
|
|
1101
|
+
with conexion.cursor() as cursor:
|
|
1102
|
+
# Asegurar existencia de la tabla _migraciones
|
|
1103
|
+
cursor.execute("""
|
|
1104
|
+
CREATE TABLE IF NOT EXISTS _migraciones (
|
|
1105
|
+
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
1106
|
+
tabla VARCHAR(100),
|
|
1107
|
+
sentencia TEXT,
|
|
1108
|
+
fecha TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
1109
|
+
)
|
|
1110
|
+
""")
|
|
1111
|
+
|
|
1112
|
+
# --- Crear tablas nuevas (+ índices + comentarios) ---
|
|
1113
|
+
for nombre_tabla, tabla in self.tablas.items():
|
|
1114
|
+
sentencias_creacion = [tabla.to_sql()] + tabla.to_sql_indices() + tabla.to_sql_comentarios()
|
|
1115
|
+
resultados[nombre_tabla] = []
|
|
1116
|
+
for sql_creacion in sentencias_creacion:
|
|
1117
|
+
cursor.execute(sql_creacion)
|
|
1118
|
+
self._registrar_migracion(conexion, nombre_tabla, sql_creacion)
|
|
1119
|
+
resultados[nombre_tabla].append(sql_creacion)
|
|
1120
|
+
if self.logger:
|
|
1121
|
+
self.logger.info(f"Tabla \"{nombre_tabla}\": ejecutado CREATE IF NOT EXISTS.")
|
|
1122
|
+
|
|
1123
|
+
# --- Aplicar ALTERs ---
|
|
1124
|
+
for nombre_tabla, tabla in self.tablas.items():
|
|
1125
|
+
alter_sqls = tabla.comparar_generar_alter(conexion, permitir_drop=permitir_drop)
|
|
1126
|
+
for alter_sql in alter_sqls:
|
|
1127
|
+
cursor.execute(alter_sql)
|
|
1128
|
+
self._registrar_migracion(conexion, nombre_tabla, alter_sql)
|
|
1129
|
+
resultados[nombre_tabla].append(alter_sql)
|
|
1130
|
+
if self.logger:
|
|
1131
|
+
self.logger.info(f"Tabla \"{nombre_tabla}\": ejecutado -> {alter_sql}")
|
|
1132
|
+
|
|
1133
|
+
conexion.commit()
|
|
1134
|
+
if self.logger:
|
|
1135
|
+
self.logger.info("Todos los cambios aplicados y confirmados.")
|
|
1136
|
+
|
|
1137
|
+
except Exception:
|
|
1138
|
+
self.rollback()
|
|
1139
|
+
resultados["error"] = traceback.format_exc()
|
|
1140
|
+
if self.logger:
|
|
1141
|
+
self.logger.error("Error durante aplicar_cambios():\n" + resultados["error"])
|
|
1142
|
+
finally:
|
|
1143
|
+
self.cerrar()
|
|
1144
|
+
|
|
1145
|
+
return resultados
|
|
1146
|
+
|
|
1147
|
+
def _registrar_migracion(self, conexion, tabla: str, sentencia: str):
|
|
1148
|
+
"""
|
|
1149
|
+
Registra una sentencia estructural (CREATE, ALTER, CREATE INDEX, COMMENT...) aplicada
|
|
1150
|
+
a una tabla en el historial de migraciones.
|
|
1151
|
+
|
|
1152
|
+
Crea automáticamente la tabla `_migraciones` si no existe. Antes de registrar, verifica que la sentencia
|
|
1153
|
+
no haya sido previamente aplicada para evitar duplicados. Se utiliza una combinación de `tabla + sentencia`
|
|
1154
|
+
como criterio de unicidad.
|
|
1155
|
+
|
|
1156
|
+
Este método está pensado para ser llamado automáticamente desde `aplicar_cambios()` tras ejecutar cada cambio.
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
- `conexion` (psycopg.Connection): Conexión activa a la base de datos.
|
|
1160
|
+
- `tabla` (str): Nombre de la tabla afectada por la sentencia.
|
|
1161
|
+
- `sentencia` (str): Sentencia SQL completa que ha sido ejecutada.
|
|
1162
|
+
"""
|
|
1163
|
+
with conexion.cursor() as cursor:
|
|
1164
|
+
cursor.execute("""
|
|
1165
|
+
CREATE TABLE IF NOT EXISTS _migraciones (
|
|
1166
|
+
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
1167
|
+
tabla VARCHAR(100),
|
|
1168
|
+
sentencia TEXT,
|
|
1169
|
+
fecha TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
1170
|
+
)
|
|
1171
|
+
""")
|
|
1172
|
+
cursor.execute("""
|
|
1173
|
+
SELECT 1 FROM _migraciones
|
|
1174
|
+
WHERE tabla = %s AND sentencia = %s
|
|
1175
|
+
""", (tabla, sentencia))
|
|
1176
|
+
if not cursor.fetchone():
|
|
1177
|
+
cursor.execute("""
|
|
1178
|
+
INSERT INTO _migraciones (tabla, sentencia)
|
|
1179
|
+
VALUES (%s, %s)
|
|
1180
|
+
""", (tabla, sentencia))
|
|
1181
|
+
|
|
1182
|
+
def verificar_contra_estructura_actual(self) -> dict[str, list[str]]:
|
|
1183
|
+
"""
|
|
1184
|
+
Compara la estructura definida en los modelos locales con la registrada previamente
|
|
1185
|
+
en la tabla 'estructura_actual'.
|
|
1186
|
+
|
|
1187
|
+
Returns:
|
|
1188
|
+
dict[str, list[str]]: Diccionario con diferencias detectadas, agrupadas por tabla.
|
|
1189
|
+
"""
|
|
1190
|
+
divergencias: dict[str, list[str]] = {}
|
|
1191
|
+
conn = self.conectar()
|
|
1192
|
+
|
|
1193
|
+
try:
|
|
1194
|
+
with conn.cursor(row_factory=dict_row) as cur:
|
|
1195
|
+
for tabla in self.tablas.values():
|
|
1196
|
+
cur.execute("""
|
|
1197
|
+
SELECT tipo, nombre, definicion FROM estructura_actual
|
|
1198
|
+
WHERE db_name = %s AND tabla = %s
|
|
1199
|
+
""", (self.db, tabla.nombre))
|
|
1200
|
+
registros_previos = cur.fetchall()
|
|
1201
|
+
|
|
1202
|
+
prev_dict = {
|
|
1203
|
+
(r['tipo'], r['nombre']): r['definicion'].strip() if r['definicion'] else ""
|
|
1204
|
+
for r in registros_previos
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
actual_dict = {('tabla', None): tabla.to_sql().strip()}
|
|
1208
|
+
|
|
1209
|
+
for col in tabla.columnas:
|
|
1210
|
+
actual_dict[('columna', col.nombre)] = col.to_sql().strip()
|
|
1211
|
+
|
|
1212
|
+
for idx in tabla.indices:
|
|
1213
|
+
nombre_idx = idx.nombre or "_".join(idx.columnas)
|
|
1214
|
+
actual_dict[('indice', nombre_idx)] = idx.to_sql().strip()
|
|
1215
|
+
|
|
1216
|
+
for fk in tabla.foreign_keys:
|
|
1217
|
+
nombre_fk = fk.nombre or f"fk_{tabla.nombre}_{fk.columna}"
|
|
1218
|
+
actual_dict[('fk', nombre_fk)] = fk.to_sql().strip()
|
|
1219
|
+
|
|
1220
|
+
for clave, nueva_def in actual_dict.items():
|
|
1221
|
+
antigua_def = prev_dict.get(clave)
|
|
1222
|
+
if antigua_def is not None and nueva_def != antigua_def:
|
|
1223
|
+
tipo, nombre = clave
|
|
1224
|
+
nombre_txt = f"'{nombre}'" if nombre else "(sin nombre)"
|
|
1225
|
+
divergencias.setdefault(tabla.nombre, []).append(
|
|
1226
|
+
f"Cambio en {tipo} {nombre_txt}:\n - Antes: {antigua_def}\n - Ahora: {nueva_def}"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
for clave in prev_dict:
|
|
1230
|
+
if clave not in actual_dict:
|
|
1231
|
+
tipo, nombre = clave
|
|
1232
|
+
if tipo == "columna":
|
|
1233
|
+
continue
|
|
1234
|
+
if tipo == "tabla" and nombre is None:
|
|
1235
|
+
continue
|
|
1236
|
+
nombre_txt = f"'{nombre}'" if nombre else "(sin nombre)"
|
|
1237
|
+
divergencias.setdefault(tabla.nombre, []).append(
|
|
1238
|
+
f"{tipo.capitalize()} {nombre_txt} eliminado respecto a estructura registrada."
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
for clave in actual_dict:
|
|
1242
|
+
if clave not in prev_dict:
|
|
1243
|
+
tipo, nombre = clave
|
|
1244
|
+
nombre_txt = f"'{nombre}'" if nombre else "(sin nombre)"
|
|
1245
|
+
divergencias.setdefault(tabla.nombre, []).append(
|
|
1246
|
+
f"{tipo.capitalize()} {nombre_txt} añadido."
|
|
1247
|
+
)
|
|
1248
|
+
finally:
|
|
1249
|
+
self.cerrar()
|
|
1250
|
+
|
|
1251
|
+
return divergencias
|
|
1252
|
+
|
|
1253
|
+
def _ordenar_tablas_por_dependencias(self) -> List[Tabla]:
|
|
1254
|
+
"""
|
|
1255
|
+
Ordena las tablas internas considerando las claves foráneas (dependencias).
|
|
1256
|
+
|
|
1257
|
+
Returns:
|
|
1258
|
+
List[Tabla]: Lista ordenada por orden de creación seguro.
|
|
1259
|
+
"""
|
|
1260
|
+
tablas = {t.nombre: t for t in self.tablas.values()}
|
|
1261
|
+
dependencias = {
|
|
1262
|
+
t.nombre: [
|
|
1263
|
+
fk.referencia_tabla
|
|
1264
|
+
for fk in t.foreign_keys
|
|
1265
|
+
if fk.referencia_tabla != t.nombre
|
|
1266
|
+
]
|
|
1267
|
+
for t in self.tablas.values()
|
|
1268
|
+
}
|
|
1269
|
+
ordenadas = []
|
|
1270
|
+
visitadas = set()
|
|
1271
|
+
en_curso = set()
|
|
1272
|
+
|
|
1273
|
+
def visitar(nombre):
|
|
1274
|
+
if nombre in visitadas:
|
|
1275
|
+
return
|
|
1276
|
+
if nombre in en_curso:
|
|
1277
|
+
raise RuntimeError(f"Ciclo de dependencias detectado al visitar la tabla '{nombre}'.")
|
|
1278
|
+
|
|
1279
|
+
en_curso.add(nombre)
|
|
1280
|
+
for dep in dependencias.get(nombre, []):
|
|
1281
|
+
visitar(dep)
|
|
1282
|
+
en_curso.remove(nombre)
|
|
1283
|
+
|
|
1284
|
+
visitadas.add(nombre)
|
|
1285
|
+
ordenadas.append(tablas[nombre])
|
|
1286
|
+
|
|
1287
|
+
for nombre in tablas:
|
|
1288
|
+
visitar(nombre)
|
|
1289
|
+
|
|
1290
|
+
return ordenadas
|
|
1291
|
+
|
|
1292
|
+
def simular_cambios_avanzado(self) -> dict[str, dict[str, list[str]]]:
|
|
1293
|
+
"""
|
|
1294
|
+
Simula diferencias entre los modelos y:
|
|
1295
|
+
- La estructura real actual de la base de datos.
|
|
1296
|
+
- La estructura registrada en estructura_actual, si existe.
|
|
1297
|
+
|
|
1298
|
+
Si la tabla estructura_actual no existe o está vacía, se asume primera ejecución
|
|
1299
|
+
y solo se compara contra la base de datos real.
|
|
1300
|
+
|
|
1301
|
+
Returns:
|
|
1302
|
+
dict: Contiene claves:
|
|
1303
|
+
- 'comparacion_bd': Diferencias con la base de datos real.
|
|
1304
|
+
- 'comparacion_estructura_actual': Diferencias con la estructura registrada (si aplica).
|
|
1305
|
+
"""
|
|
1306
|
+
print("Comparando estructura de modelos con base de datos real...")
|
|
1307
|
+
diferencias_bd = self.simular_cambios(permitir_drop=True)
|
|
1308
|
+
|
|
1309
|
+
conn = self.conectar()
|
|
1310
|
+
usar_estructura_actual = False
|
|
1311
|
+
|
|
1312
|
+
try:
|
|
1313
|
+
with conn.cursor(row_factory=dict_row) as cur:
|
|
1314
|
+
cur.execute("""
|
|
1315
|
+
SELECT COUNT(*) AS total FROM information_schema.tables
|
|
1316
|
+
WHERE table_schema = 'public' AND table_name = 'estructura_actual'
|
|
1317
|
+
""")
|
|
1318
|
+
existe = cur.fetchone()
|
|
1319
|
+
if existe and existe.get("total", 0) > 0:
|
|
1320
|
+
cur.execute("SELECT COUNT(*) AS filas FROM estructura_actual WHERE db_name = %s", (self.db,))
|
|
1321
|
+
filas = cur.fetchone()
|
|
1322
|
+
if filas and filas.get("filas", 0) > 0:
|
|
1323
|
+
usar_estructura_actual = True
|
|
1324
|
+
finally:
|
|
1325
|
+
self.cerrar()
|
|
1326
|
+
|
|
1327
|
+
if usar_estructura_actual:
|
|
1328
|
+
print("Comparando estructura de modelos con estructura_actual registrada...")
|
|
1329
|
+
diferencias_guardadas = self.verificar_contra_estructura_actual()
|
|
1330
|
+
else:
|
|
1331
|
+
print("Tabla 'estructura_actual' no existe o está vacía. Se asume primera ejecución.")
|
|
1332
|
+
diferencias_guardadas = {}
|
|
1333
|
+
|
|
1334
|
+
return {
|
|
1335
|
+
"comparacion_bd": diferencias_bd,
|
|
1336
|
+
"comparacion_estructura_actual": diferencias_guardadas
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
def _aplicar_cambios_respecto_a_estructura_actual(self, permitir_drop: bool = False) -> dict[str, list[str]]:
|
|
1340
|
+
"""
|
|
1341
|
+
Implementación interna que compara los modelos contra la estructura registrada en estructura_actual.
|
|
1342
|
+
Aplica CREATE TABLE (+ índices + comentarios) o ALTER según sea necesario. Nunca elimina columnas.
|
|
1343
|
+
|
|
1344
|
+
Args:
|
|
1345
|
+
permitir_drop (bool): Permite eliminar índices y claves foráneas. No afecta a columnas.
|
|
1346
|
+
|
|
1347
|
+
Returns:
|
|
1348
|
+
dict[str, list[str]]: Acciones aplicadas agrupadas por tabla.
|
|
1349
|
+
"""
|
|
1350
|
+
resultado: dict[str, list[str]] = {}
|
|
1351
|
+
conn = self.conectar(autocommit=False)
|
|
1352
|
+
|
|
1353
|
+
try:
|
|
1354
|
+
with conn.cursor(row_factory=dict_row) as cur:
|
|
1355
|
+
for tabla in self.tablas.values():
|
|
1356
|
+
cur.execute("""
|
|
1357
|
+
SELECT tipo, nombre, definicion FROM estructura_actual
|
|
1358
|
+
WHERE db_name = %s AND tabla = %s
|
|
1359
|
+
""", (self.db, tabla.nombre))
|
|
1360
|
+
registros_previos = cur.fetchall()
|
|
1361
|
+
prev_dict = {(r["tipo"], r["nombre"]): (r["definicion"].strip() if r["definicion"] else "") for r in registros_previos}
|
|
1362
|
+
|
|
1363
|
+
actual_dict = {('tabla', None): tabla.to_sql().strip()}
|
|
1364
|
+
|
|
1365
|
+
for col in tabla.columnas:
|
|
1366
|
+
actual_dict[('columna', col.nombre)] = col.to_sql().strip()
|
|
1367
|
+
|
|
1368
|
+
for idx in tabla.indices:
|
|
1369
|
+
nombre_idx = idx.nombre or "_".join(idx.columnas)
|
|
1370
|
+
actual_dict[('indice', nombre_idx)] = idx.to_sql().strip()
|
|
1371
|
+
|
|
1372
|
+
for fk in tabla.foreign_keys:
|
|
1373
|
+
nombre_fk = fk.nombre or f"fk_{tabla.nombre}_{fk.columna}"
|
|
1374
|
+
actual_dict[('fk', nombre_fk)] = fk.to_sql().strip()
|
|
1375
|
+
|
|
1376
|
+
cur.execute("""
|
|
1377
|
+
SELECT COUNT(*) AS total FROM information_schema.tables
|
|
1378
|
+
WHERE table_schema = 'public' AND table_name = %s
|
|
1379
|
+
""", (tabla.nombre,))
|
|
1380
|
+
existe_bd = cur.fetchone()
|
|
1381
|
+
|
|
1382
|
+
if not existe_bd or existe_bd.get("total", 0) == 0:
|
|
1383
|
+
sentencias = [tabla.to_sql()] + tabla.to_sql_indices() + tabla.to_sql_comentarios()
|
|
1384
|
+
for sentencia in sentencias:
|
|
1385
|
+
cur.execute(sentencia)
|
|
1386
|
+
resultado[tabla.nombre] = sentencias
|
|
1387
|
+
else:
|
|
1388
|
+
alter_sqls = tabla.comparar_generar_alter(conn, permitir_drop=permitir_drop)
|
|
1389
|
+
for alter_sql in alter_sqls:
|
|
1390
|
+
cur.execute(alter_sql)
|
|
1391
|
+
resultado.setdefault(tabla.nombre, []).append(alter_sql)
|
|
1392
|
+
|
|
1393
|
+
conn.commit()
|
|
1394
|
+
self.guardar_estructura_actual(conn)
|
|
1395
|
+
|
|
1396
|
+
except Exception as e:
|
|
1397
|
+
conn.rollback()
|
|
1398
|
+
raise RuntimeError(f"Error aplicando cambios en '{self.db}': {e}") from e
|
|
1399
|
+
|
|
1400
|
+
finally:
|
|
1401
|
+
self.cerrar()
|
|
1402
|
+
|
|
1403
|
+
return resultado
|
|
1404
|
+
|
|
1405
|
+
def aplicar_cambios_respecto_a_estructura_actual(self, permitir_drop: bool = False) -> dict[str, list[str]]:
|
|
1406
|
+
"""
|
|
1407
|
+
Aplica cambios estructurales comparando los modelos con la estructura registrada en 'estructura_actual'.
|
|
1408
|
+
Si la tabla no existe o está vacía, se considera primera ejecución y se aplican los cambios comparando contra
|
|
1409
|
+
la base de datos real, generando después la tabla estructura_actual.
|
|
1410
|
+
|
|
1411
|
+
Args:
|
|
1412
|
+
permitir_drop (bool): Permite eliminar índices o claves foráneas si ya no existen en los modelos.
|
|
1413
|
+
Nunca se eliminan columnas.
|
|
1414
|
+
|
|
1415
|
+
Returns:
|
|
1416
|
+
dict[str, list[str]]: Acciones realizadas, agrupadas por tabla.
|
|
1417
|
+
"""
|
|
1418
|
+
self.tablas = {t.nombre: t for t in self._ordenar_tablas_por_dependencias()}
|
|
1419
|
+
conn = self.conectar()
|
|
1420
|
+
usar_estructura_actual = False
|
|
1421
|
+
|
|
1422
|
+
try:
|
|
1423
|
+
with conn.cursor(row_factory=dict_row) as cur:
|
|
1424
|
+
cur.execute("""
|
|
1425
|
+
SELECT COUNT(*) AS total FROM information_schema.tables
|
|
1426
|
+
WHERE table_schema = 'public' AND table_name = 'estructura_actual'
|
|
1427
|
+
""")
|
|
1428
|
+
existe = cur.fetchone()
|
|
1429
|
+
if existe and existe.get("total", 0) > 0:
|
|
1430
|
+
cur.execute("SELECT COUNT(*) AS filas FROM estructura_actual WHERE db_name = %s", (self.db,))
|
|
1431
|
+
filas = cur.fetchone()
|
|
1432
|
+
if filas and filas.get("filas", 0) > 0:
|
|
1433
|
+
usar_estructura_actual = True
|
|
1434
|
+
finally:
|
|
1435
|
+
self.cerrar()
|
|
1436
|
+
|
|
1437
|
+
if not usar_estructura_actual:
|
|
1438
|
+
print("No hay estructura registrada. Se considera primera ejecución.")
|
|
1439
|
+
resultado = self.aplicar_cambios(permitir_drop=permitir_drop)
|
|
1440
|
+
conn = self.conectar()
|
|
1441
|
+
self.guardar_estructura_actual(conn)
|
|
1442
|
+
self.cerrar()
|
|
1443
|
+
return resultado
|
|
1444
|
+
|
|
1445
|
+
print("Aplicando cambios comparando contra estructura_actual registrada...")
|
|
1446
|
+
return self._aplicar_cambios_respecto_a_estructura_actual(permitir_drop)
|
|
1447
|
+
|
|
1448
|
+
def crear_tabla_estructura_si_no_existe(self, conn):
|
|
1449
|
+
"""
|
|
1450
|
+
Crea la tabla estructura_actual si no existe.
|
|
1451
|
+
"""
|
|
1452
|
+
sql = """
|
|
1453
|
+
CREATE TABLE IF NOT EXISTS estructura_actual (
|
|
1454
|
+
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
1455
|
+
db_name VARCHAR(100) NOT NULL,
|
|
1456
|
+
tabla VARCHAR(100) NOT NULL,
|
|
1457
|
+
tipo VARCHAR(20) NOT NULL CHECK (tipo IN ('tabla', 'columna', 'indice', 'fk')),
|
|
1458
|
+
nombre VARCHAR(100),
|
|
1459
|
+
definicion TEXT NOT NULL,
|
|
1460
|
+
fecha_actualizacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
1461
|
+
UNIQUE (db_name, tabla, tipo, nombre)
|
|
1462
|
+
)
|
|
1463
|
+
"""
|
|
1464
|
+
with conn.cursor() as cur:
|
|
1465
|
+
cur.execute(sql)
|
|
1466
|
+
conn.commit()
|
|
1467
|
+
|
|
1468
|
+
def guardar_estructura_actual(self, conn):
|
|
1469
|
+
"""
|
|
1470
|
+
Guarda en la tabla 'estructura_actual' la estructura actual de los modelos
|
|
1471
|
+
tras aplicar cambios correctamente. Borra primero los datos anteriores por tabla.
|
|
1472
|
+
|
|
1473
|
+
- Args:
|
|
1474
|
+
- conn (psycopg.Connection): Conexión activa a la base de datos.
|
|
1475
|
+
"""
|
|
1476
|
+
try:
|
|
1477
|
+
self.crear_tabla_estructura_si_no_existe(conn)
|
|
1478
|
+
|
|
1479
|
+
with conn.cursor() as cur:
|
|
1480
|
+
for tabla in self.tablas.values():
|
|
1481
|
+
cur.execute(
|
|
1482
|
+
"DELETE FROM estructura_actual WHERE db_name=%s AND tabla=%s",
|
|
1483
|
+
(self.db, tabla.nombre)
|
|
1484
|
+
)
|
|
1485
|
+
for fila in tabla.extraer_estructura(self.db):
|
|
1486
|
+
# Solo se permiten tipos válidos, evitamos errores como ('nombre', None) o cabeceras mal insertadas
|
|
1487
|
+
if fila[2] not in {'tabla', 'columna', 'indice', 'fk'}:
|
|
1488
|
+
if self.logger:
|
|
1489
|
+
self.logger.warning(f"[IGNORADO] Tipo inválido '{fila[2]}' detectado en tabla '{tabla.nombre}': {fila}")
|
|
1490
|
+
continue
|
|
1491
|
+
|
|
1492
|
+
cur.execute("""
|
|
1493
|
+
INSERT INTO estructura_actual (db_name, tabla, tipo, nombre, definicion)
|
|
1494
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
1495
|
+
""", fila)
|
|
1496
|
+
|
|
1497
|
+
conn.commit()
|
|
1498
|
+
|
|
1499
|
+
except Exception as e:
|
|
1500
|
+
conn.rollback()
|
|
1501
|
+
raise RuntimeError(f"Error guardando estructura actual para {self.db}: {e}") from e
|
|
1502
|
+
|
|
1503
|
+
def insertar_registros_iniciales(self):
|
|
1504
|
+
"""
|
|
1505
|
+
Inserta automáticamente los registros definidos en `tabla.registros_iniciales`
|
|
1506
|
+
para cada tabla sincronizada, si aún no existen.
|
|
1507
|
+
|
|
1508
|
+
Los registros iniciales siempre deben incluir el campo `id` y se asume que es la clave primaria.
|
|
1509
|
+
Cada registro se inserta en su propia sub-transacción (savepoint): si uno falla, no arrastra
|
|
1510
|
+
al resto (en Postgres, a diferencia de MariaDB, un error dentro de una transacción la deja
|
|
1511
|
+
abortada hasta el siguiente rollback).
|
|
1512
|
+
"""
|
|
1513
|
+
conn = self.conectar()
|
|
1514
|
+
|
|
1515
|
+
for tabla in self.tablas.values():
|
|
1516
|
+
if not tabla.registros_iniciales:
|
|
1517
|
+
continue
|
|
1518
|
+
|
|
1519
|
+
for registro in tabla.registros_iniciales:
|
|
1520
|
+
if "id" not in registro:
|
|
1521
|
+
print(f"Registro sin campo 'id' en tabla '{tabla.nombre}', se omite.")
|
|
1522
|
+
continue
|
|
1523
|
+
|
|
1524
|
+
try:
|
|
1525
|
+
with conn.transaction():
|
|
1526
|
+
with conn.cursor() as cursor:
|
|
1527
|
+
sql_check = f'SELECT 1 FROM "{tabla.nombre}" WHERE "id" = %s LIMIT 1'
|
|
1528
|
+
cursor.execute(sql_check, (registro["id"],))
|
|
1529
|
+
|
|
1530
|
+
if not cursor.fetchone():
|
|
1531
|
+
columnas = ", ".join(f'"{k}"' for k in registro)
|
|
1532
|
+
placeholders = ", ".join(["%s"] * len(registro))
|
|
1533
|
+
valores_insert = tuple(registro.values())
|
|
1534
|
+
sql_insert = f'INSERT INTO "{tabla.nombre}" ({columnas}) VALUES ({placeholders})'
|
|
1535
|
+
cursor.execute(sql_insert, valores_insert)
|
|
1536
|
+
except Exception as e:
|
|
1537
|
+
print(f"Error insertando en '{tabla.nombre}': {e}")
|
|
1538
|
+
|
|
1539
|
+
self.cerrar()
|
|
1540
|
+
|
|
1541
|
+
def crear_vistas(self):
|
|
1542
|
+
"""
|
|
1543
|
+
Crea o reemplaza las vistas definidas en cada tabla usando `CREATE OR REPLACE VIEW`.
|
|
1544
|
+
Las vistas se recrean siempre, sin comparar con el estado previo.
|
|
1545
|
+
|
|
1546
|
+
Se asume que las vistas están correctamente definidas en el atributo `tabla.vistas`.
|
|
1547
|
+
El orden se resuelve automáticamente si una vista depende de otra.
|
|
1548
|
+
"""
|
|
1549
|
+
|
|
1550
|
+
conn = self.conectar()
|
|
1551
|
+
cursor = conn.cursor()
|
|
1552
|
+
|
|
1553
|
+
# Reunir todas las vistas como {nombre: SQL}
|
|
1554
|
+
vistas_dict: Dict[str, str] = {}
|
|
1555
|
+
for tabla in self.tablas.values():
|
|
1556
|
+
if tabla.vistas:
|
|
1557
|
+
for vista_sql in tabla.vistas:
|
|
1558
|
+
if not vista_sql.strip().lower().startswith("create"):
|
|
1559
|
+
print(f"Vista en tabla '{tabla.nombre}' no comienza por 'CREATE':\n{vista_sql.strip().splitlines()[0]}")
|
|
1560
|
+
continue
|
|
1561
|
+
nombre_vista = self._extraer_nombre_vista(vista_sql)
|
|
1562
|
+
if nombre_vista:
|
|
1563
|
+
vistas_dict[nombre_vista] = vista_sql
|
|
1564
|
+
|
|
1565
|
+
if not vistas_dict:
|
|
1566
|
+
print("No se encontraron vistas para crear.")
|
|
1567
|
+
return
|
|
1568
|
+
|
|
1569
|
+
# Ordenar por dependencias entre vistas
|
|
1570
|
+
dependencias = {}
|
|
1571
|
+
for nombre, sql in vistas_dict.items():
|
|
1572
|
+
sql_lower = sql.lower()
|
|
1573
|
+
deps = []
|
|
1574
|
+
for otro in vistas_dict:
|
|
1575
|
+
if otro == nombre:
|
|
1576
|
+
continue
|
|
1577
|
+
# Patrón flexible: busca vista entre espacios, comillas dobles/simples o delimitadores SQL
|
|
1578
|
+
patron = rf"""[\s"'(,]({re.escape(otro.lower())})[\s"'.,)]"""
|
|
1579
|
+
if re.search(patron, sql_lower):
|
|
1580
|
+
deps.append(otro)
|
|
1581
|
+
dependencias[nombre] = set(deps)
|
|
1582
|
+
|
|
1583
|
+
ordenadas = []
|
|
1584
|
+
visitadas = set()
|
|
1585
|
+
|
|
1586
|
+
def visitar(v):
|
|
1587
|
+
if v in visitadas:
|
|
1588
|
+
return
|
|
1589
|
+
for dep in dependencias.get(v, []):
|
|
1590
|
+
visitar(dep)
|
|
1591
|
+
visitadas.add(v)
|
|
1592
|
+
ordenadas.append(v)
|
|
1593
|
+
|
|
1594
|
+
for vista in vistas_dict:
|
|
1595
|
+
visitar(vista)
|
|
1596
|
+
|
|
1597
|
+
print(f"Se van a crear {len(ordenadas)} vistas: {', '.join(ordenadas)}")
|
|
1598
|
+
|
|
1599
|
+
for nombre in ordenadas:
|
|
1600
|
+
sql = vistas_dict[nombre]
|
|
1601
|
+
try:
|
|
1602
|
+
cursor.execute(sql)
|
|
1603
|
+
print(f"Vista creada: {nombre}")
|
|
1604
|
+
if self.logger:
|
|
1605
|
+
self.logger.info(f"[Vista] Ejecutada: {nombre}")
|
|
1606
|
+
except Exception as e:
|
|
1607
|
+
print(f"Error al crear vista '{nombre}': {e}")
|
|
1608
|
+
print("\nSQL con error:\n" + sql + "\n")
|
|
1609
|
+
if self.logger:
|
|
1610
|
+
self.logger.error(f"Error al crear vista '{nombre}': {e}")
|
|
1611
|
+
raise RuntimeError(f"Error al crear vista '{nombre}': {e}") from e
|
|
1612
|
+
|
|
1613
|
+
conn.commit()
|
|
1614
|
+
self.cerrar()
|
|
1615
|
+
|
|
1616
|
+
def crear_triggers(self) -> list[str]:
|
|
1617
|
+
"""
|
|
1618
|
+
Crea o reemplaza los triggers definidos en las tablas asociadas.
|
|
1619
|
+
Cada sentencia se ejecuta en su propia sub-transacción (savepoint) para que un
|
|
1620
|
+
trigger con error no invalide la conexión para el resto.
|
|
1621
|
+
"""
|
|
1622
|
+
resultado = []
|
|
1623
|
+
conexion = self.conectar()
|
|
1624
|
+
for tabla in self.tablas.values():
|
|
1625
|
+
if tabla.triggers:
|
|
1626
|
+
for trigger_sql in tabla.triggers:
|
|
1627
|
+
try:
|
|
1628
|
+
with conexion.transaction():
|
|
1629
|
+
with conexion.cursor() as cur:
|
|
1630
|
+
cur.execute(trigger_sql)
|
|
1631
|
+
resultado.append(f"Trigger creado o reemplazado en {tabla.nombre}")
|
|
1632
|
+
except Exception as e:
|
|
1633
|
+
resultado.append(f"Error creando trigger en {tabla.nombre}: {e}")
|
|
1634
|
+
self.cerrar()
|
|
1635
|
+
return resultado
|
|
1636
|
+
|
|
1637
|
+
def crear_procedimientos(self) -> list[str]:
|
|
1638
|
+
"""
|
|
1639
|
+
Crea o reemplaza los procedimientos almacenados definidos en las tablas asociadas.
|
|
1640
|
+
Cada sentencia se ejecuta en su propia sub-transacción (savepoint) para que un
|
|
1641
|
+
procedimiento con error no invalide la conexión para el resto.
|
|
1642
|
+
"""
|
|
1643
|
+
resultado = []
|
|
1644
|
+
conexion = self.conectar()
|
|
1645
|
+
for tabla in self.tablas.values():
|
|
1646
|
+
if tabla.procedimientos:
|
|
1647
|
+
for proc_sql in tabla.procedimientos:
|
|
1648
|
+
try:
|
|
1649
|
+
with conexion.transaction():
|
|
1650
|
+
with conexion.cursor() as cur:
|
|
1651
|
+
cur.execute(proc_sql)
|
|
1652
|
+
resultado.append(f"Procedimiento creado o reemplazado en {tabla.nombre}")
|
|
1653
|
+
except Exception as e:
|
|
1654
|
+
resultado.append(f"Error creando procedimiento en {tabla.nombre}: {e}")
|
|
1655
|
+
self.cerrar()
|
|
1656
|
+
return resultado
|
|
1657
|
+
|
|
1658
|
+
def _extraer_nombre_vista(self, sql: str) -> Optional[str]:
|
|
1659
|
+
"""
|
|
1660
|
+
Extrae el nombre de la vista desde una sentencia CREATE VIEW.
|
|
1661
|
+
Admite CREATE OR REPLACE VIEW "nombre" AS ... (o sin comillas).
|
|
1662
|
+
"""
|
|
1663
|
+
match = re.search(r'CREATE\s+(OR\s+REPLACE\s+)?VIEW\s+"?(\w+)"?', sql, re.IGNORECASE)
|
|
1664
|
+
return match.group(2) if match else None
|
|
1665
|
+
|
|
1666
|
+
def crear_todo(self, permitir_drop: bool = True):
|
|
1667
|
+
"""
|
|
1668
|
+
Ejecuta todo el ciclo de sincronización completo:
|
|
1669
|
+
- Aplica cambios estructurales respecto a estructura_actual (creación/modificación de tablas)
|
|
1670
|
+
- Inserta registros iniciales
|
|
1671
|
+
- Crea o reemplaza las vistas
|
|
1672
|
+
|
|
1673
|
+
Args:
|
|
1674
|
+
permitir_drop (bool): Permite eliminar claves foráneas e índices si ya no existen en los modelos.
|
|
1675
|
+
"""
|
|
1676
|
+
print(f"Aplicando cambios estructurales en '{self.db}'...")
|
|
1677
|
+
resultado = self.aplicar_cambios_respecto_a_estructura_actual(permitir_drop=permitir_drop)
|
|
1678
|
+
print(f"Insertando registros iniciales en '{self.db}'...")
|
|
1679
|
+
self.insertar_registros_iniciales()
|
|
1680
|
+
print(f"Creando vistas en '{self.db}'...")
|
|
1681
|
+
self.crear_vistas()
|
|
1682
|
+
resultado_tg = self.crear_triggers()
|
|
1683
|
+
for linea in resultado_tg:
|
|
1684
|
+
print(linea)
|
|
1685
|
+
resultado_pr = self.crear_procedimientos()
|
|
1686
|
+
for linea in resultado_pr:
|
|
1687
|
+
print(linea)
|
|
1688
|
+
return resultado
|