tina4-python 3.13.10__tar.gz → 3.13.12__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.12}/PKG-INFO +1 -1
  2. {tina4_python-3.13.10 → tina4_python-3.13.12}/pyproject.toml +1 -1
  3. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/adapter.py +26 -0
  5. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/connection.py +27 -5
  6. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/firebird.py +13 -4
  7. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/mssql.py +15 -7
  8. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/mysql.py +12 -3
  9. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/postgres.py +64 -4
  10. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/sqlite.py +9 -2
  11. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/orm/fields.py +25 -1
  12. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/orm/model.py +83 -10
  13. {tina4_python-3.13.10 → tina4_python-3.13.12}/.gitignore +0 -0
  14. {tina4_python-3.13.10 → tina4_python-3.13.12}/README.md +0 -0
  15. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/CLAUDE.md +0 -0
  16. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/HtmlElement.py +0 -0
  17. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/Testing.py +0 -0
  18. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/ai/__init__.py +0 -0
  19. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/api/__init__.py +0 -0
  20. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/auth/__init__.py +0 -0
  21. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/cache/__init__.py +0 -0
  22. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/cli/__init__.py +0 -0
  23. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/container/__init__.py +0 -0
  24. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/__init__.py +0 -0
  25. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/cache.py +0 -0
  26. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/constants.py +0 -0
  27. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/events.py +0 -0
  28. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/middleware.py +0 -0
  29. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/rate_limiter.py +0 -0
  30. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/request.py +0 -0
  31. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/response.py +0 -0
  32. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/router.py +0 -0
  33. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/core/server.py +0 -0
  34. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/crud/__init__.py +0 -0
  35. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/__init__.py +0 -0
  36. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/mongodb.py +0 -0
  37. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/database/odbc.py +0 -0
  38. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/debug/__init__.py +0 -0
  39. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/debug/error_overlay.py +0 -0
  40. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/dev_admin/__init__.py +0 -0
  41. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/dev_admin/metrics.py +0 -0
  42. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/dev_admin/plan.py +0 -0
  43. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/dev_admin/project_index.py +0 -0
  44. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/docs.py +0 -0
  45. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/dotenv/__init__.py +0 -0
  46. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/env.py +0 -0
  47. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/frond/FROND.md +0 -0
  48. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/frond/__init__.py +0 -0
  49. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/frond/engine.py +0 -0
  50. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/auth/meta.json +0 -0
  51. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  52. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/database/meta.json +0 -0
  53. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  54. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/error-overlay/meta.json +0 -0
  55. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  56. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/orm/meta.json +0 -0
  57. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  58. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  59. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/queue/meta.json +0 -0
  60. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  61. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/rest-api/meta.json +0 -0
  62. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  63. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/templates/meta.json +0 -0
  64. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  65. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  66. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/graphql/__init__.py +0 -0
  67. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/i18n/__init__.py +0 -0
  68. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/mcp/__init__.py +0 -0
  69. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/mcp/protocol.py +0 -0
  70. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/mcp/tools.py +0 -0
  71. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/messenger/__init__.py +0 -0
  72. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/migration/__init__.py +0 -0
  73. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/migration/runner.py +0 -0
  74. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/orm/__init__.py +0 -0
  75. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/__feedback/widget.js +0 -0
  76. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/css/tina4.css +0 -0
  77. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/css/tina4.min.css +0 -0
  78. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/favicon.ico +0 -0
  79. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/images/logo.svg +0 -0
  80. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  81. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/js/frond.js +0 -0
  82. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/js/frond.min.js +0 -0
  83. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  84. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  85. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/js/tina4.min.js +0 -0
  86. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/js/tina4js.min.js +0 -0
  87. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/swagger/index.html +0 -0
  88. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  89. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/query_builder/__init__.py +0 -0
  90. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue/__init__.py +0 -0
  91. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue/job.py +0 -0
  92. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue/kafka_backend.py +0 -0
  93. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue/lite_backend.py +0 -0
  94. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue/mongo_backend.py +0 -0
  95. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue/rabbitmq_backend.py +0 -0
  96. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue_backends/__init__.py +0 -0
  97. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue_backends/kafka_backend.py +0 -0
  98. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue_backends/mongo_backend.py +0 -0
  99. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  100. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/__init__.py +0 -0
  101. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  102. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_badges.scss +0 -0
  103. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  104. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_cards.scss +0 -0
  105. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_forms.scss +0 -0
  106. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_grid.scss +0 -0
  107. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_modals.scss +0 -0
  108. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_nav.scss +0 -0
  109. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_reset.scss +0 -0
  110. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_tables.scss +0 -0
  111. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_typography.scss +0 -0
  112. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  113. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/_variables.scss +0 -0
  114. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/base.scss +0 -0
  115. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/colors.scss +0 -0
  116. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/scss/tina4css/tina4.scss +0 -0
  117. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/seeder/__init__.py +0 -0
  118. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/service/__init__.py +0 -0
  119. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/session/__init__.py +0 -0
  120. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/session_handlers/__init__.py +0 -0
  121. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  122. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/session_handlers/redis_handler.py +0 -0
  123. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/session_handlers/valkey_handler.py +0 -0
  124. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/swagger/__init__.py +0 -0
  125. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/components/crud.twig +0 -0
  126. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  127. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  128. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/docker/python/Dockerfile +0 -0
  129. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  130. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/302.twig +0 -0
  131. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/401.twig +0 -0
  132. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/403.twig +0 -0
  133. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/404.twig +0 -0
  134. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/500.twig +0 -0
  135. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/502.twig +0 -0
  136. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/503.twig +0 -0
  137. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/errors/base.twig +0 -0
  138. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/frontend/README.md +0 -0
  139. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/templates/readme.md +0 -0
  140. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/test/__init__.py +0 -0
  141. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/test_client/__init__.py +0 -0
  142. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  143. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  144. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  145. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  146. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/validator/__init__.py +0 -0
  155. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/websocket/__init__.py +0 -0
  156. {tina4_python-3.13.10 → tina4_python-3.13.12}/tina4_python/websocket/backplane.py +0 -0
  157. {tina4_python-3.13.10 → tina4_python-3.13.12}/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.12
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.12"
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.12"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -349,6 +349,32 @@ class DatabaseAdapter:
349
349
  last_id=last_id,
