tina4-python 3.13.10__tar.gz → 3.13.11__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 (157) hide show
  1. {tina4_python-3.13.10 → tina4_python-3.13.11}/PKG-INFO +1 -1
  2. {tina4_python-3.13.10 → tina4_python-3.13.11}/pyproject.toml +1 -1
  3. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/connection.py +21 -3
  5. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/postgres.py +50 -2
  6. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/orm/fields.py +25 -1
  7. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/orm/model.py +83 -10
  8. {tina4_python-3.13.10 → tina4_python-3.13.11}/.gitignore +0 -0
  9. {tina4_python-3.13.10 → tina4_python-3.13.11}/README.md +0 -0
  10. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/CLAUDE.md +0 -0
  11. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/HtmlElement.py +0 -0
  12. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/Testing.py +0 -0
  13. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/ai/__init__.py +0 -0
  14. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/api/__init__.py +0 -0
  15. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/auth/__init__.py +0 -0
  16. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/cache/__init__.py +0 -0
  17. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/cli/__init__.py +0 -0
  18. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/__init__.py +0 -0
  20. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/cache.py +0 -0
  21. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/constants.py +0 -0
  22. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/events.py +0 -0
  23. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/middleware.py +0 -0
  24. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/rate_limiter.py +0 -0
  25. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/request.py +0 -0
  26. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/response.py +0 -0
  27. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/router.py +0 -0
  28. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/core/server.py +0 -0
  29. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/crud/__init__.py +0 -0
  30. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/__init__.py +0 -0
  31. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/adapter.py +0 -0
  32. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/firebird.py +0 -0
  33. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/mongodb.py +0 -0
  34. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/mssql.py +0 -0
  35. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/mysql.py +0 -0
  36. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/odbc.py +0 -0
  37. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/database/sqlite.py +0 -0
  38. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/debug/__init__.py +0 -0
  39. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/debug/error_overlay.py +0 -0
  40. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/dev_admin/__init__.py +0 -0
  41. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/dev_admin/metrics.py +0 -0
  42. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/dev_admin/plan.py +0 -0
  43. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/dev_admin/project_index.py +0 -0
  44. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/docs.py +0 -0
  45. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/dotenv/__init__.py +0 -0
  46. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/env.py +0 -0
  47. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/frond/FROND.md +0 -0
  48. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/frond/__init__.py +0 -0
  49. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/frond/engine.py +0 -0
  50. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/auth/meta.json +0 -0
  51. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  52. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/database/meta.json +0 -0
  53. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  54. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/error-overlay/meta.json +0 -0
  55. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  56. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/orm/meta.json +0 -0
  57. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  58. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  59. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/queue/meta.json +0 -0
  60. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  61. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/rest-api/meta.json +0 -0
  62. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  63. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/templates/meta.json +0 -0
  64. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  65. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  66. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/graphql/__init__.py +0 -0
  67. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/i18n/__init__.py +0 -0
  68. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/mcp/__init__.py +0 -0
  69. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/mcp/protocol.py +0 -0
  70. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/mcp/tools.py +0 -0
  71. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.10 → tina4_python-3.13.11}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.10
3
+ Version: 3.13.11
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.10"
3
+ version = "3.13.11"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.13.10"
11
+ __version__ = "3.13.11"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -421,7 +421,14 @@ class Database:
421
421
 
422
422
  def fetch(self, sql: str, params: list = None,
423
423
  limit: int = 100, offset: int = 0) -> DatabaseResult:
424
- """Fetch rows with pagination."""
424
+ """Fetch rows with pagination.
425
+
426
+ v3.13.11 (issue #49 Gap 3): mirror :meth:`execute` and capture
427
+ ``last_error`` here too — pre-v3.13.11 a failed ``fetch`` left
428
+ ``db.get_error()`` returning ``None`` even though the adapter
429
+ had stored the failure on its own ``last_error``. Callers had
430
+ no way to read the cause via the public API.
431
+ """
425
432
  if self._cache_enabled:
426
433
  key = self._cache_key(sql + f":L{limit}:S{offset}", params)
427
434
  cached = self._cache_get(key)
@@ -430,13 +437,24 @@ class Database:
430
437
  self._cache_hits += 1
431
438
  return cached
432
439
  adapter = self._get_adapter()
433
- result = adapter.fetch(sql, params, limit, offset)
440
+ try:
441
+ result = adapter.fetch(sql, params, limit, offset)
442
+ self.last_error = None
443
+ except Exception:
444
+ self.last_error = getattr(adapter, "last_error", None) or self.last_error
445
+ raise
434
446
  self._cache_set(key, result)
435
447
  with self._cache_lock:
436
448
  self._cache_misses += 1
