encino-orm 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/publish-testpypi.yml +0 -2
- {encino_orm-0.2.2 → encino_orm-0.2.4}/CHANGELOG.md +33 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/PKG-INFO +9 -3
- {encino_orm-0.2.2 → encino_orm-0.2.4}/README.md +6 -2
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docker-compose.yml +5 -1
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/docker.md +27 -1
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/getting-started.md +1 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/guide.md +17 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/integrations.md +27 -2
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/base.py +10 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/routes.py +2 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/codegen.py +2 -2
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/constraint.py +3 -6
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/model.py +7 -1
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/query_builder.py +1 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/mssql.py +6 -1
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/mysql.py +5 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/oracle.py +2 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/postgresql.py +2 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/jwt.py +46 -5
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/sqlite.py +5 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/pyproject.toml +2 -1
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_from_db.py +1 -1
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_mssql.py +3 -0
- encino_orm-0.2.4/tests/test_redis_cache.py +78 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/uv.lock +18 -2
- {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/ci.yml +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/docs.yml +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/release.yml +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/.gitignore +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/LICENSE +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/credits.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/0-design.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/1-model.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/2-constraint.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/3-graphql.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/4-crud.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/5-security.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/6-from_db.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/7-missing.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/8-pk.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/9-singleton.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/a-xpress.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/has_many.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/engines.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/index.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/context.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/db.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/filter.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/migration.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/model.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/observability.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/query_builder.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/relations.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/sql.md +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/__init__.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/_rows.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/cli.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/context.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/engine.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/exceptions.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/__init__.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/filters.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/resolvers.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/scalars.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/schema.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/types.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/__init__.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/errors.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/parsing.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/registry.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/__init__.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/tables.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/types.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/mariadb.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/migration.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/__init__.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/cache_backend.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/cached.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/column.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/domain.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/exceptions.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/filter.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/hooks.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/index.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/records.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/references.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/scope.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/types.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/observability.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/pool.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/query.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/__init__.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/exceptions.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/guard.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/models.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/permissions.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/sql.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/transfer.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/mkdocs.yml +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/__init__.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/conftest.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_aggregates.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_base.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_bulk_upsert.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_cached_model.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_cli.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_constraint.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_crud.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_d_recommendations.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_decimal_json.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_e_recommendations.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_engine.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_f_recommendations.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_g_recommendations.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_graphql.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_has_many.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_hooks.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_improvements.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_index.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_issues.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_mariadb.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_migrations.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_model.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_mysql.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_observability.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_oracle.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_pk.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_pool.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_postgresql.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_query_builder.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_records.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_references.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_scalar_types.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_scope_softdelete.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_security.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_singleton.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_sql_functions.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_sqlite.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_transfer.py +0 -0
- {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_types.py +0 -0
|
@@ -8,6 +8,39 @@ en `0.x`, **no hay garantía de estabilidad** (ver `README.md`).
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.2.4] - 2026-09-10
|
|
12
|
+
|
|
13
|
+
### Añadido
|
|
14
|
+
|
|
15
|
+
- Extra opcional `cache` (`redis`) para `CachedModel` con `RedisCacheBackend`
|
|
16
|
+
probado contra Redis real; servicio `redis` en `docker-compose.yml` y tests de
|
|
17
|
+
caché (`tests/test_redis_cache.py`).
|
|
18
|
+
|
|
19
|
+
### Seguridad
|
|
20
|
+
|
|
21
|
+
- `Model.search(columns=...)` y `QueryBuilder.sum(column)` validan los
|
|
22
|
+
identificadores de columna antes de incrustarlos en SQL.
|
|
23
|
+
- `columns_of` de SQLite y MySQL valida el nombre de tabla antes de interpolarlo.
|
|
24
|
+
- SQL Server conecta con TLS por defecto (`Encrypt=yes`,
|
|
25
|
+
`TrustServerCertificate=no`); configurable con `encrypt`/
|
|
26
|
+
`trust_server_certificate`.
|
|
27
|
+
- Codegen (`generate_model`) emite `_table` y `name=` con `repr`, evitando
|
|
28
|
+
inyección de código desde nombres de tabla/columna.
|
|
29
|
+
- JWT: separación access/refresh mediante el claim `type` y lista permitida de
|
|
30
|
+
algoritmos (rechaza `"none"`).
|
|
31
|
+
- Los nombres de `SAVEPOINT` se validan antes de interpolar en SQL.
|
|
32
|
+
- El `PUT` de REST ignora los campos de solo lectura (`id`, `enabled`,
|
|
33
|
+
`created_at`, `updated_at`).
|
|
34
|
+
|
|
35
|
+
## [0.2.3] - 2026-09-08
|
|
36
|
+
|
|
37
|
+
### Corregido
|
|
38
|
+
|
|
39
|
+
- Compatibilidad con Python 3.10: se elimina el *starred unpacking* dentro de
|
|
40
|
+
subíndices `Annotated[...]` (sintaxis PEP 646, sólo válida en Python 3.11+) en
|
|
41
|
+
`encino_orm/model/model.py` y `encino_orm/model/constraint.py`. Ahora el
|
|
42
|
+
paquete funciona realmente con `requires-python = ">=3.10"`.
|
|
43
|
+
|
|
11
44
|
## [0.2.2] - 2026-09-08
|
|
12
45
|
|
|
13
46
|
### Añadido
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: encino-orm
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Librería asíncrona de interfaz unificada para SQLite, MySQL y PostgreSQL
|
|
5
5
|
Project-URL: Repository, https://github.com/hvalles/encinorm
|
|
6
6
|
Project-URL: Documentation, https://hvalles.github.io/encinorm/
|
|
@@ -16,6 +16,8 @@ Provides-Extra: all-db
|
|
|
16
16
|
Requires-Dist: aioodbc>=0.5; extra == 'all-db'
|
|
17
17
|
Requires-Dist: oracledb>=2.0; extra == 'all-db'
|
|
18
18
|
Requires-Dist: pyodbc>=5.0; extra == 'all-db'
|
|
19
|
+
Provides-Extra: cache
|
|
20
|
+
Requires-Dist: redis>=5.0; extra == 'cache'
|
|
19
21
|
Provides-Extra: graphql
|
|
20
22
|
Requires-Dist: strawberry-graphql>=0.200; extra == 'graphql'
|
|
21
23
|
Provides-Extra: http
|
|
@@ -30,7 +32,7 @@ Requires-Dist: fastapi>=0.110; extra == 'security'
|
|
|
30
32
|
Requires-Dist: pyjwt<2.13,>=2.8; extra == 'security'
|
|
31
33
|
Description-Content-Type: text/markdown
|
|
32
34
|
|
|
33
|
-
# encino_orm · v0.2.
|
|
35
|
+
# encino_orm · v0.2.4
|
|
34
36
|
|
|
35
37
|
ORM asíncrono de interfaz unificada para **SQLite**, **MySQL**, **MariaDB**,
|
|
36
38
|
**PostgreSQL**, **SQL Server** y **Oracle**, construido sobre `pydantic`. Proporciona un modelo de datos declarativo, CRUD
|
|
@@ -39,7 +41,7 @@ migraciones, y capas opcionales de producto: REST (FastAPI), GraphQL
|
|
|
39
41
|
(Strawberry), seguridad (RBAC + JWT) y generación de código desde la base de
|
|
40
42
|
datos.
|
|
41
43
|
|
|
42
|
-
> **Estado: experimental (v0.2.
|
|
44
|
+
> **Estado: experimental (v0.2.4).** encino_orm se encuentra en **fase
|
|
43
45
|
> experimental**: la API pública y su comportamiento pueden cambiar **sin previo
|
|
44
46
|
> aviso** en versiones posteriores, **sin garantía de compatibilidad
|
|
45
47
|
> retroactiva**. El núcleo ORM está probado sobre los tres motores (más de 350
|
|
@@ -85,6 +87,9 @@ pip install -e .
|
|
|
85
87
|
# con extras opcionales
|
|
86
88
|
pip install -e ".[http,security,graphql]"
|
|
87
89
|
|
|
90
|
+
# caché en Redis (CachedModel + RedisCacheBackend)
|
|
91
|
+
pip install -e ".[cache]"
|
|
92
|
+
|
|
88
93
|
# motores opcionales (SQL Server / Oracle)
|
|
89
94
|
pip install -e ".[mssql,oracle]"
|
|
90
95
|
```
|
|
@@ -96,6 +101,7 @@ Extras disponibles:
|
|
|
96
101
|
| `http` | `fastapi` (REST CRUD) |
|
|
97
102
|
| `security` | `fastapi` + `PyJWT` (RBAC + JWT) |
|
|
98
103
|
| `graphql` | `strawberry-graphql` (GraphQL) |
|
|
104
|
+
| `cache` | `redis` (`CachedModel` + `RedisCacheBackend`) |
|
|
99
105
|
| `mssql` | `aioodbc` + `pyodbc` (SQL Server, requiere ODBC Driver 18) |
|
|
100
106
|
| `oracle` | `oracledb` (Oracle, modo *thin*) |
|
|
101
107
|
| `all-db` | `mssql` + `oracle` |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# encino_orm · v0.2.
|
|
1
|
+
# encino_orm · v0.2.4
|
|
2
2
|
|
|
3
3
|
ORM asíncrono de interfaz unificada para **SQLite**, **MySQL**, **MariaDB**,
|
|
4
4
|
**PostgreSQL**, **SQL Server** y **Oracle**, construido sobre `pydantic`. Proporciona un modelo de datos declarativo, CRUD
|
|
@@ -7,7 +7,7 @@ migraciones, y capas opcionales de producto: REST (FastAPI), GraphQL
|
|
|
7
7
|
(Strawberry), seguridad (RBAC + JWT) y generación de código desde la base de
|
|
8
8
|
datos.
|
|
9
9
|
|
|
10
|
-
> **Estado: experimental (v0.2.
|
|
10
|
+
> **Estado: experimental (v0.2.4).** encino_orm se encuentra en **fase
|
|
11
11
|
> experimental**: la API pública y su comportamiento pueden cambiar **sin previo
|
|
12
12
|
> aviso** en versiones posteriores, **sin garantía de compatibilidad
|
|
13
13
|
> retroactiva**. El núcleo ORM está probado sobre los tres motores (más de 350
|
|
@@ -53,6 +53,9 @@ pip install -e .
|
|
|
53
53
|
# con extras opcionales
|
|
54
54
|
pip install -e ".[http,security,graphql]"
|
|
55
55
|
|
|
56
|
+
# caché en Redis (CachedModel + RedisCacheBackend)
|
|
57
|
+
pip install -e ".[cache]"
|
|
58
|
+
|
|
56
59
|
# motores opcionales (SQL Server / Oracle)
|
|
57
60
|
pip install -e ".[mssql,oracle]"
|
|
58
61
|
```
|
|
@@ -64,6 +67,7 @@ Extras disponibles:
|
|
|
64
67
|
| `http` | `fastapi` (REST CRUD) |
|
|
65
68
|
| `security` | `fastapi` + `PyJWT` (RBAC + JWT) |
|
|
66
69
|
| `graphql` | `strawberry-graphql` (GraphQL) |
|
|
70
|
+
| `cache` | `redis` (`CachedModel` + `RedisCacheBackend`) |
|
|
67
71
|
| `mssql` | `aioodbc` + `pyodbc` (SQL Server, requiere ODBC Driver 18) |
|
|
68
72
|
| `oracle` | `oracledb` (Oracle, modo *thin*) |
|
|
69
73
|
| `all-db` | `mssql` + `oracle` |
|
|
@@ -21,6 +21,7 @@ cada prueba se alimentan por variables de entorno `ENCINO_ORM_*` (ver
|
|
|
21
21
|
| PostgreSQL | `postgres:16-alpine` | 5432 | ~111 MB | Usado en CI. |
|
|
22
22
|
| SQL Server Express | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | ~596 MB | Requiere EULA + contraseña `sa` compleja + ≥ 2 GB RAM. |
|
|
23
23
|
| Oracle XE | `gvenzl/oracle-xe:21-slim` | 1521 | ~720 MB | En Docker Hub (sin `docker login`). |
|
|
24
|
+
| Redis | `redis:latest` | 6379 | ~30 MB | Para `RedisCacheBackend` (extra `cache`). |
|
|
24
25
|
|
|
25
26
|
> Los tamaños son los **comprimidos** medidos con `docker manifest inspect`
|
|
26
27
|
> (linux/amd64); el consumo en disco es mayor. Detalle completo en
|
|
@@ -120,6 +121,13 @@ docker run -d --name mssql-test -p 1433:1433 \
|
|
|
120
121
|
|
|
121
122
|
Instalar el extra: `pip install -e ".[mssql]"`.
|
|
122
123
|
|
|
124
|
+
> **TLS (por defecto seguro):** el motor conecta con `Encrypt=yes` y
|
|
125
|
+
> `TrustServerCertificate=no`. El contenedor local usa un **certificado
|
|
126
|
+
> autofirmado**, por lo que las pruebas activan
|
|
127
|
+
> `ENCINO_ORM_MSSQL_TRUST_CERT=true` para confiar en él. En producción, usa un
|
|
128
|
+
> certificado válido o pasa `trust_server_certificate=True`/`encrypt=False`
|
|
129
|
+
> explícitamente a `MssqlDb.connect(...)`.
|
|
130
|
+
|
|
123
131
|
### 5.1. Instalar el driver ODBC en el host
|
|
124
132
|
|
|
125
133
|
Además del extra de Python (`aioodbc`/`pyodbc`), el host necesita el
|
|
@@ -191,6 +199,19 @@ Instalar el extra: `pip install -e ".[oracle]"`.
|
|
|
191
199
|
|
|
192
200
|
---
|
|
193
201
|
|
|
202
|
+
## 7. Redis
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
docker run -d --name redis-test -p 6379:6379 redis:latest
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Conexión para las pruebas** (`tests/test_redis_cache.py`): URL
|
|
209
|
+
`redis://127.0.0.1:6379`.
|
|
210
|
+
|
|
211
|
+
Instalar el extra: `pip install -e ".[cache]"`.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
194
215
|
## Variables de entorno
|
|
195
216
|
|
|
196
217
|
Cada `tests/test_<motor>.py` lee estas variables (con los valores por defecto de
|
|
@@ -201,8 +222,9 @@ la tabla). En CI se exportan en el paso *Run tests*.
|
|
|
201
222
|
| MySQL | `ENCINO_ORM_MYSQL_HOST/PORT/USER/PASSWORD/DB` | `127.0.0.1` / `3306` / `root` / `admin` / `encino_orm_test` |
|
|
202
223
|
| MariaDB | `ENCINO_ORM_MARIADB_HOST/PORT/USER/PASSWORD/DB` | `127.0.0.1` / `3306` / `root` / `admin` / `encino_orm_test` |
|
|
203
224
|
| PostgreSQL | `ENCINO_ORM_POSTGRES_HOST/PORT/USER/PASSWORD/DB` | `127.0.0.1` / `5432` / `postgres` / `admin` / `encino_orm_test` |
|
|
204
|
-
| SQL Server | `ENCINO_ORM_MSSQL_HOST/PORT/USER/PASSWORD/DB/DRIVER` | `127.0.0.1` / `1433` / `sa` / `Admin_123` / `encino_orm_test` / `ODBC Driver 18 for SQL Server` |
|
|
225
|
+
| SQL Server | `ENCINO_ORM_MSSQL_HOST/PORT/USER/PASSWORD/DB/DRIVER/TRUST_CERT` | `127.0.0.1` / `1433` / `sa` / `Admin_123` / `encino_orm_test` / `ODBC Driver 18 for SQL Server` / `true` |
|
|
205
226
|
| Oracle | `ENCINO_ORM_ORACLE_HOST/PORT/SERVICE/USER/PASSWORD` | `127.0.0.1` / `1521` / `XEPDB1` / `system` / `admin` |
|
|
227
|
+
| Redis | `ENCINO_ORM_REDIS_URL` | `redis://127.0.0.1:6379` |
|
|
206
228
|
|
|
207
229
|
Ejemplo (Linux/macOS):
|
|
208
230
|
|
|
@@ -252,6 +274,10 @@ services:
|
|
|
252
274
|
environment:
|
|
253
275
|
ORACLE_PASSWORD: admin
|
|
254
276
|
ports: ["1521:1521"]
|
|
277
|
+
|
|
278
|
+
redis:
|
|
279
|
+
image: redis:latest
|
|
280
|
+
ports: ["6379:6379"]
|
|
255
281
|
```
|
|
256
282
|
|
|
257
283
|
> MariaDB y MySQL comparten protocolo; si se levantan ambos a la vez, usa puertos
|
|
@@ -7,6 +7,7 @@ Guía rápida para instalar encino_orm y crear tu primer modelo funcional.
|
|
|
7
7
|
```bash
|
|
8
8
|
pip install -e . # núcleo
|
|
9
9
|
pip install -e ".[http,security,graphql]" # capas opcionales (REST, GraphQL, seguridad)
|
|
10
|
+
pip install -e ".[cache]" # caché en Redis (CachedModel + RedisCacheBackend)
|
|
10
11
|
pip install -e ".[mssql,oracle]" # motores SQL Server y Oracle
|
|
11
12
|
pip install -e ".[all-db]" # o todos los motores opcionales de golpe
|
|
12
13
|
```
|
|
@@ -484,6 +484,23 @@ await u.load(duration=300) # cachea 300 s; invalida al actualizar/b
|
|
|
484
484
|
Backends: `MemoryCacheBackend`, `RedisCacheBackend(url=...)`, o cualquiera que
|
|
485
485
|
implemente el `Protocol` `CacheBackend`.
|
|
486
486
|
|
|
487
|
+
### Caché en Redis
|
|
488
|
+
|
|
489
|
+
`RedisCacheBackend` requiere la dependencia opcional `redis`
|
|
490
|
+
(`pip install -e ".[cache]"`) y un Redis en ejecución:
|
|
491
|
+
|
|
492
|
+
```python
|
|
493
|
+
from encino_orm.model import CachedModel, RedisCacheBackend
|
|
494
|
+
|
|
495
|
+
cache = RedisCacheBackend(url="redis://localhost:6379")
|
|
496
|
+
u = User(db, cache=cache, id=1)
|
|
497
|
+
await u.load(duration=300) # GET/SET sobre Redis con TTL
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
La clave se deriva de un `sha1` de `tabla:[clave=valor&...]` y el valor se
|
|
501
|
+
serializa a JSON (`model_dump(mode="json")`). La URL se puede configurar con la
|
|
502
|
+
variable de entorno `ENCINO_ORM_REDIS_URL` en las pruebas.
|
|
503
|
+
|
|
487
504
|
## 11. Esquema y migraciones
|
|
488
505
|
|
|
489
506
|
```python
|
|
@@ -32,6 +32,9 @@ Endpoints generados por modelo (tabla `users`):
|
|
|
32
32
|
(`/api/memberships/{tenant_id}/{code}`).
|
|
33
33
|
- `create_crud` acepta `get_db` para inyectar una dependency propia; por defecto
|
|
34
34
|
usa `session(pool)`.
|
|
35
|
+
- El `PUT` ignora los campos de solo lectura `id`, `enabled`, `created_at` y
|
|
36
|
+
`updated_at` (no se pueden modificar por esta vía; el soft-delete se gestiona
|
|
37
|
+
con `DELETE`).
|
|
35
38
|
|
|
36
39
|
## 2. GraphQL (Strawberry)
|
|
37
40
|
|
|
@@ -60,9 +63,29 @@ from encino_orm.security import (
|
|
|
60
63
|
)
|
|
61
64
|
|
|
62
65
|
token = emit_token("user-1", SECRET)
|
|
63
|
-
payload = verify_token(token, SECRET) # {"sub": "user-1", ...}
|
|
66
|
+
payload = verify_token(token, SECRET) # {"sub": "user-1", "type": "access", ...}
|
|
64
67
|
```
|
|
65
68
|
|
|
69
|
+
### Access vs refresh tokens
|
|
70
|
+
|
|
71
|
+
`emit_token`/`verify_token` gestionan tokens de **acceso** y
|
|
72
|
+
`emit_refresh`/`verify_refresh` tokens de **refresco**. Ambos se distinguen por el
|
|
73
|
+
claim `type` (`"access"`/`"refresh"`): `verify_token` **rechaza** un token de
|
|
74
|
+
refresco y `verify_refresh` **exige** que lo sea, de modo que un access token
|
|
75
|
+
robado no sirve para emitir nuevos tokens:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from encino_orm.security import emit_refresh, verify_refresh
|
|
79
|
+
|
|
80
|
+
refresh = emit_refresh("user-1", SECRET, expires_seconds=604800)
|
|
81
|
+
payload = verify_refresh(refresh, SECRET) # solo acepta type == "refresh"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Los algoritmos se validan contra una lista permitida (`HS256`, `RS256`, `ES256`,
|
|
85
|
+
…) y se rechaza `"none"` tanto al emitir como al verificar. Por compatibilidad,
|
|
86
|
+
`verify_token` sigue aceptando tokens legados **sin** `type` (pero no podrán
|
|
87
|
+
usarse como refresh).
|
|
88
|
+
|
|
66
89
|
### Roles y permisos
|
|
67
90
|
|
|
68
91
|
Las tablas de seguridad se crean con `create_tables(db)` y se siembran con
|
|
@@ -113,7 +136,9 @@ encino_orm generate models mysql --host localhost --user root --password s3cret
|
|
|
113
136
|
```
|
|
114
137
|
|
|
115
138
|
Las claves primarias compuestas y los nombres de columna reservados se detectan
|
|
116
|
-
y se emiten automáticamente (`_primary_key`, `name=
|
|
139
|
+
y se emiten automáticamente (`_primary_key`, `name='...'`). El `_table` y el
|
|
140
|
+
`name=` se generan con `repr`, por lo que los nombres con caracteres especiales
|
|
141
|
+
quedan correctamente escapados en el código generado.
|
|
117
142
|
|
|
118
143
|
## 5. Observabilidad
|
|
119
144
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
3
|
import random
|
|
4
|
+
import re
|
|
4
5
|
from abc import ABC, abstractmethod
|
|
5
6
|
from contextlib import asynccontextmanager
|
|
6
7
|
|
|
@@ -8,6 +9,8 @@ from .query import Query
|
|
|
8
9
|
|
|
9
10
|
logger = logging.getLogger("encino_orm")
|
|
10
11
|
|
|
12
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
13
|
+
|
|
11
14
|
|
|
12
15
|
class Db(ABC):
|
|
13
16
|
MAX_TRIES = 9
|
|
@@ -53,6 +56,13 @@ class Db(ABC):
|
|
|
53
56
|
@abstractmethod
|
|
54
57
|
async def save_point(self, name: str): ...
|
|
55
58
|
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _check_identifier(value: str, label: str) -> str:
|
|
61
|
+
"""Valida que `value` sea un identificador SQL seguro (evita inyección en SAVEPOINT)."""
|
|
62
|
+
if not isinstance(value, str) or not _IDENTIFIER_RE.match(value):
|
|
63
|
+
raise ValueError(f"{label} inválido: {value!r}")
|
|
64
|
+
return value
|
|
65
|
+
|
|
56
66
|
@staticmethod
|
|
57
67
|
async def wait(waiter: int = -1):
|
|
58
68
|
"""Mecanismo de espera en caso de bloqueo por deadlock en la base de datos."""
|
|
@@ -46,6 +46,8 @@ def _build_path_handler(model, get_db, op):
|
|
|
46
46
|
" if not obj._exists:\n"
|
|
47
47
|
" raise HTTPException(404, detail='no encontrado')\n"
|
|
48
48
|
" for k, v in data.model_dump(exclude_unset=True).items():\n"
|
|
49
|
+
" if k in ('id', 'enabled', 'created_at', 'updated_at'):\n"
|
|
50
|
+
" continue\n"
|
|
49
51
|
" setattr(obj, k, v)\n"
|
|
50
52
|
" await obj.update()\n"
|
|
51
53
|
f" return await _cursor(model, db, {kwargs}).load()\n"
|
|
@@ -76,7 +76,7 @@ async def generate_model(db, table: str, *, folder: str,
|
|
|
76
76
|
_DOMAIN_IMPORT,
|
|
77
77
|
"",
|
|
78
78
|
f"class {cls}(Model):",
|
|
79
|
-
f
|
|
79
|
+
f" _table = {table!r}",
|
|
80
80
|
]
|
|
81
81
|
if missing:
|
|
82
82
|
lines.append(f" _fields_disabled = {missing!r}")
|
|
@@ -95,7 +95,7 @@ async def generate_model(db, table: str, *, folder: str,
|
|
|
95
95
|
if not c.nullable:
|
|
96
96
|
args.append("required=True")
|
|
97
97
|
if fname != c.name:
|
|
98
|
-
args.append(f
|
|
98
|
+
args.append(f"name={c.name!r}")
|
|
99
99
|
suffix = f"({', '.join(args)})" if args else "()"
|
|
100
100
|
lines.append(f" {fname}: {type_expr}{suffix}")
|
|
101
101
|
lines.append("")
|
|
@@ -109,11 +109,8 @@ def make_constraint(py_type, *, datatype=None, required=False, name=None,
|
|
|
109
109
|
field_kwargs=field_kwargs,
|
|
110
110
|
validators=validators,
|
|
111
111
|
)
|
|
112
|
-
return Annotated
|
|
113
|
-
t,
|
|
114
|
-
|
|
115
|
-
constraint.to_field(),
|
|
116
|
-
*(AfterValidator(v) for v in validators),
|
|
117
|
-
]
|
|
112
|
+
return Annotated.__class_getitem__(
|
|
113
|
+
(t, constraint, constraint.to_field(), *(AfterValidator(v) for v in validators))
|
|
114
|
+
)
|
|
118
115
|
|
|
119
116
|
return build
|
|
@@ -436,7 +436,10 @@ class Model(BaseModel):
|
|
|
436
436
|
if adapter is None:
|
|
437
437
|
info = cls.model_fields[field]
|
|
438
438
|
metadata = getattr(info, "metadata", None) or []
|
|
439
|
-
|
|
439
|
+
if metadata:
|
|
440
|
+
annotation = Annotated.__class_getitem__((info.annotation, *metadata))
|
|
441
|
+
else:
|
|
442
|
+
annotation = info.annotation
|
|
440
443
|
adapter = TypeAdapter(annotation)
|
|
441
444
|
per_class[field] = adapter
|
|
442
445
|
return adapter
|
|
@@ -738,6 +741,9 @@ class Model(BaseModel):
|
|
|
738
741
|
async def search(self, filter=None, columns=None, limit=None, page: int = 1,
|
|
739
742
|
sort_by: list | None = None, include_deleted: bool = False) -> list:
|
|
740
743
|
columns = columns or ["*"]
|
|
744
|
+
for c in columns:
|
|
745
|
+
if c != "*" and not _IDENTIFIER_RE.match(c):
|
|
746
|
+
raise ValueError(f"columna inválida: {c!r}")
|
|
741
747
|
cols_sql = ", ".join(columns)
|
|
742
748
|
filter = self._effective_filter(filter, include_deleted)
|
|
743
749
|
where = ""
|
|
@@ -73,10 +73,13 @@ class MssqlDb(Db):
|
|
|
73
73
|
user = kwargs["user"]
|
|
74
74
|
password = kwargs["password"]
|
|
75
75
|
database = kwargs.get("db") or kwargs.get("database") or "master"
|
|
76
|
+
encrypt = kwargs.get("encrypt", True)
|
|
77
|
+
trust_cert = kwargs.get("trust_server_certificate", False)
|
|
76
78
|
conn_str = (
|
|
77
79
|
f"DRIVER={{{driver}}};SERVER={host},{port};"
|
|
78
80
|
f"UID={user};PWD={password};DATABASE={database};"
|
|
79
|
-
"Encrypt=no;
|
|
81
|
+
f"Encrypt={'yes' if encrypt else 'no'};"
|
|
82
|
+
f"TrustServerCertificate={'yes' if trust_cert else 'no'};"
|
|
80
83
|
)
|
|
81
84
|
self._connection = await aioodbc.connect(dsn=conn_str)
|
|
82
85
|
self._connection.autocommit = False
|
|
@@ -114,6 +117,7 @@ class MssqlDb(Db):
|
|
|
114
117
|
if self._connection is None:
|
|
115
118
|
return
|
|
116
119
|
if save_point:
|
|
120
|
+
save_point = self._check_identifier(save_point, "savepoint")
|
|
117
121
|
await self._execute_raw(f"ROLLBACK TRANSACTION {save_point}")
|
|
118
122
|
else:
|
|
119
123
|
await self._connection.rollback()
|
|
@@ -121,6 +125,7 @@ class MssqlDb(Db):
|
|
|
121
125
|
|
|
122
126
|
async def save_point(self, name: str):
|
|
123
127
|
if self._connection is not None:
|
|
128
|
+
name = self._check_identifier(name, "savepoint")
|
|
124
129
|
await self._execute_raw(f"SAVE TRANSACTION {name}")
|
|
125
130
|
|
|
126
131
|
def _ensure_connected(self):
|
|
@@ -12,6 +12,7 @@ from .observability import current_trace_id
|
|
|
12
12
|
from .query import Query
|
|
13
13
|
|
|
14
14
|
_PLACEHOLDER_RE = re.compile(r"%\(([A-Za-z0-9_]+)\)s")
|
|
15
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
15
16
|
_MIGRATIONS_TABLE = "_encino_orm_migrations"
|
|
16
17
|
|
|
17
18
|
|
|
@@ -87,12 +88,14 @@ class MysqlDb(Db):
|
|
|
87
88
|
if self._connection is None:
|
|
88
89
|
return
|
|
89
90
|
if save_point:
|
|
91
|
+
save_point = self._check_identifier(save_point, "savepoint")
|
|
90
92
|
await self._execute_raw(f"ROLLBACK TO SAVEPOINT {save_point}")
|
|
91
93
|
else:
|
|
92
94
|
await self._connection.rollback()
|
|
93
95
|
|
|
94
96
|
async def save_point(self, name: str):
|
|
95
97
|
if self._connection is not None:
|
|
98
|
+
name = self._check_identifier(name, "savepoint")
|
|
96
99
|
await self._execute_raw(f"SAVEPOINT {name}")
|
|
97
100
|
|
|
98
101
|
def _ensure_connected(self):
|
|
@@ -110,6 +113,8 @@ class MysqlDb(Db):
|
|
|
110
113
|
)
|
|
111
114
|
|
|
112
115
|
async def columns_of(self, table: str) -> list[ColumnSpec]:
|
|
116
|
+
if not _IDENTIFIER_RE.match(table):
|
|
117
|
+
raise ValueError(f"nombre de tabla inválido: {table!r}")
|
|
113
118
|
rows = await self.fetch_all(Query(f"SHOW COLUMNS FROM {table}", []))
|
|
114
119
|
return [
|
|
115
120
|
ColumnSpec(
|
|
@@ -111,6 +111,7 @@ class OracleDb(Db):
|
|
|
111
111
|
if self._connection is None:
|
|
112
112
|
return
|
|
113
113
|
if save_point:
|
|
114
|
+
save_point = self._check_identifier(save_point, "savepoint")
|
|
114
115
|
await self._execute_raw(f"ROLLBACK TO {save_point}")
|
|
115
116
|
else:
|
|
116
117
|
await self._connection.rollback()
|
|
@@ -118,6 +119,7 @@ class OracleDb(Db):
|
|
|
118
119
|
|
|
119
120
|
async def save_point(self, name: str):
|
|
120
121
|
if self._connection is not None:
|
|
122
|
+
name = self._check_identifier(name, "savepoint")
|
|
121
123
|
await self._execute_raw(f"SAVEPOINT {name}")
|
|
122
124
|
|
|
123
125
|
def _ensure_connected(self):
|
|
@@ -96,12 +96,14 @@ class PostgresDb(Db):
|
|
|
96
96
|
if self._connection is None:
|
|
97
97
|
return
|
|
98
98
|
if save_point:
|
|
99
|
+
save_point = self._check_identifier(save_point, "savepoint")
|
|
99
100
|
await self._connection.execute(f"ROLLBACK TO SAVEPOINT {save_point}")
|
|
100
101
|
else:
|
|
101
102
|
await self._connection.execute("ROLLBACK")
|
|
102
103
|
|
|
103
104
|
async def save_point(self, name: str):
|
|
104
105
|
if self._connection is not None:
|
|
106
|
+
name = self._check_identifier(name, "savepoint")
|
|
105
107
|
await self._connection.execute(f"SAVEPOINT {name}")
|
|
106
108
|
|
|
107
109
|
def _ensure_connected(self):
|
|
@@ -14,6 +14,20 @@ def _now() -> int:
|
|
|
14
14
|
return int(time.time())
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
_ALLOWED_ALGORITHMS = frozenset({
|
|
18
|
+
"HS256", "HS384", "HS512",
|
|
19
|
+
"RS256", "RS384", "RS512",
|
|
20
|
+
"ES256", "ES384", "ES512",
|
|
21
|
+
"PS256", "PS384", "PS512",
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_algorithm(algorithm: str) -> str:
|
|
26
|
+
if algorithm not in _ALLOWED_ALGORITHMS:
|
|
27
|
+
raise ValueError(f"algoritmo JWT no permitido: {algorithm!r}")
|
|
28
|
+
return algorithm
|
|
29
|
+
|
|
30
|
+
|
|
17
31
|
def _jwt():
|
|
18
32
|
try:
|
|
19
33
|
import jwt
|
|
@@ -28,29 +42,56 @@ def _jwt():
|
|
|
28
42
|
def emit_token(user_id: str, secret: str, expires_seconds: int = 900,
|
|
29
43
|
algorithm: str = "HS256", **claims) -> str:
|
|
30
44
|
jwt = _jwt()
|
|
31
|
-
|
|
45
|
+
_check_algorithm(algorithm)
|
|
46
|
+
payload = {"sub": user_id, "type": "access", "iat": _now(),
|
|
47
|
+
"exp": _now() + expires_seconds, **claims}
|
|
32
48
|
return jwt.encode(payload, secret, algorithm=algorithm)
|
|
33
49
|
|
|
34
50
|
|
|
51
|
+
def _filter_algorithms(algorithms: list[str] | None) -> list[str]:
|
|
52
|
+
algorithms = algorithms or ["HS256"]
|
|
53
|
+
for a in algorithms:
|
|
54
|
+
if a not in _ALLOWED_ALGORITHMS:
|
|
55
|
+
raise ValueError(f"algoritmo JWT no permitido: {a!r}")
|
|
56
|
+
return list(algorithms)
|
|
57
|
+
|
|
58
|
+
|
|
35
59
|
def verify_token(token: str, secret: str, algorithms: list[str] | None = None) -> dict:
|
|
36
60
|
jwt = _jwt()
|
|
37
|
-
algorithms = algorithms
|
|
61
|
+
algorithms = _filter_algorithms(algorithms)
|
|
38
62
|
try:
|
|
39
|
-
|
|
63
|
+
payload = jwt.decode(
|
|
40
64
|
token, secret, algorithms=algorithms, options={"require": ["exp"]}
|
|
41
65
|
)
|
|
42
66
|
except jwt.ExpiredSignatureError as exc:
|
|
43
67
|
raise AuthenticationError("token caducado") from exc
|
|
44
68
|
except jwt.InvalidTokenError as exc:
|
|
45
69
|
raise AuthenticationError("token inválido") from exc
|
|
70
|
+
if payload.get("type") == "refresh":
|
|
71
|
+
raise AuthenticationError("token de refresco no válido como access")
|
|
72
|
+
return payload
|
|
46
73
|
|
|
47
74
|
|
|
48
75
|
def emit_refresh(user_id: str, secret: str, expires_seconds: int = 604800,
|
|
49
76
|
algorithm: str = "HS256", **claims) -> str:
|
|
50
77
|
jwt = _jwt()
|
|
51
|
-
|
|
78
|
+
_check_algorithm(algorithm)
|
|
79
|
+
payload = {"sub": user_id, "type": "refresh", "iat": _now(),
|
|
80
|
+
"exp": _now() + expires_seconds, **claims}
|
|
52
81
|
return jwt.encode(payload, secret, algorithm=algorithm)
|
|
53
82
|
|
|
54
83
|
|
|
55
84
|
def verify_refresh(token: str, secret: str, algorithms: list[str] | None = None) -> dict:
|
|
56
|
-
|
|
85
|
+
jwt = _jwt()
|
|
86
|
+
algorithms = _filter_algorithms(algorithms)
|
|
87
|
+
try:
|
|
88
|
+
payload = jwt.decode(
|
|
89
|
+
token, secret, algorithms=algorithms, options={"require": ["exp"]}
|
|
90
|
+
)
|
|
91
|
+
except jwt.ExpiredSignatureError as exc:
|
|
92
|
+
raise AuthenticationError("token caducado") from exc
|
|
93
|
+
except jwt.InvalidTokenError as exc:
|
|
94
|
+
raise AuthenticationError("token inválido") from exc
|
|
95
|
+
if payload.get("type") != "refresh":
|
|
96
|
+
raise AuthenticationError("token no es de refresco")
|
|
97
|
+
return payload
|
|
@@ -10,6 +10,7 @@ from .observability import current_trace_id
|
|
|
10
10
|
from .query import Query
|
|
11
11
|
|
|
12
12
|
_PLACEHOLDER_RE = re.compile(r"%\(([A-Za-z0-9_]+)\)s")
|
|
13
|
+
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
13
14
|
_MIGRATIONS_TABLE = "_encino_orm_migrations"
|
|
14
15
|
|
|
15
16
|
|
|
@@ -80,12 +81,14 @@ class SqliteDb(Db):
|
|
|
80
81
|
if not self._connection:
|
|
81
82
|
return
|
|
82
83
|
if save_point:
|
|
84
|
+
save_point = self._check_identifier(save_point, "savepoint")
|
|
83
85
|
await self._connection.execute(f"ROLLBACK TO SAVEPOINT {save_point}")
|
|
84
86
|
else:
|
|
85
87
|
await self._connection.rollback()
|
|
86
88
|
|
|
87
89
|
async def save_point(self, name: str):
|
|
88
90
|
if self._connection:
|
|
91
|
+
name = self._check_identifier(name, "savepoint")
|
|
89
92
|
await self._connection.execute(f"SAVEPOINT {name}")
|
|
90
93
|
|
|
91
94
|
def _ensure_connected(self):
|
|
@@ -103,6 +106,8 @@ class SqliteDb(Db):
|
|
|
103
106
|
)
|
|
104
107
|
|
|
105
108
|
async def columns_of(self, table: str) -> list[ColumnSpec]:
|
|
109
|
+
if not _IDENTIFIER_RE.match(table):
|
|
110
|
+
raise ValueError(f"nombre de tabla inválido: {table!r}")
|
|
106
111
|
rows = await self.fetch_all(Query(f"PRAGMA table_info({table})", []))
|
|
107
112
|
return [
|
|
108
113
|
ColumnSpec(
|
|
@@ -7,7 +7,7 @@ packages = ["encino_orm"]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "encino-orm"
|
|
10
|
-
version = "0.2.
|
|
10
|
+
version = "0.2.4"
|
|
11
11
|
description = "Librería asíncrona de interfaz unificada para SQLite, MySQL y PostgreSQL"
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
13
|
license = "MIT"
|
|
@@ -26,6 +26,7 @@ dependencies = [
|
|
|
26
26
|
security = ["fastapi>=0.110", "PyJWT>=2.8,<2.13"]
|
|
27
27
|
http = ["fastapi>=0.110"]
|
|
28
28
|
graphql = ["strawberry-graphql>=0.200"]
|
|
29
|
+
cache = ["redis>=5.0"]
|
|
29
30
|
mssql = ["aioodbc>=0.5", "pyodbc>=5.0"]
|
|
30
31
|
oracle = ["oracledb>=2.0"]
|
|
31
32
|
all-db = ["aioodbc>=0.5", "pyodbc>=5.0", "oracledb>=2.0"]
|
|
@@ -15,6 +15,9 @@ MSSQL_CONFIG = {
|
|
|
15
15
|
"password": os.getenv("ENCINO_ORM_MSSQL_PASSWORD", "Admin_123"),
|
|
16
16
|
"db": os.getenv("ENCINO_ORM_MSSQL_DB", "encino_orm_test"),
|
|
17
17
|
"driver": os.getenv("ENCINO_ORM_MSSQL_DRIVER", "ODBC Driver 18 for SQL Server"),
|
|
18
|
+
# El contenedor local usa certificado autofirmado; se confía solo en pruebas.
|
|
19
|
+
"trust_server_certificate": os.getenv("ENCINO_ORM_MSSQL_TRUST_CERT", "true").lower()
|
|
20
|
+
in ("1", "true", "yes"),
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
|