encino-orm 0.2.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.
Files changed (57) hide show
  1. encino_orm/__init__.py +62 -0
  2. encino_orm/base.py +162 -0
  3. encino_orm/cli.py +139 -0
  4. encino_orm/context.py +61 -0
  5. encino_orm/engine.py +45 -0
  6. encino_orm/exceptions.py +22 -0
  7. encino_orm/graphql/__init__.py +9 -0
  8. encino_orm/graphql/filters.py +211 -0
  9. encino_orm/graphql/resolvers.py +65 -0
  10. encino_orm/graphql/scalars.py +19 -0
  11. encino_orm/graphql/schema.py +190 -0
  12. encino_orm/graphql/types.py +72 -0
  13. encino_orm/http/__init__.py +45 -0
  14. encino_orm/http/errors.py +22 -0
  15. encino_orm/http/parsing.py +65 -0
  16. encino_orm/http/registry.py +32 -0
  17. encino_orm/http/routes.py +108 -0
  18. encino_orm/introspection/__init__.py +13 -0
  19. encino_orm/introspection/codegen.py +106 -0
  20. encino_orm/introspection/tables.py +11 -0
  21. encino_orm/introspection/types.py +135 -0
  22. encino_orm/migration.py +56 -0
  23. encino_orm/model/__init__.py +111 -0
  24. encino_orm/model/cache_backend.py +59 -0
  25. encino_orm/model/cached.py +47 -0
  26. encino_orm/model/column.py +7 -0
  27. encino_orm/model/constraint.py +119 -0
  28. encino_orm/model/domain.py +65 -0
  29. encino_orm/model/exceptions.py +33 -0
  30. encino_orm/model/filter.py +200 -0
  31. encino_orm/model/hooks.py +34 -0
  32. encino_orm/model/index.py +27 -0
  33. encino_orm/model/model.py +1009 -0
  34. encino_orm/model/query_builder.py +286 -0
  35. encino_orm/model/records.py +22 -0
  36. encino_orm/model/references.py +32 -0
  37. encino_orm/model/scope.py +26 -0
  38. encino_orm/model/types.py +195 -0
  39. encino_orm/mysql.py +270 -0
  40. encino_orm/observability.py +149 -0
  41. encino_orm/pool.py +301 -0
  42. encino_orm/postgresql.py +258 -0
  43. encino_orm/query.py +34 -0
  44. encino_orm/security/__init__.py +33 -0
  45. encino_orm/security/exceptions.py +20 -0
  46. encino_orm/security/guard.py +71 -0
  47. encino_orm/security/jwt.py +56 -0
  48. encino_orm/security/models.py +62 -0
  49. encino_orm/security/permissions.py +56 -0
  50. encino_orm/sql.py +142 -0
  51. encino_orm/sqlite.py +245 -0
  52. encino_orm/transfer.py +177 -0
  53. encino_orm-0.2.0.dist-info/METADATA +168 -0
  54. encino_orm-0.2.0.dist-info/RECORD +57 -0
  55. encino_orm-0.2.0.dist-info/WHEEL +4 -0
  56. encino_orm-0.2.0.dist-info/entry_points.txt +2 -0
  57. encino_orm-0.2.0.dist-info/licenses/LICENSE +21 -0