437
449
  return result
438
450
  adapter = self._get_adapter()
439
- return adapter.fetch(sql, params, limit, offset)
451
+ try:
452
+ result = adapter.fetch(sql, params, limit, offset)
453
+ self.last_error = None
454
+ return result
455
+ except Exception:
456
+ self.last_error = getattr(adapter, "last_error", None) or self.last_error
457
+ raise
440
458
 
441
459
  def fetch_all(self, sql: str, params: list = None,
442
460
  limit: int = 100, offset: int = 0) -> list[dict]:
@@ -137,7 +137,16 @@ class PostgreSQLAdapter(DatabaseAdapter):
137
137
  raise
138
138
 
139
139
  def _on_query_error(self, exc: Exception, sql: str, params=None):
140
- """Handle a failed query: log, store, auto-rollback if implicit."""
140
+ """Handle a failed query: log, store, auto-rollback if implicit.
141
+
142
+ v3.13.11 (issue #49 Gap 1): inside an explicit transaction the
143
+ auto-rollback step defers to the user — they may be running a
144
+ SAVEPOINT/retry pattern. The visibility half stays: the original
145
+ cause is still logged via ``Log.error``, and an additional
146
+ ``Log.warning`` makes the context explicit so a tail-the-log
147
+ operator can spot the upstream cause that's about to be buried
148
+ by a downstream ``InFailedSqlTransaction`` cascade.
149
+ """
141
150
  # Lazy import — avoid circular import at module load and let the
142
151
  # framework boot without Log if something exotic is happening.
143
152
  try:
@@ -148,6 +157,15 @@ class PostgreSQLAdapter(DatabaseAdapter):
148
157
  sql=preview,
149
158
  params=params,
150
159
  )
160
+ # v3.13.11: extra in-txn marker so the original cause is
161
+ # easy to spot above the cascade line.
162
+ if self._in_transaction:
163
+ Log.warning(
164
+ "Above failure occurred inside an explicit transaction — "
165
+ "auto-rollback skipped (caller owns the transaction). "
166
+ "Subsequent queries on this connection will cascade with "
167
+ "'current transaction is aborted' until rollback() is called."
168
+ )
151
169
  except Exception:
152
170
  pass
153
171
 
@@ -166,6 +184,29 @@ class PostgreSQLAdapter(DatabaseAdapter):
166
184
  # Don't mask the original error if rollback itself fails.
167
185
  pass
168
186
 
187
+ def _log_silent_probe_failure(self, exc: Exception, sql: str, params=None):
188
+ """v3.13.11 (issue #49 Gap 1): log a probe-query failure as a
189
+ warning WITHOUT touching ``last_error`` or rolling back.
190
+
191
+ Used by the COUNT probe in :meth:`fetch` which is best-effort —
192
+ it's allowed to fail without surfacing to the caller — but inside
193
+ an explicit transaction the connection is now poisoned, the
194
+ paginated query that follows will cascade, and the real cause
195
+ used to be lost entirely. This logs it so an operator scrolling
196
+ back through the log can spot the upstream cause.
197
+ """
198
+ try:
199
+ from tina4_python.debug import Log
200
+ preview = sql.strip().splitlines()[0][:200] if sql else "<no sql>"
201
+ Log.warning(
202
+ f"PostgreSQL probe query failed (original cause, before "
203
+ f"any cascade): {exc.__class__.__name__}: {exc}",
204
+ sql=preview,
205
+ params=params,
206
+ )
207
+ except Exception:
208
+ pass
209
+
169
210
  def connect(self, connection_string: str, username: str = "", password: str = "", **kwargs):
