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.
@@ -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