encino_orm/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ from .base import Db
2
+ from .context import bind, get_default_db, resolve_db, set_default_db
3
+ from .engine import Engine, engine_of, is_mysql, is_postgres, is_sqlite
4
+ from .query import Query
5
+ from .sql import SqlFunctions, Weekday
6
+ from .sqlite import SqliteDb
7
+ from .mysql import MysqlDb
8
+ from .postgresql import PostgresDb
9
+ from .pool import PoolDb, create_db, session
10
+ from .observability import OtelQueryTracer, QueryTracer, current_trace_id, trace_id
11
+ from .migration import (
12
+ Migration,
13
+ apply_migration,
14
+ apply_migrations,
15
+ migrations_from_dir,
16
+ rollback_migration,
17
+ )
18
+ from .exceptions import (
19
+ EncinoOrmError,
20
+ ConnectionError,
21
+ QueryError,
22
+ UnsupportedEngineError,
23
+ MigrationError,
24
+ PoolExhaustedError,
25
+ )
26
+
27
+ __all__ = [
28
+ "Db",
29
+ "Query",
30
+ "Engine",
31
+ "engine_of",
32
+ "is_sqlite",
33
+ "is_mysql",
34
+ "is_postgres",
35
+ "SqlFunctions",
36
+ "Weekday",
37
+ "SqliteDb",
38
+ "MysqlDb",
39
+ "PostgresDb",
40
+ "PoolDb",
41
+ "create_db",
42
+ "session",
43
+ "set_default_db",
44
+ "get_default_db",
45
+ "bind",
46
+ "resolve_db",
47
+ "QueryTracer",
48
+ "OtelQueryTracer",
49
+ "trace_id",
50
+ "current_trace_id",
51
+ "Migration",
52
+ "apply_migration",
53
+ "apply_migrations",
54
+ "migrations_from_dir",
55
+ "rollback_migration",
56
+ "EncinoOrmError",
57
+ "ConnectionError",
58
+ "QueryError",
59
+ "UnsupportedEngineError",
60
+ "MigrationError",
61
+ "PoolExhaustedError",
62
+ ]
encino_orm/base.py ADDED
@@ -0,0 +1,162 @@
1
+ import asyncio
2
+ import logging
3
+ import random
4
+ from abc import ABC, abstractmethod
5
+ from contextlib import asynccontextmanager
6
+
7
+ from .query import Query
8
+
9
+ logger = logging.getLogger("encino_orm")
10
+
11
+
12
+ class Db(ABC):
13
+ MAX_TRIES = 9
14
+ WAITERS = [x * 0.02 for x in range(1, 11)]
15
+ MAX_WAIT = len(WAITERS) - 1
16
+
17
+ dialect: str = ""
18
+
19
+ @property
20
+ def fn(self):
21
+ """Namespace de funciones SQL portables (`db.fn.now()`, `db.fn.date_add(...)`)."""
22
+ from .sql import SqlFunctions
23
+
24
+ return SqlFunctions(self.dialect)
25
+
26
+ @abstractmethod
27
+ async def connect(self, **kwargs): ...
28
+
29
+ @abstractmethod
30
+ async def close(self): ...
31
+
32
+ @abstractmethod
33
+ async def is_alive(self): ...
34
+
35
+ @abstractmethod
36
+ async def in_transaction(self): ...
37
+
38
+ @asynccontextmanager
39
+ async def transaction(self):
40
+ try:
41
+ yield
42
+ await self.commit()
43
+ except Exception:
44
+ await self.rollback()
45
+ raise
46
+
47
+ @abstractmethod
48
+ async def commit(self): ...
49
+
50
+ @abstractmethod
51
+ async def rollback(self, save_point: str = None): ...
52
+
53
+ @abstractmethod
54
+ async def save_point(self, name: str): ...
55
+
56
+ @staticmethod
57
+ async def wait(waiter: int = -1):
58
+ """Mecanismo de espera en caso de bloqueo por deadlock en la base de datos."""
59
+ if waiter < 0:
60
+ waiter = random.randint(0, Db.MAX_WAIT)
61
+ await asyncio.sleep(Db.WAITERS[waiter])
62
+ return waiter
63
+
64
+ def is_lock_error(self, exc: Exception) -> bool:
65
+ """Indica si `exc` corresponde a un error de bloqueo/deadlock re-reintentable."""
66
+ return False
67
+
68
+ async def retry(self, coro, tries: int = None):
69
+ """Reintenta una coroutine ante errores de bloqueo (deadlock)."""
70
+ max_tries = tries if tries is not None else self.MAX_TRIES
71
+ last_exc = None
72
+ for attempt in range(max_tries):
73
+ try:
74
+ return await coro()
75
+ except Exception as exc:
76
+ if not self.is_lock_error(exc):
77
+ raise
78
+ last_exc = exc
79
+ if attempt + 1 >= max_tries:
80
+ break
81
+ await self.wait()
82
+ raise last_exc
83
+
84
+ @abstractmethod
85
+ def insert(self, tabla: str, data: dict, ignore_duplicated=False, replace=False): ...
86
+
87
+ @abstractmethod
88
+ def delete(self, tabla: str, keys: dict): ...
89
+
90
+ @abstractmethod
91
+ def update(self, tabla: str, keys: dict, values: dict): ...
92
+
93
+ @abstractmethod
94
+ async def execute(self, qry: Query): ...
95
+
96
+ @abstractmethod
97
+ async def fetch_all(self, qry: Query): ...
98
+
99
+ @abstractmethod
100
+ async def fetch_one(self, qry: Query): ...
101
+
102
+ @abstractmethod
103
+ async def fetch_many(self, qry: Query, limit: int, page: int): ...
104
+
105
+ @abstractmethod
106
+ async def exists(self, qry: Query): ...
107
+
108
+ @abstractmethod
109
+ async def last_id(self): ...
110
+
111
+ @abstractmethod
112
+ async def migrate(self, name: str, qry: Query): ...
113
+
114
+ @abstractmethod
115
+ async def migrate_status(self): ...
116
+
117
+ def _tables_sql(self) -> str:
118
+ """SQL base que lista las tablas del catálogo (por motor).
119
+
120
+ Opcional: los motores que no lo implementen no soportan `list_tables`.
121
+ """
122
+ raise NotImplementedError("introspección no soportada para este motor")
123
+
124
+ async def columns_of(self, table: str) -> list:
125
+ """Devuelve la especificación de columnas de una tabla (por motor).
126
+
127
+ Opcional: los motores que no lo implementen no soportan `columns_of`.
128
+ """
129
+ raise NotImplementedError("introspección no soportada para este motor")
130
+
131
+ async def list_tables(self, *, name: str = "", limit: int = 50, page: int = 1):
132
+ """Lista las tablas del catálogo con filtro por nombre y paginación."""
133
+ from .model.records import Records
134
+
135
+ sql = self._tables_sql()
136
+ params = []
137
+ if name:
138
+ sql += " AND name LIKE {0}"
139
+ params.append(f"%{name}%")
140
+ total = (await self.fetch_one(Query(f"SELECT COUNT(*) FROM ({sql})", params)))["COUNT(*)"]
141
+ rows = await self.fetch_many(Query(sql, params), limit, page)
142
+ return Records(rows=rows, total=total, limit=limit, page=page)
143
+
144
+ async def paginate(self, qry: Query, limit: int, page: int = 1):
145
+ """Devuelve un `Records` con la página y el total de un `Query` raw.
146
+
147
+ El total se calcula envolviendo el SQL en
148
+ ``SELECT COUNT(*) AS n FROM (...)``, por lo que solo es fiable para
149
+ SELECT simples (sin ``;`` final, sin su propio ``LIMIT``/``OFFSET`` y sin
150
+ cláusulas no re-embebibles como ``FOR UPDATE``). Conviene incluir
151
+ ``ORDER BY`` en el SQL para una paginación estable.
152
+ """
153
+ from .model.records import Records
154
+
155
+ rows = await self.fetch_many(qry, limit, page)
156
+ sql = qry.sql_template.strip().rstrip(";")
157
+ count_qry = Query(
158
+ f"SELECT COUNT(*) AS n FROM ({sql}) _encino_orm_count", list(qry.fields)
159
+ )
160
+ row = await self.fetch_one(count_qry)
161
+ total = row["n"] if row else 0
162
+ return Records(rows=rows, total=total, limit=limit, page=page)
encino_orm/cli.py ADDED
@@ -0,0 +1,139 @@
1
+ """CLI de encino_orm (solo stdlib `argparse`).
2
+
3
+ Subcomandos: `encino_orm generate models <engine> [tablas...]` y
4
+ `encino_orm copy <src-engine> <dst-engine> [tablas...]`.
5
+ """
6
+
7
+ import argparse
8
+ import asyncio
9
+ import sys
10
+
11
+ from .engine import Engine
12
+
13
+ _ENGINE_CHOICES = [e.value for e in Engine]
14
+
15
+
16
+ def _add_conn_args(parser, prefix: str, label: str):
17
+ def flag(name):
18
+ return f"--{prefix}-{name}" if prefix else f"--{name}"
19
+
20
+ parser.add_argument(flag("database"), help=f"archivo (sqlite) o nombre de BD {label}")
21
+ parser.add_argument(flag("host"), help=f"host {label} (mysql/postgresql)")
22
+ parser.add_argument(flag("port"), type=int, help=f"puerto {label} (mysql/postgresql)")
23
+ parser.add_argument(flag("user"), help=f"usuario {label} (mysql/postgresql)")
24
+ parser.add_argument(flag("password"), help=f"contraseña {label} (mysql/postgresql)")
25
+
26
+
27
+ def _build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="encino_orm",
30
+ description="Herramientas de encino_orm (codegen y copia de datos).",
31
+ )
32
+ sub = parser.add_subparsers(dest="command")
33
+
34
+ gen = sub.add_parser("generate", help="genera código desde la base de datos")
35
+ gen_sub = gen.add_subparsers(dest="subcommand")
36
+
37
+ models = gen_sub.add_parser("models", help="genera modelos desde tablas existentes")
38
+ models.add_argument("engine", choices=_ENGINE_CHOICES)
39
+ models.add_argument("tables", nargs="*", help="tablas a generar (default: todas)")
40
+ models.add_argument("--folder", default="models", help="carpeta de salida (default: models)")
41
+ _add_conn_args(models, "", "")
42
+
43
+ copy = sub.add_parser("copy", help="copia tablas/BD completa entre bases de datos")
44
+ copy.add_argument("src_engine", choices=_ENGINE_CHOICES)
45
+ copy.add_argument("dst_engine", choices=_ENGINE_CHOICES)
46
+ copy.add_argument("tables", nargs="*", help="tablas a copiar (default: todas)")
47
+ copy.add_argument("--create", action="store_true", help="crea las tablas en destino")
48
+ copy.add_argument("--truncate", action="store_true", help="vacía cada tabla destino antes de copiar")
49
+ copy.add_argument("--no-preserve-ids", action="store_true",
50
+ help="no copiar la PK auto-incremental (dejar que el destino la asigne)")
51
+ copy.add_argument("--no-disable-fk", action="store_true",
52
+ help="no desactivar las restricciones de FK durante la copia")
53
+ _add_conn_args(copy, "src", "origen")
54
+ _add_conn_args(copy, "dst", "destino")
55
+
56
+ return parser
57
+
58
+
59
+ def _conn_kwargs(args) -> dict:
60
+ return _conn_kwargs_prefixed(args, "engine", "")
61
+
62
+
63
+ def _conn_kwargs_prefixed(args, engine_attr: str, prefix: str) -> dict:
64
+ engine = getattr(args, engine_attr)
65
+
66
+ def get(name):
67
+ full = f"{prefix}_{name}" if prefix else name
68
+ return getattr(args, full, None)
69
+
70
+ if engine == Engine.SQLITE:
71
+ return {"database": get("database") or ":memory:"}
72
+ kw = {}
73
+ if get("host"):
74
+ kw["host"] = get("host")
75
+ if get("port"):
76
+ kw["port"] = get("port")
77
+ if get("user"):
78
+ kw["user"] = get("user")
79
+ if get("password"):
80
+ kw["password"] = get("password")
81
+ if get("database"):
82
+ kw["db" if engine == Engine.MYSQL else "database"] = get("database")
83
+ return kw
84
+
85
+
86
+ async def _generate_models(args) -> int:
87
+ from .introspection import generate_model, list_tables
88
+ from .pool import create_db
89
+
90
+ db = await create_db(args.engine, **_conn_kwargs(args))
91
+ try:
92
+ tables = args.tables or [r["name"] for r in (await list_tables(db)).rows]
93
+ for table in tables:
94
+ print(await generate_model(db, table, folder=args.folder))
95
+ return 0
96
+ finally:
97
+ await db.close()
98
+
99
+
100
+ async def _copy(args) -> int:
101
+ from .pool import create_db
102
+ from .transfer import copy_database
103
+
104
+ src = await create_db(args.src_engine, **_conn_kwargs_prefixed(args, "src_engine", "src"))
105
+ try:
106
+ dst = await create_db(args.dst_engine, **_conn_kwargs_prefixed(args, "dst_engine", "dst"))
107
+ try:
108
+ result = await copy_database(
109
+ src, dst, tables=args.tables or None,
110
+ create=args.create, truncate=args.truncate,
111
+ preserve_ids=not args.no_preserve_ids,
112
+ disable_fk=not args.no_disable_fk,
113
+ )
114
+ for table, count in result.items():
115
+ print(f"{table}: {count} filas")
116
+ return 0
117
+ finally:
118
+ await dst.close()
119
+ finally:
120
+ await src.close()
121
+
122
+
123
+ def main(argv=None) -> int:
124
+ parser = _build_parser()
125
+ args = parser.parse_args(argv)
126
+ if args.command == "generate" and args.subcommand == "models":
127
+ try:
128
+ return asyncio.run(_generate_models(args))
129
+ except Exception as exc: # pragma: no cover - depende del entorno
130
+ print(f"error: {exc}", file=sys.stderr)
131
+ return 1
132
+ if args.command == "copy":
133
+ try:
134
+ return asyncio.run(_copy(args))
135
+ except Exception as exc: # pragma: no cover - depende del entorno
136
+ print(f"error: {exc}", file=sys.stderr)
137
+ return 1
138
+ parser.print_help()
139
+ return 1
encino_orm/context.py ADDED
@@ -0,0 +1,61 @@
1
+ """Conexión por defecto / ambiente para `Model` sin `db` explícito.
2
+
3
+ Proporciona un "singleton" de conexión (por proceso) y un enlace ambiente por
4
+ tarea/contexto (`contextvars`), de modo que los `Model` puedan resolver su
5
+ conexión de forma implícita. El orden de resolución es:
6
+
7
+ 1. `db` explícito (constructor/método).
8
+ 2. transacción activa del pool (`_current_connection`).
9
+ 3. `bind()` / `session()` (ambiente).
10
+ 4. `set_default_db()` (proceso).
11
+ 5. error `ConnectionError`.
12
+ """
13
+
14
+ import contextvars
15
+ from contextlib import contextmanager
16
+
17
+ from .exceptions import ConnectionError
18
+
19
+ # Conexión/pool por defecto de TODO el proceso (el "singleton").
20
+ _default_db = None
21
+
22
+ # Conexión ambiente por tarea/contexto (bind / session).
23
+ _ambient_db = contextvars.ContextVar("encino_orm_ambient_db", default=None)
24
+
25
+
26
+ def set_default_db(db) -> None:
27
+ """Registra la conexión o pool por defecto del proceso."""
28
+ global _default_db
29
+ _default_db = db
30
+
31
+
32
+ def get_default_db():
33
+ """Devuelve la conexión/pool por defecto del proceso, o `None`."""
34
+ return _default_db
35
+
36
+
37
+ @contextmanager
38
+ def bind(db):
39
+ """Establece la conexión ambiente para el bloque (async-safe vía contextvar)."""
40
+ token = _ambient_db.set(db)
41
+ try:
42
+ yield db
43
+ finally:
44
+ _ambient_db.reset(token)
45
+
46
+
47
+ def resolve_db():
48
+ """Resuelve la conexión actual. Lanza `ConnectionError` si no hay ninguna."""
49
+ from .pool import _current_connection # lazy: evita import circular
50
+
51
+ conn = _current_connection.get() # 1. transacción activa del pool
52
+ if conn is not None:
53
+ return conn
54
+ ambient = _ambient_db.get() # 2. bind()/session()
55
+ if ambient is not None:
56
+ return ambient
57
+ if _default_db is not None: # 3. set_default_db()
58
+ return _default_db
59
+ raise ConnectionError(
60
+ "Sin conexión: pasa `db`, usa `bind()`, `set_default_db()` o `session()`"
61
+ )
encino_orm/engine.py ADDED
@@ -0,0 +1,45 @@
1
+ """Identificación tipada de motores de base de datos."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class Engine(str, Enum):
7
+ """Motores soportados (miembro `str`, comparable con su valor).
8
+
9
+ ``Engine.SQLITE == "sqlite"`` es `True`; para claves de dict/parámetros que
10
+ exigen `str` usa ``engine.value``.
11
+ """
12
+
13
+ SQLITE = "sqlite"
14
+ MYSQL = "mysql"
15
+ POSTGRESQL = "postgresql"
16
+
17
+ def __str__(self) -> str:
18
+ return self.value
19
+
20
+
21
+ def engine_of(db) -> Engine:
22
+ """Devuelve el motor activo como `Engine`.
23
+
24
+ Acepta un `Db`/`PoolDb` (lee `dialect`), un `Engine` (se devuelve tal cual) o
25
+ una cadena (`"sqlite"`/`"mysql"`/`"postgresql"`). Un valor desconocido lanza
26
+ `ValueError`.
27
+ """
28
+ if isinstance(db, Engine):
29
+ return db
30
+ dialect = getattr(db, "dialect", db)
31
+ if isinstance(dialect, Engine):
32
+ return dialect
33
+ return Engine(dialect)
34
+
35
+
36
+ def is_sqlite(db) -> bool:
37
+ return engine_of(db) is Engine.SQLITE
38
+
39
+
40
+ def is_mysql(db) -> bool:
41
+ return engine_of(db) is Engine.MYSQL
42
+
43
+
44
+ def is_postgres(db) -> bool:
45
+ return engine_of(db) is Engine.POSTGRESQL
@@ -0,0 +1,22 @@
1
+ class EncinoOrmError(Exception):
2
+ pass
3
+
4
+
5
+ class ConnectionError(EncinoOrmError):
6
+ pass
7
+
8
+
9
+ class QueryError(EncinoOrmError):
10
+ pass
11
+
12
+
13
+ class UnsupportedEngineError(EncinoOrmError):
14
+ pass
15
+
16
+
17
+ class MigrationError(EncinoOrmError):
18
+ pass
19
+
20
+
21
+ class PoolExhaustedError(EncinoOrmError):
22
+ pass
@@ -0,0 +1,9 @@
1
+ """Subpaquete opcional `encino_orm.graphql` (Strawberry GraphQL).
2
+
3
+ Genera tipos, queries y mutations a partir de los `Model` de encino_orm.
4
+ `strawberry-graphql` es dependencia opcional (extras `graphql`).
5
+ """
6
+
7
+ from .schema import build_schema
8
+
9
+ __all__ = ["build_schema"]