350
350
  )
351
351
 
352
+ @staticmethod
353
+ def _strip_trailing_semicolons(sql: str) -> str:
354
+ """v3.13.12: normalize user-supplied SQL by stripping trailing
355
+ semicolons and whitespace.
356
+
357
+ ``fetch()`` and ``fetch_one()`` wrap the user's SQL — fetch()
358
+ builds a ``SELECT COUNT(*) FROM ({sql}) AS _count_subquery``
359
+ for the pagination probe and appends ``LIMIT/OFFSET`` to the
360
+ real query. A trailing ``;`` in the user's SQL breaks both:
361
+
362
+ user input: "SELECT * FROM users;"
363
+ wrapped: "SELECT COUNT(*) FROM (SELECT * FROM users;) ..." ← syntax error
364
+ paginated: "SELECT * FROM users; LIMIT 100 OFFSET 0" ← invalid
365
+
366
+ Stripping trailing semicolons before any wrapping is the
367
+ single-line defence. Internal semicolons (between meaningful
368
+ SQL statements) are left alone — the driver will reject those
369
+ if multi-statement isn't supported on the engine.
370
+ """
371
+ if not sql:
372
+ return sql
373
+ stripped = sql.rstrip()
374
+ while stripped.endswith(";"):
375
+ stripped = stripped[:-1].rstrip()
376
+ return stripped
377
+
352
378
  def fetch(self, sql: str, params: list = None,
353
379
  limit: int = 100, offset: int = 0) -> DatabaseResult:
354
380
  """Execute a read query and return multiple rows."""
