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.
Files changed (141) hide show
  1. {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/publish-testpypi.yml +0 -2
  2. {encino_orm-0.2.2 → encino_orm-0.2.4}/CHANGELOG.md +33 -0
  3. {encino_orm-0.2.2 → encino_orm-0.2.4}/PKG-INFO +9 -3
  4. {encino_orm-0.2.2 → encino_orm-0.2.4}/README.md +6 -2
  5. {encino_orm-0.2.2 → encino_orm-0.2.4}/docker-compose.yml +5 -1
  6. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/docker.md +27 -1
  7. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/getting-started.md +1 -0
  8. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/guide.md +17 -0
  9. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/integrations.md +27 -2
  10. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/base.py +10 -0
  11. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/routes.py +2 -0
  12. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/codegen.py +2 -2
  13. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/constraint.py +3 -6
  14. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/model.py +7 -1
  15. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/query_builder.py +1 -0
  16. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/mssql.py +6 -1
  17. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/mysql.py +5 -0
  18. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/oracle.py +2 -0
  19. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/postgresql.py +2 -0
  20. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/jwt.py +46 -5
  21. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/sqlite.py +5 -0
  22. {encino_orm-0.2.2 → encino_orm-0.2.4}/pyproject.toml +2 -1
  23. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_from_db.py +1 -1
  24. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_mssql.py +3 -0
  25. encino_orm-0.2.4/tests/test_redis_cache.py +78 -0
  26. {encino_orm-0.2.2 → encino_orm-0.2.4}/uv.lock +18 -2
  27. {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/ci.yml +0 -0
  28. {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/docs.yml +0 -0
  29. {encino_orm-0.2.2 → encino_orm-0.2.4}/.github/workflows/release.yml +0 -0
  30. {encino_orm-0.2.2 → encino_orm-0.2.4}/.gitignore +0 -0
  31. {encino_orm-0.2.2 → encino_orm-0.2.4}/LICENSE +0 -0
  32. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/credits.md +0 -0
  33. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/0-design.md +0 -0
  34. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/1-model.md +0 -0
  35. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/2-constraint.md +0 -0
  36. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/3-graphql.md +0 -0
  37. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/4-crud.md +0 -0
  38. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/5-security.md +0 -0
  39. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/6-from_db.md +0 -0
  40. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/7-missing.md +0 -0
  41. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/8-pk.md +0 -0
  42. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/9-singleton.md +0 -0
  43. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/a-xpress.md +0 -0
  44. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/design/has_many.md +0 -0
  45. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/engines.md +0 -0
  46. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/index.md +0 -0
  47. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/context.md +0 -0
  48. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/db.md +0 -0
  49. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/filter.md +0 -0
  50. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/migration.md +0 -0
  51. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/model.md +0 -0
  52. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/observability.md +0 -0
  53. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/query_builder.md +0 -0
  54. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/relations.md +0 -0
  55. {encino_orm-0.2.2 → encino_orm-0.2.4}/docs/reference/sql.md +0 -0
  56. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/__init__.py +0 -0
  57. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/_rows.py +0 -0
  58. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/cli.py +0 -0
  59. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/context.py +0 -0
  60. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/engine.py +0 -0
  61. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/exceptions.py +0 -0
  62. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/__init__.py +0 -0
  63. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/filters.py +0 -0
  64. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/resolvers.py +0 -0
  65. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/scalars.py +0 -0
  66. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/schema.py +0 -0
  67. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/graphql/types.py +0 -0
  68. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/__init__.py +0 -0
  69. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/errors.py +0 -0
  70. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/parsing.py +0 -0
  71. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/http/registry.py +0 -0
  72. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/__init__.py +0 -0
  73. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/tables.py +0 -0
  74. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/introspection/types.py +0 -0
  75. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/mariadb.py +0 -0
  76. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/migration.py +0 -0
  77. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/__init__.py +0 -0
  78. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/cache_backend.py +0 -0
  79. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/cached.py +0 -0
  80. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/column.py +0 -0
  81. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/domain.py +0 -0
  82. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/exceptions.py +0 -0
  83. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/filter.py +0 -0
  84. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/hooks.py +0 -0
  85. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/index.py +0 -0
  86. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/records.py +0 -0
  87. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/references.py +0 -0
  88. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/scope.py +0 -0
  89. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/model/types.py +0 -0
  90. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/observability.py +0 -0
  91. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/pool.py +0 -0
  92. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/query.py +0 -0
  93. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/__init__.py +0 -0
  94. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/exceptions.py +0 -0
  95. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/guard.py +0 -0
  96. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/models.py +0 -0
  97. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/security/permissions.py +0 -0
  98. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/sql.py +0 -0
  99. {encino_orm-0.2.2 → encino_orm-0.2.4}/encino_orm/transfer.py +0 -0
  100. {encino_orm-0.2.2 → encino_orm-0.2.4}/mkdocs.yml +0 -0
  101. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/__init__.py +0 -0
  102. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/conftest.py +0 -0
  103. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_aggregates.py +0 -0
  104. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_base.py +0 -0
  105. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_bulk_upsert.py +0 -0
  106. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_cached_model.py +0 -0
  107. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_cli.py +0 -0
  108. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_constraint.py +0 -0
  109. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_crud.py +0 -0
  110. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_d_recommendations.py +0 -0
  111. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_decimal_json.py +0 -0
  112. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_e_recommendations.py +0 -0
  113. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_engine.py +0 -0
  114. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_f_recommendations.py +0 -0
  115. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_g_recommendations.py +0 -0
  116. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_graphql.py +0 -0
  117. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_has_many.py +0 -0
  118. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_hooks.py +0 -0
  119. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_improvements.py +0 -0
  120. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_index.py +0 -0
  121. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_issues.py +0 -0
  122. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_mariadb.py +0 -0
  123. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_migrations.py +0 -0
  124. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_model.py +0 -0
  125. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_mysql.py +0 -0
  126. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_observability.py +0 -0
  127. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_oracle.py +0 -0
  128. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_pk.py +0 -0
  129. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_pool.py +0 -0
  130. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_postgresql.py +0 -0
  131. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_query_builder.py +0 -0
  132. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_records.py +0 -0
  133. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_references.py +0 -0
  134. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_scalar_types.py +0 -0
  135. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_scope_softdelete.py +0 -0
  136. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_security.py +0 -0
  137. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_singleton.py +0 -0
  138. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_sql_functions.py +0 -0
  139. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_sqlite.py +0 -0
  140. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_transfer.py +0 -0
  141. {encino_orm-0.2.2 → encino_orm-0.2.4}/tests/test_types.py +0 -0
@@ -2,8 +2,6 @@ name: Publish to TestPyPI
2
2
 
3
3
  on:
4
4
  workflow_dispatch:
5
- push:
6
- branches: [main]
7
5
 
8
6
  permissions:
9
7
  contents: read
@@ -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.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.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.2).** encino_orm se encuentra en **fase
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.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.2).** encino_orm se encuentra en **fase
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` |
@@ -33,4 +33,8 @@ services:
33
33
  image: gvenzl/oracle-xe:21-slim
34
34
  environment:
35
35
  ORACLE_PASSWORD: admin
36
- ports: ["1521:1521"]
36
+ ports: ["1521:1521"]
37
+
38
+ redis:
39
+ image: redis:latest
40
+ ports: ["6379:6379"]
@@ -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' _table = "{table}"',
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'name="{c.name}"')
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
- constraint,
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
- annotation = Annotated[info.annotation, *metadata] if metadata else info.annotation
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 = ""
@@ -241,6 +241,7 @@ class QueryBuilder:
241
241
 
242
242
  async def sum(self, column: str):
243
243
  self._ensure_db()
244
+ column = _safe_column(column)
244
245
  sql, params = self._build_base()
245
246
  sql = f"SELECT SUM({column}) {sql}"
246
247
  row = await self._db.fetch_one(Query(sql, params))
@@ -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;TrustServerCertificate=yes;"
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
- payload = {"sub": user_id, "iat": _now(), "exp": _now() + expires_seconds, **claims}
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 or ["HS256"]
61
+ algorithms = _filter_algorithms(algorithms)
38
62
  try:
39
- return jwt.decode(
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
- payload = {"sub": user_id, "iat": _now(), "exp": _now() + expires_seconds, **claims}
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
- return verify_token(token, secret, algorithms)
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.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"]
@@ -220,4 +220,4 @@ class TestGenerateModel:
220
220
  text = path.read_text(encoding="utf-8")
221
221
  assert "class LineaDetalle(Model):" in text
222
222
  # "order" es palabra reservada -> atributo con sufijo y name= mapeado
223
- assert 'name="order"' in text
223
+ assert "name='order'" in text
@@ -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