170
211
  """Connect to PostgreSQL.
171
212
 
@@ -280,8 +321,15 @@ class PostgreSQLAdapter(DatabaseAdapter):
280
321
  try:
281
322
  self._safe_execute(cursor, count_sql, params)
282
323
  total = cursor.fetchone()["cnt"]
283
- except Exception:
324
+ except Exception as e:
284
325
  total = 0
326
+ # v3.13.11 (issue #49 Gap 1): log the probe failure as a
327
+ # warning so the original cause appears in the log even
328
+ # though the probe itself swallows. Without this line, a
329
+ # failure inside an explicit transaction is invisible —
330
+ # the cascade message from the paginated query below is
331
+ # all the operator sees.
332
+ self._log_silent_probe_failure(e, count_sql, params)
285
333
  # If the probe failed because the connection arrived (or
286
334
  # became) aborted, roll back so the real pagination query
287
335
  # below sees a clean txn. Without this we'd cascade.
@@ -51,12 +51,36 @@ class Field:
51
51
  self.choices = choices
52
52
  self.validator = validator # Custom callable(value) → raises ValueError
53
53
 
54
+ # ── v3.13.11 (issue #50): callable-default resolution ──────────────
55
+ #
56
+ # Pre-v3.13.11 a callable default (e.g. ``DateTimeField(default=lambda:
57
+ # datetime.now())``) reached the driver verbatim and blew up with
58
+ # ``can't adapt type 'function'``. Now we evaluate the callable at the
59
+ # point of use, so every instance gets a freshly computed value (per-row
60
+ # timestamps actually differ). Types are excluded — ``default=int`` is
61
+ # almost never intended to mean "call int()".
62
+ def _resolve_default(self):
63
+ d = self.default
64
+ if callable(d) and not isinstance(d, type):
65
+ return d()
66
+ return d
67
+
54
68
  def validate(self, value):
55
69
  """Validate and coerce value to the field type."""
56
70
  if value is None:
57
71
  if self.required and self.default is None:
58
72
  raise ValueError(f"Field '{self.name}' is required")
59
- return self.default
73
+ return self._resolve_default()
74
+
75
+ # v3.13.11 (issue #50): if the user stored a callable as the field
76
+ # value (e.g. ``default=lambda: datetime.now()`` reached us without
77
+ # being resolved on instance init), evaluate it now so the driver
78
+ # gets a real value, not the function object. Types are excluded —
79
+ # ``default=int`` is almost never intended to mean "call int()".
80
+ if callable(value) and not isinstance(value, type):
81
+ value = value()
82
+ if value is None:
83
+ return None
60
84
 
61
85
  # Type coercion.
62
86
  #
@@ -177,9 +177,14 @@ class ORM(metaclass=ORMMeta):
177
177
  # Initialize relationship cache
178
178
  self._rel_cache = {}
179
179
 
180
- # Set defaults from field definitions
180
+ # Set defaults from field definitions.
181
+ # v3.13.11 (issue #50): callable defaults are evaluated *per
182
+ # instance* so per-row timestamps (e.g. ``default=lambda:
183
+ # datetime.now()``) actually differ. Pre-v3.13.11 the function
184
+ # object reached the driver verbatim and blew up with
185
+ # ``can't adapt type 'function'``.
181
186
  for name, field in self._fields.items():
182
- setattr(self, name, field.default)
187
+ setattr(self, name, field._resolve_default())
183
188
 
184
189
  # Accept JSON string or dict
185
190
  if isinstance(data, str):
@@ -304,10 +309,22 @@ class ORM(metaclass=ORMMeta):
304
309
  # ── CRUD ────────────────────────────────────────────────────
305
310
 
306
311
  def save(self) -> Self | bool:
307
- """Insert or update. Returns self on success, False on failure."""
312
+ """Insert or update. Returns self on success, False on failure.
313
+
314
+ v3.13.11 (issue #50): for non-auto-increment primary keys (e.g.
315
+ user-supplied string IDs like ``"GC-100"``), the decision between
316
+ INSERT and UPDATE is made on whether the row *exists*, not on
317
+ whether the PK is set. Pre-v3.13.11 a natural-key save() always
318
+ chose UPDATE — matched zero rows — and silently returned success
319
+ without inserting anything.
320
+
321
+ Auto-increment behaviour is unchanged: ``pk_value is None`` means
322
+ "new row, let the engine assign an ID" and we still INSERT.
323
+ """
308
324
  db = self._get_db()
309
325
  pk = self._get_pk()
310
326
  pk_value = getattr(self, pk, None)
327
+ pk_field = self._fields[pk]
311
328
  table = self._get_table()
312
329
  pk_db_col = self.field_mapping.get(pk, self._fields[pk].column)
313
330
 
@@ -316,22 +333,52 @@ class ORM(metaclass=ORMMeta):
316
333
  if field.auto_increment and pk_value is None:
317
334
  continue # Skip auto-increment on insert
318
335
  value = getattr(self, name)
336
+ # v3.13.11 (issue #50): resolve callable values at write time
337
+ # too, in case the user set ``self.created_at = lambda: ...``
338
+ # directly. Defensive — Field.validate() already handles this
339
+ # for the normal __init__ + _populate paths.
340
+ if callable(value) and not isinstance(value, type):
341
+ value = value()
319
342
  if value is not None or not field.auto_increment:
320
343
  # Use field_mapping for the column name, fall back to field.column
321
344
  db_col = self.field_mapping.get(name, field.column)
322
345
  data[db_col] = value
323
346
 
347
+ # v3.13.11 (issue #50): pick INSERT vs UPDATE on row existence
348
+ # for non-auto-increment PKs. Auto-increment keeps the legacy
349
+ # behaviour (PK is None → INSERT, PK is set → UPDATE).
350
+ is_update = False
351
+ if pk_value is not None:
352
+ if pk_field.auto_increment:
353
+ is_update = True
354
+ else:
355
+ # Natural-key model — check if the row already exists.
356
+ # type(self).exists() handles soft-delete + scope filters
357
+ # the same way the rest of the ORM does.
358
+ try:
359
+ is_update = type(self).exists(pk_value)
360
+ except Exception:
361
+ # If we can't tell (e.g. table doesn't exist yet),
362
+ # fall back to INSERT — the user will see the real
363
+ # error from the driver instead of a silent no-op.
364
+ is_update = False
365
+
324
366
  db.start_transaction()
325
367
  try:
326
- if pk_value is not None:
368
+ if is_update:
327
369
  update_data = {k: v for k, v in data.items() if k != pk_db_col}
328
370
  if update_data:
329
371
  db.update(table, update_data, f"{pk_db_col} = ?", [pk_value])
330
372
  else:
331
373
  db.insert(table, data)
332
- last_id = db.get_last_id()
333
- if last_id and pk in self._fields:
334
- setattr(self, pk, last_id)
374
+ # Only adopt the engine-assigned ID for auto-increment PKs.
375
+ # Natural-key PKs were already set by the caller; don't
376
+ # overwrite them with the driver's last_id (which on PG
377
+ # may be a sequence value that doesn't apply here).
378
+ if pk_field.auto_increment:
379
+ last_id = db.get_last_id()
380
+ if last_id and pk in self._fields:
381
+ setattr(self, pk, last_id)
335
382
  db.commit()
336
383
  except Exception:
337
384
  db.rollback()
@@ -673,10 +720,21 @@ class ORM(metaclass=ORMMeta):
673
720
  StringField → VARCHAR(255)
674
721
  TextField → TEXT
675
722
  NumericField/FloatField → REAL
676
- BooleanField → INTEGER
723
+ BooleanField → engine-aware (see below)
677
724
  DateTimeField → DATETIME
678
725
  BlobField → BLOB
679
726
 
727
+ v3.13.11 BooleanField mapping (engine-aware):
728
+ SQLite → INTEGER (no native bool — historic convention)
729
+ PostgreSQL → BOOLEAN (native type; psycopg2 binds Python
730
+ ``bool`` as BOOLEAN, so INTEGER columns
731
+ caused ``operator does not exist:
732
+ boolean = integer``)
733
+ MySQL → BOOLEAN (synonym for TINYINT(1))
734
+ MSSQL → BIT (the SQL Server bool type)
735
+ Firebird → INTEGER (driver round-trip is uneven; integer
736
+ is the safer choice on Firebird)
737
+
680
738
  Auto-increment primary keys use engine-appropriate syntax.
681
739
  Returns True on success.
682
740
  """