@@ -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,17 +437,28 @@ 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
- limit: int = 100, offset: int = 0) -> list[dict]:
443
- """Fetch rows and return the records list directly.
460
+ limit: int = 0, offset: int = 0) -> list[dict]:
461
+ """Fetch ALL rows and return the records list directly.
444
462
 
445
463
  Symmetric with ``fetch_one``. For the common case where you just
446
464
  want the rows and don't need the ``DatabaseResult`` metadata
@@ -451,6 +469,10 @@ class Database:
451
469
  for row in rows:
452
470
  print(row["name"])
453
471
 
472
+ v3.13.12: default ``limit`` is **0** (no truncation) — the method
473
+ name says ``fetch_all``, so it returns all matching rows. Pre-v3.13.12
474
+ silently truncated to 100. Pass an explicit ``limit=N`` to cap.
475
+
454
476
  Returns ``[]`` (not ``None``) when no rows match.
455
477
  """
456
478
  return self.fetch(sql, params, limit, offset).records
@@ -283,6 +283,9 @@ class FirebirdAdapter(DatabaseAdapter):
283
283
 
284
284
  def fetch(self, sql: str, params: list = None,
285
285
  limit: int = 100, offset: int = 0) -> DatabaseResult:
286
+ # v3.13.12: strip trailing `;` — Firebird wraps with COUNT(*)
287
+ # and appends ROWS pagination; a trailing semicolon breaks both.
288
+ sql = self._strip_trailing_semicolons(sql)
286
289
  sql = self._translate_sql(sql)
287
290
  cursor = self._conn.cursor()
288
291
 
@@ -297,10 +300,15 @@ class FirebirdAdapter(DatabaseAdapter):
297
300
  # paginated query below regardless of whether count succeeded.
298
301
  cursor = self._conn.cursor()
299
302
 
300
- # Apply Firebird pagination — ROWS start TO end
301
- start = offset + 1
302
- end = offset + limit
303
- paginated_sql = f"{sql} ROWS {start} TO {end}"
303
+ # Apply Firebird pagination — ROWS start TO end.
304
+ # v3.13.12: limit <= 0 means "no pagination" (fetch_all's
305
+ # default give me ALL rows).
306
+ if limit is None or limit <= 0:
307
+ paginated_sql = sql
308
+ else:
309
+ start = offset + 1
310
+ end = offset + limit
311
+ paginated_sql = f"{sql} ROWS {start} TO {end}"
304
312
  cursor = self._safe_cursor_execute(cursor, paginated_sql, params)
305
313
 
306
314
  desc = cursor.description
@@ -310,6 +318,7 @@ class FirebirdAdapter(DatabaseAdapter):
310
318
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
311
319
 
312
320
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
321
+ sql = self._strip_trailing_semicolons(sql)
313
322
  sql = self._translate_sql(sql)
314
323
  cursor = self._conn.cursor()
315
324
  cursor = self._safe_cursor_execute(cursor, sql, params)
@@ -105,6 +105,8 @@ class MSSQLAdapter(DatabaseAdapter):
105
105
 
106
106
  def fetch(self, sql: str, params: list = None,
107
107
  limit: int = 100, offset: int = 0) -> DatabaseResult:
108
+ # v3.13.12: strip trailing `;` — see DatabaseAdapter helper.
109
+ sql = self._strip_trailing_semicolons(sql)
108
110
  sql = self._translate_sql(sql)
109
111
  cursor = self._conn.cursor(as_dict=True)
110
112
 
@@ -116,20 +118,26 @@ class MSSQLAdapter(DatabaseAdapter):
116
118
  except Exception:
117
119
  total = 0
118
120
 
119
- # Apply pagination — MSSQL uses OFFSET/FETCH
120
- # This requires an ORDER BY; if none exists, add a default
121
- if not re.search(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
122
- paginated_sql = f"{sql} ORDER BY (SELECT NULL) OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
121
+ # Apply pagination — MSSQL uses OFFSET/FETCH.
122
+ # v3.13.12: limit <= 0 means "no pagination" (fetch_all's
123
+ # default give me ALL rows).
124
+ if limit is None or limit <= 0:
125
+ paginated_sql = sql
126
+ paginated_params = tuple(params or [])
123
127
  else:
124
- paginated_sql = f"{sql} OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
125
-
126
- paginated_params = tuple(params or []) + (offset, limit)
128
+ # OFFSET/FETCH requires an ORDER BY; if none exists, add a default
129
+ if not re.search(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
130
+ paginated_sql = f"{sql} ORDER BY (SELECT NULL) OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
131
+ else:
132
+ paginated_sql = f"{sql} OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
133
+ paginated_params = tuple(params or []) + (offset, limit)
127
134
  cursor.execute(paginated_sql, paginated_params)
128
135
  rows = [dict(row) for row in cursor.fetchall()]
129
136
 
130
137
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
131
138
 
132
139
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
140
+ sql = self._strip_trailing_semicolons(sql)
133
141
  sql = self._translate_sql(sql)
134
142
  cursor = self._conn.cursor(as_dict=True)
135
143
  cursor.execute(sql, tuple(params) if params else ())
@@ -94,6 +94,9 @@ class MySQLAdapter(DatabaseAdapter):
94
94
 
95
95
  def fetch(self, sql: str, params: list = None,
96
96
  limit: int = 100, offset: int = 0) -> DatabaseResult:
97
+ # v3.13.12: strip trailing `;` before wrapping with COUNT(*)
98
+ # and appending LIMIT/OFFSET — see DatabaseAdapter helper.
99
+ sql = self._strip_trailing_semicolons(sql)
97
100
  sql = self._translate_sql(sql)
98
101
  cursor = self._conn.cursor(dictionary=True)
99
102
 
@@ -105,15 +108,21 @@ class MySQLAdapter(DatabaseAdapter):
105
108
  except Exception:
106
109
  total = 0
107
110
 
108
- # Apply pagination
109
- paginated_sql = f"{sql} LIMIT %s OFFSET %s"
110
- paginated_params = (params or []) + [limit, offset]
111
+ # Apply pagination — v3.13.12: limit <= 0 means "no pagination"
112
+ # (fetch_all's default give me ALL rows).
113
+ if limit is None or limit <= 0:
114
+ paginated_sql = sql
115
+ paginated_params = params or []
116
+ else:
117
+ paginated_sql = f"{sql} LIMIT %s OFFSET %s"
118
+ paginated_params = (params or []) + [limit, offset]
111
119
  cursor.execute(paginated_sql, paginated_params)
112
120
  rows = [dict(row) for row in cursor.fetchall()]
113
121
 
114
122
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
115
123
 
116
124
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
125
+ sql = self._strip_trailing_semicolons(sql)
117
126
  sql = self._translate_sql(sql)
118
127
  cursor = self._conn.cursor(dictionary=True)
119
128
  cursor.execute(sql, params or [])
@@ -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
 
@@ -265,6 +306,10 @@ class PostgreSQLAdapter(DatabaseAdapter):
265
306
  limit: int = 100, offset: int = 0) -> DatabaseResult:
266
307
  import psycopg2.extras
267
308
 
309
+ # v3.13.12: strip trailing `;` before the framework wraps with
310
+ # COUNT(*) and appends LIMIT/OFFSET — otherwise a user-supplied
311
+ # `"SELECT * FROM users;"` produces invalid wrapped SQL.
312
+ sql = self._strip_trailing_semicolons(sql)
268
313
  sql = self._translate_sql(sql)
269
314
 
270
315
  # v3.13.8: heal first so the COUNT probe below doesn't open on a
@@ -280,8 +325,15 @@ class PostgreSQLAdapter(DatabaseAdapter):
280
325
  try:
281
326
  self._safe_execute(cursor, count_sql, params)
282
327
  total = cursor.fetchone()["cnt"]
283
- except Exception:
328
+ except Exception as e:
284
329
  total = 0
330
+ # v3.13.11 (issue #49 Gap 1): log the probe failure as a
331
+ # warning so the original cause appears in the log even
332
+ # though the probe itself swallows. Without this line, a
333
+ # failure inside an explicit transaction is invisible —
334
+ # the cascade message from the paginated query below is
335
+ # all the operator sees.
336
+ self._log_silent_probe_failure(e, count_sql, params)
285
337
  # If the probe failed because the connection arrived (or
286
338
  # became) aborted, roll back so the real pagination query
287
339
  # below sees a clean txn. Without this we'd cascade.
@@ -294,8 +346,14 @@ class PostgreSQLAdapter(DatabaseAdapter):
294
346
  # Apply pagination — the real query, so use the error-handling
295
347
  # wrapper. The count probe above is best-effort and stays on
296
348
  # _safe_execute (a failed probe shouldn't taint last_error).
297
- paginated_sql = f"{sql} LIMIT %s OFFSET %s"
298
- paginated_params = (params or []) + [limit, offset]
349
+ # v3.13.12: limit <= 0 means "no pagination" (fetch_all's
350
+ # default give me ALL rows, not a silent first-100 slice).
351
+ if limit is None or limit <= 0:
352
+ paginated_sql = sql
353
+ paginated_params = params or []
354
+ else:
355
+ paginated_sql = f"{sql} LIMIT %s OFFSET %s"
356
+ paginated_params = (params or []) + [limit, offset]
299
357
  self._exec_with_handling(cursor, paginated_sql, paginated_params)
300
358
  rows = [self._decode_blobs(dict(row)) for row in cursor.fetchall()]
301
359
 
@@ -304,6 +362,8 @@ class PostgreSQLAdapter(DatabaseAdapter):
304
362
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
305
363
  import psycopg2.extras
306
364
 
365
+ # v3.13.12: see fetch() — trailing semicolons break the wrappers.
366
+ sql = self._strip_trailing_semicolons(sql)
307
367
  sql = self._translate_sql(sql)
308
368
  cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
309
369
  self._exec_with_handling(cursor, sql, params)
@@ -108,6 +108,8 @@ class SQLiteAdapter(DatabaseAdapter):
108
108
 
109
109
  def fetch(self, sql: str, params: list = None,
110
110
  limit: int = 100, offset: int = 0) -> DatabaseResult:
111
+ # v3.13.12: strip trailing `;` before wrapping — see DatabaseAdapter.
112
+ sql = self._strip_trailing_semicolons(sql)
111
113
  # Count total rows (without LIMIT/OFFSET)
112
114
  count_sql = f"SELECT COUNT(*) as cnt FROM ({sql})"
113
115
  try:
@@ -115,8 +117,12 @@ class SQLiteAdapter(DatabaseAdapter):
115
117
  except Exception:
116
118
  total = 0
117
119
 
118
- # Apply pagination — skip if SQL already has LIMIT
119
- if "LIMIT" in sql.upper().split("--")[0]:
120
+ # Apply pagination — skip if SQL already has LIMIT, or if
121
+ # limit <= 0 (v3.13.12: fetch_all's "give me all rows" path).
122
+ if limit is None or limit <= 0:
123
+ paginated_sql = sql
124
+ paginated_params = params or []
125
+ elif "LIMIT" in sql.upper().split("--")[0]:
120
126
  paginated_sql = sql
121
127
  paginated_params = params or []
122
128
  else:
@@ -128,6 +134,7 @@ class SQLiteAdapter(DatabaseAdapter):
128
134
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
129
135
 
130
136
  def fetch_one(self, sql: str, params: list = None) -> dict | None:
137
+ sql = self._strip_trailing_semicolons(sql)
131
138
  cursor = self._conn.execute(sql, params or [])
132
139
  row = cursor.fetchone()
133
140
  return dict(row) if row else None
@@ -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