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/crud.py ADDED
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ #-*- coding: UTF-8 -*-
3
+ # --------------------------
4
+ # jjandres 2025
5
+ # Adaptación a Postgres de mtpk_mariadb.crud
6
+ # --------------------------
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import is_dataclass, asdict
11
+ from datetime import date, datetime
12
+ from decimal import Decimal
13
+ from enum import Enum
14
+ from pathlib import Path
15
+
16
+ from typing import Any, Optional, Tuple, Mapping, Dict, Type, Callable
17
+ from .core_sync import Tabla
18
+ from .async_adapter import AsyncDatabase
19
+ from .utils import construir_condiciones_sql, resolver_orden_sql
20
+ from .excepciones import MtpkErrorValidacionDb, MtpkErrorDb
21
+ from uuid import UUID
22
+ import uuid
23
+
24
+ class AsyncCrudBase:
25
+ def __init__(self, tabla: Tabla, config: dict | Callable[[], dict], logger: Optional[Any] = None):
26
+ """
27
+ Inicializa una instancia CRUD para la tabla especificada.
28
+
29
+ Args:
30
+ tabla (Tabla): Objeto de definición de tabla.
31
+ config (dict): Configuración de conexión a la base de datos.
32
+ """
33
+ self.tabla = tabla
34
+ self._config_factory: Optional[Callable[[], dict]] = config if callable(config) else None
35
+ self.config = config() if callable(config) else config
36
+ self.logger = logger
37
+
38
+ def _resolver_config_actual(self) -> dict:
39
+ if self._config_factory:
40
+ self.config = self._config_factory() or {}
41
+ return self.config
42
+
43
+ async def _inst_db_si_necesario(self, db: Optional[AsyncDatabase]) -> AsyncDatabase:
44
+ """
45
+ Si `db` ya es una instancia AsyncDatabase, lo reutiliza.
46
+ Si no, la crea para usar la tabla actual.
47
+ La conexión no se establece aquí, solo se registra la tabla.
48
+ Args:
49
+ db (Optional[AsyncDatabase]): Instancia AsyncDatabase existente o None.
50
+
51
+ Returns:
52
+ AsyncDatabase: Instancia AsyncDatabase con la tabla registrada.
53
+ """
54
+ if db is not None:
55
+ return db
56
+ db = AsyncDatabase(logger=self.logger, **self._resolver_config_actual())
57
+ db.add_tabla(self.tabla)
58
+ return db
59
+
60
+ async def insertar(self, datos: dict, aplicar_protegidos: bool = False, db: Optional[AsyncDatabase] = None, conexion: Any = None) -> int:
61
+ """
62
+ OBSOLETO: Usa el método insert().
63
+ Inserta un nuevo registro en la tabla.
64
+
65
+ Args:
66
+ db (Optional[AsyncDatabase]): Conexión activa o None.
67
+ datos (dict): Datos a insertar.
68
+ aplicar_protegidos (bool): Si se deben excluir columnas protegidas.
69
+ conexion (Any): Conexión reutilizable, si aplica.
70
+
71
+ Returns:
72
+ int: ID del nuevo registro insertado (vía RETURNING id).
73
+ """
74
+ try:
75
+ db = await self._inst_db_si_necesario(db)
76
+ campos_validos = {
77
+ col.nombre for col in self.tabla.columnas
78
+ if not (aplicar_protegidos and getattr(col, "protegido_insertar", False))
79
+ }
80
+ datos_filtrados = {k: v for k, v in datos.items() if k in campos_validos}
81
+ columnas = ", ".join(f'"{k}"' for k in datos_filtrados)
82
+ valores = ", ".join(["%s"] * len(datos_filtrados))
83
+ sql = f'INSERT INTO "{self.tabla.nombre}" ({columnas}) VALUES ({valores}) RETURNING id'
84
+ await db.query(sql, tuple(datos_filtrados.values()), conexion=conexion)
85
+ return db.ultimo_insert_id
86
+
87
+ except Exception as e:
88
+ identificador = str(uuid.uuid4())
89
+ raise MtpkErrorDb(f"Error insertando registro: {e}", identificador, logger=self.logger) from e
90
+
91
+ async def insert(self, datos: dict, db: Optional[AsyncDatabase] = None, conexion: Any = None) -> int:
92
+ """
93
+ Inserta un nuevo registro en la tabla.
94
+
95
+ Args:
96
+ db (Optional[AsyncDatabase]): Conexión activa o None.
97
+ datos (dict): Datos a insertar.
98
+ conexion (Any): Conexión reutilizable, si aplica.
99
+
100
+ Returns:
101
+ int: ID del nuevo registro insertado (vía RETURNING id).
102
+ """
103
+ try:
104
+ db = await self._inst_db_si_necesario(db)
105
+
106
+ # Tomamos únicamente las columnas que existen en la tabla
107
+ campos_validos = {col.nombre for col in self.tabla.columnas}
108
+ datos_filtrados = {k: v for k, v in datos.items() if k in campos_validos}
109
+
110
+ columnas = ", ".join(f'"{k}"' for k in datos_filtrados)
111
+ valores = ", ".join(["%s"] * len(datos_filtrados))
112
+ sql = f'INSERT INTO "{self.tabla.nombre}" ({columnas}) VALUES ({valores}) RETURNING id'
113
+
114
+ await db.query(sql, tuple(datos_filtrados.values()), conexion=conexion)
115
+ return db.ultimo_insert_id
116
+
117
+ except Exception as e:
118
+ identificador = str(uuid.uuid4())
119
+ raise MtpkErrorDb(f"Error insertando registro: {e}", identificador, logger=self.logger) from e
120
+
121
+ async def actualizar(self, id: int, datos: dict, aplicar_protegidos: bool = False, db: Optional[AsyncDatabase] = None, conexion: Any = None) -> int:
122
+ """
123
+ OBSOLETO: Usa el método update().
124
+ Actualiza un registro por ID.
125
+
126
+ Args:
127
+ id (int): ID del registro a actualizar.
128
+ datos (dict): Campos a modificar.
129
+ aplicar_protegidos (bool): Si se deben excluir columnas protegidas.
130
+ db (Optional[AsyncDatabase]): Conexión activa o None.
131
+ conexion (Any): Conexión reutilizable, si aplica.
132
+
133
+ Returns:
134
+ int: Número de filas afectadas.
135
+ """
136
+ try:
137
+ db = await self._inst_db_si_necesario(db)
138
+ campos_validos = {
139
+ col.nombre for col in self.tabla.columnas
140
+ if not (aplicar_protegidos and getattr(col, "protegido_actualizar", False))
141
+ }
142
+ datos_filtrados = {k: v for k, v in datos.items() if k in campos_validos}
143
+ asignaciones = ", ".join(f'"{k}" = %s' for k in datos_filtrados)
144
+ sql = f'UPDATE "{self.tabla.nombre}" SET {asignaciones} WHERE id = %s'
145
+ num_filas = await db.query(sql, tuple(datos_filtrados.values()) + (id,), conexion=conexion)
146
+ return num_filas
147
+ except Exception as e:
148
+ identificador = str(uuid.uuid4())
149
+ raise MtpkErrorDb(f"Error actualizando registro: {e}", identificador, logger=self.logger) from e
150
+
151
+ async def update(self, id: int, datos: dict, db: Optional[AsyncDatabase] = None, conexion: Any = None) -> int:
152
+ """
153
+ Actualiza un registro por ID.
154
+
155
+ Args:
156
+ id (int): ID del registro a actualizar.
157
+ datos (dict): Campos a modificar.
158
+ db (Optional[AsyncDatabase]): Conexión activa o None.
159
+ conexion (Any): Conexión reutilizable, si aplica.
160
+
161
+ Returns:
162
+ int: Número de filas afectadas.
163
+ """
164
+ try:
165
+ db = await self._inst_db_si_necesario(db)
166
+
167
+ # Filtramos solo columnas válidas de la tabla
168
+ campos_validos = {col.nombre for col in self.tabla.columnas}
169
+ datos_filtrados = {k: v for k, v in datos.items() if k in campos_validos}
170
+
171
+ asignaciones = ", ".join(f'"{k}" = %s' for k in datos_filtrados)
172
+ sql = f'UPDATE "{self.tabla.nombre}" SET {asignaciones} WHERE id = %s'
173
+
174
+ num_filas = await db.query(sql, tuple(datos_filtrados.values()) + (id,), conexion=conexion)
175
+ return num_filas
176
+
177
+ except Exception as e:
178
+ identificador = str(uuid.uuid4())
179
+ raise MtpkErrorDb(f"Error actualizando registro: {e}", identificador, logger=self.logger) from e
180
+
181
+ async def eliminar(self, id: int, db: Optional[AsyncDatabase] = None, conexion: Any = None) -> int:
182
+ """
183
+ Elimina un registro por ID.
184
+
185
+ Args:
186
+ db (Optional[AsyncDatabase]): Conexión activa o None.
187
+ id (int): ID del registro a eliminar.
188
+ conexion (Any): Conexión reutilizable, si aplica.
189
+
190
+ Returns:
191
+ int: Número de filas eliminadas.
192
+ """
193
+ try:
194
+ db = await self._inst_db_si_necesario(db)
195
+ sql = f'DELETE FROM "{self.tabla.nombre}" WHERE id = %s'
196
+ num_filas = await db.query(sql, (id,), conexion=conexion)
197
+ return num_filas
198
+ except Exception as e:
199
+ identificador = str(uuid.uuid4())
200
+ raise MtpkErrorDb(f"Error eliminando registro: {e}", identificador, logger=self.logger) from e
201
+
202
+ async def obtener(self, id: Any, alias: Optional[dict[str, str]] = None, db: Optional[AsyncDatabase] = None, conexion: Any = None) -> Optional[dict]:
203
+ """
204
+ Obtiene un registro por su ID.
205
+
206
+ Args:
207
+ `db` (Optional[AsyncDatabase]): Conexión activa o None.
208
+ `id` (Any): ID del registro a buscar.
209
+ `conexion` (Any): Conexión reutilizable, si aplica.
210
+ `alias` (Optional[dict[str, str]]): Alias SQL por campo.
211
+
212
+ Returns:
213
+ Optional[dict]: Registro encontrado o None si no existe.
214
+ """
215
+ try:
216
+ db = await self._inst_db_si_necesario(db)
217
+ sql = f'SELECT * FROM "{self.tabla.nombre}" WHERE id = %s'
218
+ return await db.query(sql, (id,), uno=True, conexion=conexion)
219
+
220
+ except Exception as e:
221
+ identificador = str(uuid.uuid4())
222
+ if self.logger:
223
+ self.logger.error("%s] Error obteniendo registro: %s", identificador, e, exc_info=True)
224
+ raise MtpkErrorDb("Error al obtener registro", identificador, logger=self.logger) from e
225
+
226
+ async def listar(self, offset: int = 0, limit: int = 100, orden: str = "id ASC", filtros: Optional[dict] = None, alias: Optional[dict[str, str]] = None, db: Optional[Any] = None, conexion: Any = None) -> Tuple[list[dict], int]:
227
+ """
228
+ Lista registros aplicando filtros, alias, ordenación y paginación.
229
+
230
+ Args:
231
+ `offset` (int): Desplazamiento inicial.
232
+ `limit` (int): Número máximo de registros a devolver.
233
+ `orden` (str): Orden SQL, como "a.nombre DESC", por defecto "id ASC".
234
+ `filtros` (Optional[dict]): Diccionario con condiciones de filtrado.
235
+ `alias` (Optional[dict[str, str]]): Alias SQL por campo.
236
+ Diccionario opcional con alias por campo, por ejemplo {'nombre_campo': 'a.nombre_alias'}
237
+ Los campos incluidos en el alias se refiere a los que afectan a la condición de filtro.
238
+ `db` (Optional[AsyncDatabase]): Conexión existente, si aplica.
239
+ `conexion` (Any): Conexión interna reutilizable.
240
+
241
+ Returns:
242
+ Tuple[list[dict], int]: Lista de registros y total de registros sin paginar.
243
+ """
244
+ try:
245
+ db = await self._inst_db_si_necesario(db)
246
+ condiciones_sql, valores = construir_condiciones_sql(filtros or {}, alias)
247
+ orden_sql = resolver_orden_sql(orden, alias)
248
+
249
+ where_sql = f"WHERE {condiciones_sql}" if condiciones_sql else ""
250
+
251
+ sql_base = f"""
252
+ SELECT * FROM "{self.tabla.nombre}"
253
+ {where_sql}
254
+ """
255
+
256
+ sql_final = f"""{sql_base} ORDER BY {orden_sql} LIMIT %s OFFSET %s"""
257
+
258
+ valores_total = valores.copy()
259
+ valores += [limit, offset]
260
+ filas = await db.query(sql_final, valores, conexion=conexion)
261
+
262
+ total = len(filas)
263
+ if total == limit:
264
+ sql_total = f"SELECT COUNT(*) as total FROM ({sql_base}) as subquery"
265
+ resultado = await db.query(sql_total, valores_total, uno=True, conexion=conexion)
266
+ total = resultado.get("total", total)
267
+
268
+ return filas, total
269
+
270
+ except Exception as e:
271
+ identificador = str(uuid.uuid4())
272
+ raise MtpkErrorDb("Error al obtener registro", identificador, logger=self.logger) from e
273
+
274
+ # Opcional: ganchos para tipos personalizados (se accede como self.NORMALIZADORES_EXTRA)
275
+ NORMALIZADORES_EXTRA: Dict[Type[Any], Callable[[Any], Any]] = {}
276
+
277
+ def _normalizar_valores(self, obj: Any) -> Any:
278
+ """
279
+ Normaliza recursivamente los valores de un objeto para adaptarlos a tipos aceptados por BD.
280
+
281
+ - Convierte UUID, Decimal, Enum, Path, date y datetime a string (isoformat en fechas).
282
+ - Recorre diccionarios y colecciones (list/tuple/set/frozenset) profundamente.
283
+ - Dataclasses -> dict (asdict) y normaliza.
284
+ - Modelos Pydantic v2 -> model_dump() y normaliza.
285
+ - bytes/bytearray/memoryview -> se devuelven tal cual (útil para BYTEA).
286
+ - Permite añadir normalizadores personalizados vía NORMALIZADORES_EXTRA.
287
+ """
288
+ # Primitivos
289
+ if obj is None or isinstance(obj, (str, int, float, bool)):
290
+ return obj
291
+
292
+ # Diccionarios
293
+ if isinstance(obj, Mapping):
294
+ return {k: self._normalizar_valores(v) for k, v in obj.items()}
295
+
296
+ # Secuencias / conjuntos (excluye str/bytes ya tratados)
297
+ if isinstance(obj, (list, tuple, set, frozenset)):
298
+ return [self._normalizar_valores(v) for v in obj]
299
+
300
+ # Enum
301
+ if isinstance(obj, Enum):
302
+ return obj.value
303
+
304
+ # UUID / Decimal / Path -> str
305
+ if isinstance(obj, (UUID, Decimal, Path)):
306
+ return str(obj)
307
+
308
+ # Fechas
309
+ if isinstance(obj, (date, datetime)):
310
+ return obj.isoformat()
311
+
312
+ # Dataclasses
313
+ if is_dataclass(obj):
314
+ return self._normalizar_valores(asdict(obj))
315
+
316
+ # Pydantic v2 (duck-typing)
317
+ if hasattr(obj, "model_dump") and callable(getattr(obj, "model_dump")):
318
+ try:
319
+ return self._normalizar_valores(obj.model_dump())
320
+ except Exception:
321
+ pass # si no es un BaseModel real, continúa
322
+
323
+ # Binarios -> deja tal cual (para JSON tendrías que base64-encode en otro método)
324
+ if isinstance(obj, (bytes, bytearray, memoryview)):
325
+ return bytes(obj) if not isinstance(obj, bytes) else obj
326
+
327
+ # Normalizadores extra registrados por el usuario
328
+ for tipo, fn in self.NORMALIZADORES_EXTRA.items():
329
+ if isinstance(obj, tipo):
330
+ return fn(obj)
331
+
332
+ # Fallback: sin tocar (mejor no forzar str() para no ocultar problemas)
333
+ return obj
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ #-*- coding: UTF-8 -*-
3
+ # ----------------------------------------
4
+ # jjandres 2025
5
+ # ----------------------------------------
6
+
7
+
8
+ from typing import Any, Optional
9
+ import traceback
10
+
11
+ class MtpkErrorValidacionDb(Exception):
12
+ """
13
+ Excepción específica para errores de validación dentro del ámbito de base de datos,
14
+ como restricciones, formatos inválidos o violaciones lógicas de consistencia.
15
+
16
+ Args:
17
+ mensaje (str): Mensaje del error.
18
+ codigo (str): Código de referencia opcional.
19
+ """
20
+ def __init__(self, mensaje: str, codigo: str = ""):
21
+ self.mensaje = mensaje
22
+ self.codigo = codigo
23
+ texto = f"{mensaje} [{codigo}]" if codigo else mensaje
24
+ super().__init__(texto)
25
+
26
+
27
+ class MtpkErrorDb(Exception):
28
+ """
29
+ Excepción base para errores críticos relacionados con operaciones de base de datos.
30
+ Registra automáticamente el error y su traza si se proporciona un logger.
31
+
32
+ Args:
33
+ mensaje (str): Descripción del error.
34
+ codigo (str): Código de error opcional.
35
+ logger (Optional[Any]): Logger opcional para capturar el evento.
36
+ """
37
+ def __init__(self, mensaje: str, codigo: str = "", logger: Optional[Any] = None):
38
+ self.mensaje = mensaje
39
+ self.codigo = codigo
40
+ texto_error = f"{mensaje} [{codigo}]"
41
+
42
+ if logger:
43
+ logger.error(texto_error)
44
+ logger.error(traceback.format_exc())
45
+
46
+ super().__init__(texto_error)
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ #-*- coding: UTF-8 -*-
3
+ # ----------------------------------------
4
+ # jjandres 2025
5
+ # ----------------------------------------
6
+
7
+ from typing import TYPE_CHECKING
8
+ import asyncio
9
+ from importlib import import_module
10
+ from .core_sync import SQLLiteral
11
+
12
+ # Solo para análisis de tipos
13
+ if TYPE_CHECKING:
14
+ from .core_sync import Tabla, Database, Columna, ForeignKey, Index
15
+
16
+ # Importa ambos backends
17
+ _sync = import_module("mtpk_postgres.core_sync")
18
+ _async = import_module("mtpk_postgres.async_adapter")
19
+
20
+
21
+ # Modo por defecto
22
+ DEFAULT_ASYNC_MODE = False
23
+
24
+ def set_async_mode(value: bool):
25
+ """Permite cambiar dinámicamente el modo por defecto (por código o startup FastAPI)."""
26
+ global DEFAULT_ASYNC_MODE # pylint: disable=global-statement
27
+ DEFAULT_ASYNC_MODE = value
28
+
29
+ def is_async_context():
30
+ """Detecta si estamos dentro de un bucle async activo."""
31
+ try:
32
+ return asyncio.current_task() is not None
33
+ except RuntimeError:
34
+ return False
35
+
36
+ def resolve_backend(async_mode=None):
37
+ """Decide qué backend usar."""
38
+ if async_mode is None:
39
+ async_mode = DEFAULT_ASYNC_MODE or is_async_context()
40
+ return _async if async_mode else _sync
41
+
42
+ # Fábricas para clases
43
+ def get_Tabla(async_mode=None):
44
+ return resolve_backend(async_mode).Tabla
45
+
46
+ def get_Columna(async_mode=None):
47
+ return resolve_backend(async_mode).Columna
48
+
49
+ def get_ForeignKey(async_mode=None):
50
+ return resolve_backend(async_mode).ForeignKey
51
+
52
+ def get_Index(async_mode=None):
53
+ return resolve_backend(async_mode).Index
54
+
55
+ def get_Database(async_mode=None):
56
+ return resolve_backend(async_mode).Database
57
+
58
+ # Exposición por defecto usando el backend predeterminado (útil para autocompletado)
59
+ Tabla = get_Tabla()
60
+ Columna = get_Columna()
61
+ ForeignKey = get_ForeignKey()
62
+ Index = get_Index()
63
+ Database = get_Database()
64
+
65
+ __all__ = [
66
+ "Tabla", "Columna", "ForeignKey", "Index", "Database",
67
+ "get_Tabla", "get_Columna", "get_ForeignKey", "get_Index", "get_Database",
68
+ "set_async_mode", "is_async_context", "SQLLiteral"
69
+ ]