@@ -684,6 +742,21 @@ class ORM(metaclass=ORMMeta):
684
742
 
685
743
  db = cls._get_db()
686
744
  table = cls._get_table()
745
+ engine = (db.get_database_type() or "").lower()
746
+
747
+ # v3.13.11: BooleanField now uses each engine's native type
748
+ # where it's reliable. SQLite and Firebird stay on INTEGER —
749
+ # SQLite has no native bool, and Firebird's driver round-trip
750
+ # for native BOOLEAN is uneven across versions.
751
+ if engine == "postgres":
752
+ bool_sql = "BOOLEAN"
753
+ elif engine == "mysql":
754
+ bool_sql = "BOOLEAN" # MySQL alias for TINYINT(1)
755
+ elif engine == "mssql":
756
+ bool_sql = "BIT"
757
+ else:
758
+ # sqlite, firebird, odbc, anything else
759
+ bool_sql = "INTEGER"
687
760
 
688
761
  # Don't recreate if table already exists
689
762
  if db.table_exists(table):
@@ -706,7 +779,7 @@ class ORM(metaclass=ORMMeta):
706
779
  elif kind in ("NumericField", "FloatField"):
707
780
  sql_type = "REAL"
708
781
  elif kind == "BooleanField":
709
- sql_type = "INTEGER"
782
+ sql_type = bool_sql
710
783
  elif kind == "DateTimeField":
711
784
  sql_type = "DATETIME"
712
785
  elif kind == "BlobField":
@@ -719,7 +792,7 @@ class ORM(metaclass=ORMMeta):
719
792
  elif ft == float:
720
793
  sql_type = "REAL"
721
794
  elif ft == bool:
722
- sql_type = "INTEGER"
795
+ sql_type = bool_sql
723
796
  elif ft == bytes:
724
797
  sql_type = "BLOB"
725
798
 
File without changes