tina4-python 3.13.84__tar.gz → 3.13.86__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 (172) hide show
  1. {tina4_python-3.13.84 → tina4_python-3.13.86}/PKG-INFO +1 -1
  2. {tina4_python-3.13.84 → tina4_python-3.13.86}/pyproject.toml +1 -1
  3. tina4_python-3.13.86/tina4_python/__init__.py +190 -0
  4. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/cli/__init__.py +38 -2
  5. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/router.py +24 -0
  6. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/server.py +11 -1
  7. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/adapter.py +32 -3
  8. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/connection.py +8 -0
  9. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/firebird.py +5 -5
  10. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/mssql.py +7 -4
  11. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/mysql.py +7 -4
  12. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/odbc.py +5 -5
  13. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/postgres.py +23 -11
  14. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/sqlite.py +20 -7
  15. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/frond/engine.py +30 -5
  16. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/orm/model.py +55 -19
  17. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/js/tina4-dev-admin.min.js +90 -87
  18. tina4_python-3.13.84/tina4_python/__init__.py +0 -141
  19. tina4_python-3.13.84/tina4_python/public/js/tina4-dev-admin.js +0 -1124
  20. {tina4_python-3.13.84 → tina4_python-3.13.86}/.gitignore +0 -0
  21. {tina4_python-3.13.84 → tina4_python-3.13.86}/README.md +0 -0
  22. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/CLAUDE.md +0 -0
  23. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/HtmlElement.py +0 -0
  24. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/Testing.py +0 -0
  25. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/ai/__init__.py +0 -0
  26. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/api/__init__.py +0 -0
  27. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/auth/__init__.py +0 -0
  28. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/cache/__init__.py +0 -0
  29. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/container/__init__.py +0 -0
  30. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/context/__init__.py +0 -0
  31. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/context/chunker.py +0 -0
  32. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/__init__.py +0 -0
  33. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/cache.py +0 -0
  34. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/constants.py +0 -0
  35. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/events.py +0 -0
  36. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/middleware.py +0 -0
  37. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/rate_limiter.py +0 -0
  38. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/request.py +0 -0
  39. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/core/response.py +0 -0
  40. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/crud/__init__.py +0 -0
  41. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/__init__.py +0 -0
  42. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/database/mongodb.py +0 -0
  43. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/debug/__init__.py +0 -0
  44. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/debug/error_overlay.py +0 -0
  45. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/dev_admin/__init__.py +0 -0
  46. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/dev_admin/metrics.py +0 -0
  47. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/dev_admin/plan.py +0 -0
  48. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/dev_admin/project_index.py +0 -0
  49. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/docs.py +0 -0
  50. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/docstore/__init__.py +0 -0
  51. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/dotenv/__init__.py +0 -0
  52. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/env.py +0 -0
  53. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/frond/FROND.md +0 -0
  54. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/frond/__init__.py +0 -0
  55. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/auth/meta.json +0 -0
  56. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  57. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/database/meta.json +0 -0
  58. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  59. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/error-overlay/meta.json +0 -0
  60. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  61. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/orm/meta.json +0 -0
  62. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  63. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  64. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/queue/meta.json +0 -0
  65. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  66. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/rest-api/meta.json +0 -0
  67. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  68. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/templates/meta.json +0 -0
  69. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  70. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  71. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/graphql/__init__.py +0 -0
  72. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/i18n/__init__.py +0 -0
  73. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/mcp/__init__.py +0 -0
  74. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/mcp/protocol.py +0 -0
  75. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/mcp/tools.py +0 -0
  76. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/messenger/__init__.py +0 -0
  77. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/migration/__init__.py +0 -0
  78. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/migration/runner.py +0 -0
  79. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/mqtt/__init__.py +0 -0
  80. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/mqtt/message.py +0 -0
  81. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/orm/__init__.py +0 -0
  82. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/orm/fields.py +0 -0
  83. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/__feedback/widget.js +0 -0
  84. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/css/tina4.css +0 -0
  85. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/css/tina4.min.css +0 -0
  86. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/favicon.ico +0 -0
  87. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/images/logo.svg +0 -0
  88. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  89. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/js/frond.js +0 -0
  90. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/js/frond.min.js +0 -0
  91. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/js/tina4.min.js +0 -0
  92. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/js/tina4js.min.js +0 -0
  93. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/swagger/index.html +0 -0
  94. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  95. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/query_builder/__init__.py +0 -0
  96. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue/__init__.py +0 -0
  97. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue/job.py +0 -0
  98. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue/kafka_backend.py +0 -0
  99. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue/lite_backend.py +0 -0
  100. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue/mongo_backend.py +0 -0
  101. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue/rabbitmq_backend.py +0 -0
  102. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue_backends/__init__.py +0 -0
  103. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue_backends/kafka_backend.py +0 -0
  104. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue_backends/mongo_backend.py +0 -0
  105. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  106. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/__init__.py +0 -0
  107. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/models/Attachment.py +0 -0
  108. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/models/Channel.py +0 -0
  109. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/models/ChannelMember.py +0 -0
  110. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/models/Message.py +0 -0
  111. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/models/Workspace.py +0 -0
  112. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/models/__init__.py +0 -0
  113. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/realtime/storage.py +0 -0
  114. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/__init__.py +0 -0
  115. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  116. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_badges.scss +0 -0
  117. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  118. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_cards.scss +0 -0
  119. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_forms.scss +0 -0
  120. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_grid.scss +0 -0
  121. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_modals.scss +0 -0
  122. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_nav.scss +0 -0
  123. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_pagination.scss +0 -0
  124. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_reset.scss +0 -0
  125. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_tables.scss +0 -0
  126. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_typography.scss +0 -0
  127. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  128. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/_variables.scss +0 -0
  129. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/base.scss +0 -0
  130. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/colors.scss +0 -0
  131. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/scss/tina4css/tina4.scss +0 -0
  132. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/seeder/__init__.py +0 -0
  133. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/service/__init__.py +0 -0
  134. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/session/__init__.py +0 -0
  135. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/session_handlers/__init__.py +0 -0
  136. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  137. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/session_handlers/redis_handler.py +0 -0
  138. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/session_handlers/valkey_handler.py +0 -0
  139. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/swagger/__init__.py +0 -0
  140. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/components/crud.twig +0 -0
  141. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  142. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  143. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/docker/python/Dockerfile +0 -0
  144. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  145. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/302.twig +0 -0
  146. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/401.twig +0 -0
  147. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/403.twig +0 -0
  148. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/404.twig +0 -0
  149. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/500.twig +0 -0
  150. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/502.twig +0 -0
  151. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/503.twig +0 -0
  152. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/errors/base.twig +0 -0
  153. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/frontend/README.md +0 -0
  154. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/templates/readme.md +0 -0
  155. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/test/__init__.py +0 -0
  156. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/test_client/__init__.py +0 -0
  157. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  158. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  159. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  160. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  161. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  162. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  163. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  164. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  165. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  166. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  167. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  168. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  169. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/validator/__init__.py +0 -0
  170. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/websocket/__init__.py +0 -0
  171. {tina4_python-3.13.84 → tina4_python-3.13.86}/tina4_python/websocket/backplane.py +0 -0
  172. {tina4_python-3.13.84 → tina4_python-3.13.86}/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.84
3
+ Version: 3.13.86
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.84"
3
+ version = "3.13.86"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -0,0 +1,190 @@
1
+ # Tina4 Python v3.0 — The Intelligent Native Application 4ramework.
2
+ # Copyright 2007 - present Tina4
3
+ # License: MIT https://opensource.org/licenses/MIT
4
+ """
5
+ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
6
+
7
+ from tina4_python import get, post, ORM, Database, Frond, Auth, Queue
8
+
9
+ One import, everything works.
10
+ """
11
+ def _resolve_version() -> str:
12
+ """Resolve the package version from a single source of truth.
13
+
14
+ Derive it rather than hardcode it, so a release only has to bump
15
+ ``pyproject.toml`` and can never leave this constant stale (which used to
16
+ make the dev toolbar and ``docs._detect_version`` under-report). Mirrors how
17
+ Node reads package.json and PHP resolves ``App::$VERSION`` at runtime.
18
+
19
+ Order:
20
+ 1. The repo's own ``pyproject.toml`` (a source checkout is authoritative
21
+ and current; installed metadata can lag an un-synced checkout). Guarded
22
+ by the project name so a copy vendored under another app's pyproject
23
+ can't hijack the version.
24
+ 2. Installed distribution metadata (the shipped wheel has no pyproject).
25
+ 3. A floor literal (last resort only).
26
+ """
27
+ try:
28
+ import pathlib
29
+ import tomllib
30
+ pyproject = pathlib.Path(__file__).resolve().parent.parent / "pyproject.toml"
31
+ if pyproject.exists():
32
+ with pyproject.open("rb") as fh:
33
+ project = tomllib.load(fh).get("project", {})
34
+ if project.get("name") == "tina4-python" and project.get("version"):
35
+ return project["version"]
36
+ except Exception:
37
+ pass
38
+ try:
39
+ from importlib.metadata import PackageNotFoundError, version
40
+ try:
41
+ return version("tina4-python")
42
+ except PackageNotFoundError:
43
+ pass
44
+ except Exception:
45
+ pass
46
+ return "3.13.56"
47
+
48
+
49
+ __version__ = _resolve_version()
50
+
51
+ # ══════════════════════════════════════════════════════════════════════════
52
+ # LAZY FEATURE LOADING (PEP 562 module __getattr__)
53
+ # ══════════════════════════════════════════════════════════════════════════
54
+ # A batteries-included framework should not pay to import all ~98 features on
55
+ # every boot. Only the CORE surface below is imported eagerly; every optional
56
+ # subsystem is registered in _LAZY and imported on FIRST reference via
57
+ # ``__getattr__``. An app that never touches GraphQL/WSDL/MQTT/Messenger never
58
+ # loads those modules — so a production runtime's footprint matches the app.
59
+ # ``from tina4_python import GraphQL`` still works: the miss routes through
60
+ # ``__getattr__``, which imports the module, caches the name in globals(), and
61
+ # returns it (so later access skips the hook). See plan/v3/feature-preload-manifest.md.
62
+
63
+ # ── CORE (always eager) — every app needs these to boot + serve ──
64
+ from tina4_python.core.router import ( # noqa: E402, F401
65
+ get, post, put, patch, delete, any_method,
66
+ noauth, secured, cached, middleware, template,
67
+ Router, RouteGroup,
68
+ )
69
+ from tina4_python.core.constants import ( # noqa: E402, F401
70
+ HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED, HTTP_NO_CONTENT,
71
+ HTTP_MOVED, HTTP_REDIRECT, HTTP_NOT_MODIFIED,
72
+ HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, HTTP_FORBIDDEN,
73
+ HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_CONFLICT,
74
+ HTTP_GONE, HTTP_UNPROCESSABLE, HTTP_TOO_MANY,
75
+ HTTP_SERVER_ERROR, HTTP_BAD_GATEWAY, HTTP_UNAVAILABLE,
76
+ APPLICATION_JSON, APPLICATION_XML, APPLICATION_FORM,
77
+ APPLICATION_OCTET, TEXT_HTML, TEXT_PLAIN, TEXT_CSV, TEXT_XML,
78
+ )
79
+ from tina4_python.core.server import ( # noqa: E402, F401
80
+ run, background, background_task_count, stop_all_background_tasks,
81
+ )
82
+ from tina4_python.core.events import on, emit, once, off # noqa: E402, F401
83
+ from tina4_python.env import Env # noqa: E402, F401
84
+
85
+ # ── OPTIONAL (lazy) — public name -> (submodule, attribute) ──
86
+ _LAZY: dict[str, tuple[str, str]] = {
87
+ # Database + ORM
88
+ "Database": ("tina4_python.database", "Database"),
89
+ "ORM": ("tina4_python.orm", "ORM"),
90
+ "bind_database": ("tina4_python.orm", "bind_database"),
91
+ "Field": ("tina4_python.orm", "Field"),
92
+ "IntegerField": ("tina4_python.orm", "IntegerField"),
93
+ "StringField": ("tina4_python.orm", "StringField"),
94
+ "BooleanField": ("tina4_python.orm", "BooleanField"),
95
+ "FloatField": ("tina4_python.orm", "FloatField"),
96
+ "DateTimeField": ("tina4_python.orm", "DateTimeField"),
97
+ "TextField": ("tina4_python.orm", "TextField"),
98
+ "BlobField": ("tina4_python.orm", "BlobField"),
99
+ "NumericField": ("tina4_python.orm", "NumericField"),
100
+ "JSONField": ("tina4_python.orm", "JSONField"),
101
+ "ForeignKeyField": ("tina4_python.orm", "ForeignKeyField"),
102
+ "IntField": ("tina4_python.orm", "IntField"),
103
+ "StrField": ("tina4_python.orm", "StrField"),
104
+ "BoolField": ("tina4_python.orm", "BoolField"),
105
+ "has_many": ("tina4_python.orm", "has_many"),
106
+ "has_one": ("tina4_python.orm", "has_one"),
107
+ "belongs_to": ("tina4_python.orm", "belongs_to"),
108
+ # Auth
109
+ "Auth": ("tina4_python.auth", "Auth"),
110
+ # Queue
111
+ "Queue": ("tina4_python.queue", "Queue"),
112
+ # MQTT
113
+ "Mqtt": ("tina4_python.mqtt", "Mqtt"),
114
+ "MqttMessage": ("tina4_python.mqtt", "MqttMessage"),
115
+ "MqttError": ("tina4_python.mqtt", "MqttError"),
116
+ # Template engine
117
+ "Frond": ("tina4_python.frond", "Frond"),
118
+ # Response cache
119
+ "ResponseCache": ("tina4_python.cache", "ResponseCache"),
120
+ "cache_stats": ("tina4_python.cache", "cache_stats"),
121
+ "clear_cache": ("tina4_python.cache", "clear_cache"),
122
+ # DI container
123
+ "Container": ("tina4_python.container", "Container"),
124
+ # HTTP client
125
+ "Api": ("tina4_python.api", "Api"),
126
+ # SOAP / WSDL
127
+ "WSDL": ("tina4_python.wsdl", "WSDL"),
128
+ "wsdl_operation": ("tina4_python.wsdl", "wsdl_operation"),
129
+ # GraphQL
130
+ "GraphQL": ("tina4_python.graphql", "GraphQL"),
131
+ # Auto-CRUD
132
+ "AutoCrud": ("tina4_python.crud", "AutoCrud"),
133
+ # Email
134
+ "Messenger": ("tina4_python.messenger", "Messenger"),
135
+ # Realtime / WebRTC
136
+ "realtime": ("tina4_python.realtime", "realtime"),
137
+ "RtcMediaBackend": ("tina4_python.realtime", "RtcMediaBackend"),
138
+ "MeshBackend": ("tina4_python.realtime", "MeshBackend"),
139
+ "StorageBackend": ("tina4_python.realtime.storage", "StorageBackend"),
140
+ "LocalStorage": ("tina4_python.realtime.storage", "LocalStorage"),
141
+ "S3Storage": ("tina4_python.realtime.storage", "S3Storage"),
142
+ # Inline testing
143
+ "tests": ("tina4_python.Testing", "tests"),
144
+ "assert_equal_inline": ("tina4_python.Testing", "assert_equal"),
145
+ }
146
+
147
+
148
+ def __getattr__(name: str): # noqa: N807 (PEP 562 module-level hook)
149
+ """Import an optional subsystem on first reference, then cache it."""
150
+ spec = _LAZY.get(name)
151
+ if spec is None:
152
+ raise AttributeError(f"module 'tina4_python' has no attribute {name!r}")
153
+ import importlib
154
+ module_path, attr = spec
155
+ value = getattr(importlib.import_module(module_path), attr)
156
+ globals()[name] = value # cache — subsequent access skips this hook
157
+ return value
158
+
159
+
160
+ def __dir__() -> list[str]:
161
+ return sorted(set(globals()) | set(_LAZY))
162
+
163
+
164
+ def _preload_from_manifest() -> None:
165
+ """Eager-warm the subsystems an app actually uses, from a generated
166
+ ``.tina4/preload.json`` (see plan/v3/feature-preload-manifest.md). No manifest,
167
+ or dev mode, means everything stays lazy — this is pure pre-warm, never a gate.
168
+ The lazy ``__getattr__`` above is what guarantees unused features never load.
169
+ """
170
+ import os
171
+ if os.environ.get("TINA4_DEBUG", "").lower() in ("true", "1", "yes", "on"):
172
+ return # dev: leave everything lazy on-demand
173
+ try:
174
+ import json
175
+ import pathlib
176
+ manifest = pathlib.Path.cwd() / ".tina4" / "preload.json"
177
+ if not manifest.exists():
178
+ return
179
+ data = json.loads(manifest.read_text())
180
+ import importlib
181
+ for feature in data.get("features", []):
182
+ try:
183
+ importlib.import_module(f"tina4_python.{feature}")
184
+ except Exception: # noqa: BLE001 — a bad manifest entry must not break boot
185
+ pass
186
+ except Exception: # noqa: BLE001
187
+ pass
188
+
189
+
190
+ _preload_from_manifest()
@@ -54,9 +54,45 @@ def _to_snake(name: str) -> str:
54
54
  return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
55
55
 
56
56
 
57
+ # Table names that collide with SQL reserved words. `CREATE TABLE order (...)`
58
+ # is a syntax error on every engine, and the ORM interpolates table names into
59
+ # SQL unquoted (and hands the raw name to driver insert/update/delete), so the
60
+ # safe fix is to never GENERATE one. The plural form is not reserved and reads
61
+ # naturally as a table name.
62
+ SQL_RESERVED_TABLE_NAMES = {
63
+ "order", "group", "user", "table", "select", "from", "where", "index",
64
+ "key", "values", "column", "constraint", "check", "default", "primary",
65
+ "foreign", "references", "unique", "join", "union", "having", "limit",
66
+ "offset", "desc", "asc", "case", "when", "then", "else", "end", "and",
67
+ "or", "not", "null", "insert", "update", "delete", "create", "drop",
68
+ "alter", "grant", "revoke", "commit", "rollback", "view", "trigger",
69
+ "procedure", "function", "database", "schema", "session", "set", "into",
70
+ "as", "on", "by", "inner", "outer", "left", "right", "full", "natural",
71
+ "using", "with", "distinct", "between", "exists", "like", "in", "is",
72
+ "all", "any", "cross", "add", "row", "rows", "range", "current", "to",
73
+ }
74
+
75
+
76
+ def _pluralize_table(name: str) -> str:
77
+ """Simple English plural, used to escape a reserved table name."""
78
+ if name.endswith("y") and not name.endswith(("ay", "ey", "iy", "oy", "uy")):
79
+ return name[:-1] + "ies"
80
+ if name.endswith(("s", "x", "z", "ch", "sh")):
81
+ return name + "es"
82
+ return name + "s"
83
+
84
+
57
85
  def _to_table(name: str) -> str:
58
- """Class name → singular table name: Product → product."""
59
- return _to_snake(name)
86
+ """Class name → singular table name: Product → product.
87
+
88
+ A name colliding with a SQL reserved word is pluralised instead
89
+ (Order → orders). Every generator routes through here, so the model's
90
+ ``table_name``, the migration DDL, the routes and the tests all agree.
91
+ """
92
+ table = _to_snake(name)
93
+ if table in SQL_RESERVED_TABLE_NAMES:
94
+ return _pluralize_table(table)
95
+ return table
60
96
 
61
97
 
62
98
  def _parse_fields(fields_str: str) -> list[tuple[str, str]]:
@@ -341,6 +341,11 @@ class Router:
341
341
  "param_names": param_names,
342
342
  "param_types": param_types,
343
343
  "handler": handler,
344
+ # Which module registered this route. Lets dev hot-reload purge a
345
+ # changed module's OLD routes before re-importing it — otherwise a
346
+ # renamed or deleted endpoint keeps serving its stale handler,
347
+ # because replace-semantics only match an identical (method, path).
348
+ "module": getattr(handler, "__module__", ""),
344
349
  "middleware": effective_middleware,
345
350
  "auth_required": auth_required,
346
351
  "cached": options.get("cached", False),
@@ -452,6 +457,25 @@ class Router:
452
457
  """Return all registered routes (debug-friendly)."""
453
458
  return _routes
454
459
 
460
+ @staticmethod
461
+ def remove_routes_for_module(module_name: str) -> int:
462
+ """Drop every route registered by ``module_name``. Returns the count.
463
+
464
+ Used by dev hot-reload BEFORE re-importing a changed module: its
465
+ decorators then re-register whatever the file currently declares. Without
466
+ this, replace-semantics only overwrite an identical (method, path), so a
467
+ renamed or deleted endpoint would keep serving its stale handler until a
468
+ full restart — you "remove" a route and it still answers.
469
+ """
470
+ if not module_name:
471
+ return 0
472
+ removed = 0
473
+ for registry in (_routes, _ws_routes):
474
+ keep = [r for r in registry if r.get("module") != module_name]
475
+ removed += len(registry) - len(keep)
476
+ registry[:] = keep
477
+ return removed
478
+
455
479
  @staticmethod
456
480
  def clear():
457
481
  """Clear all routes (for testing)."""
@@ -301,10 +301,20 @@ def _auto_discover(root_dir: str = "src"):
301
301
  # tina4_python.* / third-party module would break shared
302
302
  # singletons and class identity — never do that here.
303
303
  if module_name == root_pkg or module_name.startswith(root_pkg + "."):
304
+ # Purge this module's OLD routes first. Re-importing only
305
+ # OVERWRITES an identical (method, path), so a renamed or
306
+ # deleted endpoint would otherwise keep serving its stale
307
+ # handler until a full restart. The decorators below
308
+ # re-register whatever the file declares NOW.
309
+ from tina4_python.core.router import Router
310
+ dropped = Router.remove_routes_for_module(module_name)
304
311
  del sys.modules[module_name]
305
312
  importlib.import_module(module_name)
306
313
  _discovered_mtimes[module_name] = current_mtime
307
- Log.info(f"Reloaded changed module: {module_name}")
314
+ Log.info(
315
+ f"Reloaded changed module: {module_name}"
316
+ + (f" (dropped {dropped} stale route(s))" if dropped else "")
317
+ )
308
318
  else:
309
319
  # Out-of-scope module changed — record mtime so we don't
310
320
  # keep re-evaluating it, but do not re-import it.
@@ -479,6 +479,35 @@ class DatabaseAdapter:
479
479
  """Execute a read query and return a single row or None."""
480
480
  raise NotImplementedError
481
481
 
482
+ #: Identifier quoting for this dialect. ANSI double quotes work on SQLite,
483
+ #: PostgreSQL and Firebird; MySQL and SQL Server override this.
484
+ IDENTIFIER_QUOTE = ('"', '"')
485
+
486
+ def quote_identifier(self, name: str) -> str:
487
+ """Quote a table/column name so a SQL reserved word can be used.
488
+
489
+ ``CREATE TABLE order (...)`` / ``SELECT * FROM order`` are syntax errors
490
+ on every engine; quoting makes ``order``, ``group``, ``user`` etc. usable
491
+ as names. Idempotent (an already-quoted name is returned unchanged) and
492
+ dot-aware (``schema.table`` quotes each part).
493
+
494
+ A raw expression is never quoted — ``*``, ``COUNT(*)`` and anything that
495
+ isn't a plain identifier are passed through untouched, so existing
496
+ hand-written SQL keeps working.
497
+ """
498
+ if not name:
499
+ return name
500
+ open_q, close_q = self.IDENTIFIER_QUOTE
501
+ name = name.strip()
502
+ if name.startswith(open_q) and name.endswith(close_q):
503
+ return name
504
+ if "." in name:
505
+ return ".".join(self.quote_identifier(p) for p in name.split("."))
506
+ # Only quote a plain identifier — leave expressions/wildcards alone.
507
+ if not name.replace("_", "").replace("$", "").isalnum():
508
+ return name
509
+ return f"{open_q}{name.replace(close_q, close_q * 2)}{close_q}"
510
+
482
511
  def insert(self, table: str, data: dict | list) -> DatabaseResult:
483
512
  """Insert one or more rows.
484
513
 
@@ -492,9 +521,9 @@ class DatabaseAdapter:
492
521
  return DatabaseResult()
493
522
  # All dicts must have the same keys
494
523
  keys = list(data[0].keys())
495
- columns = ", ".join(keys)
524
+ columns = ", ".join(self.quote_identifier(k) for k in keys)
496
525
  placeholders = ", ".join(["?"] * len(keys))
497
- sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
526
+ sql = f"INSERT INTO {self.quote_identifier(table)} ({columns}) VALUES ({placeholders})"
498
527
  params_list = [list(row[k] for k in keys) for row in data]
499
528
  return self.execute_many(sql, params_list)
500
529
  raise NotImplementedError
@@ -526,7 +555,7 @@ class DatabaseAdapter:
526
555
 
527
556
  if isinstance(filter_sql, dict):
528
557
  # Build WHERE from dict
529
- where_parts = [f"{k} = ?" for k in filter_sql.keys()]
558
+ where_parts = [f"{self.quote_identifier(k)} = ?" for k in filter_sql.keys()]
530
559
  where_sql = " AND ".join(where_parts)
531
560
  return self.delete(table, where_sql, list(filter_sql.values()))
532
561
 
@@ -664,6 +664,14 @@ class Database:
664
664
  return result
665
665
  return self._fetch_one_direct(sql, params)
666
666
 
667
+ def quote_identifier(self, name: str) -> str:
668
+ """Quote a table/column name for THIS connection's dialect.
669
+
670
+ Exposed so the ORM can build SQL that survives a reserved-word name
671
+ (``table_name = "order"``) without knowing which driver is bound.
672
+ """
673
+ return self._get_adapter().quote_identifier(name)
674
+
667
675
  def insert(self, table: str, data: dict | list) -> DatabaseResult:
668
676
  if self._cache_enabled:
669
677
  self._cache_invalidate()
@@ -375,15 +375,15 @@ class FirebirdAdapter(DatabaseAdapter):
375
375
  return row
376
376
 
377
377
  def insert(self, table: str, data: dict) -> DatabaseResult:
378
- columns = ", ".join(data.keys())
378
+ columns = ", ".join(self.quote_identifier(c) for c in data.keys())
379
379
  placeholders = ", ".join(["?"] * len(data))
380
- sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
380
+ sql = f"INSERT INTO {self.quote_identifier(table)} ({columns}) VALUES ({placeholders})"
381
381
  return self.execute(sql, list(data.values()))
382
382
 
383
383
  def update(self, table: str, data: dict,
384
384
  filter_sql: str = "", params: list = None) -> DatabaseResult:
385
- set_clause = ", ".join(f"{k} = ?" for k in data.keys())
386
- sql = f"UPDATE {table} SET {set_clause}"
385
+ set_clause = ", ".join(f"{self.quote_identifier(k)} = ?" for k in data.keys())
386
+ sql = f"UPDATE {self.quote_identifier(table)} SET {set_clause}"
387
387
  all_params = list(data.values())
388
388
 
389
389
  if filter_sql:
@@ -394,7 +394,7 @@ class FirebirdAdapter(DatabaseAdapter):
394
394
 
395
395
  def delete(self, table: str,
396
396
  filter_sql: str = "", params: list = None) -> DatabaseResult:
397
- sql = f"DELETE FROM {table}"
397
+ sql = f"DELETE FROM {self.quote_identifier(table)}"
398
398
  if filter_sql:
399
399
  sql += f" WHERE {filter_sql}"
400
400
  return self.execute(sql, params or [])
@@ -12,6 +12,9 @@ from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult, SQLTr
12
12
 
13
13
 
14
14
  class MSSQLAdapter(DatabaseAdapter):
15
+ #: SQL Server uses bracketed identifiers.
16
+ IDENTIFIER_QUOTE = ("[", "]")
17
+
15
18
  """Microsoft SQL Server database driver using pymssql."""
16
19
 
17
20
  def __init__(self):
@@ -173,15 +176,15 @@ class MSSQLAdapter(DatabaseAdapter):
173
176
  # 'keys'`` because this override only handled the single-dict case.)
174
177
  if isinstance(data, list):
175
178
  return super().insert(table, data)
176
- columns = ", ".join(data.keys())
179
+ columns = ", ".join(self.quote_identifier(c) for c in data.keys())
177
180
  placeholders = ", ".join(["%s"] * len(data))
178
- sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
181
+ sql = f"INSERT INTO {self.quote_identifier(table)} ({columns}) VALUES ({placeholders})"
179
182
  return self.execute(sql, list(data.values()))
180
183
 
181
184
  def update(self, table: str, data: dict,
182
185
  filter_sql: str = "", params: list = None) -> DatabaseResult:
183
186
  set_clause = ", ".join(f"{k} = %s" for k in data.keys())
184
- sql = f"UPDATE {table} SET {set_clause}"
187
+ sql = f"UPDATE {self.quote_identifier(table)} SET {set_clause}"
185
188
  all_params = list(data.values())
186
189
 
187
190
  if filter_sql:
@@ -193,7 +196,7 @@ class MSSQLAdapter(DatabaseAdapter):
193
196
 
194
197
  def delete(self, table: str,
195
198
  filter_sql: str = "", params: list = None) -> DatabaseResult:
196
- sql = f"DELETE FROM {table}"
199
+ sql = f"DELETE FROM {self.quote_identifier(table)}"
197
200
  if filter_sql:
198
201
  translated_filter = SQLTranslator.placeholder_style(filter_sql, "%s")
199
202
  sql += f" WHERE {translated_filter}"
@@ -12,6 +12,9 @@ from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult, SQLTr
12
12
 
13
13
 
14
14
  class MySQLAdapter(DatabaseAdapter):
15
+ #: MySQL quotes identifiers with backticks (ANSI_QUOTES is not the default).
16
+ IDENTIFIER_QUOTE = ("`", "`")
17
+
15
18
  """MySQL/MariaDB database driver using mysql-connector-python."""
16
19
 
17
20
  def __init__(self):
@@ -148,15 +151,15 @@ class MySQLAdapter(DatabaseAdapter):
148
151
  # 'keys'`` because this override only handled the single-dict case.)
149
152
  if isinstance(data, list):
150
153
  return super().insert(table, data)
151
- columns = ", ".join(data.keys())
154
+ columns = ", ".join(self.quote_identifier(c) for c in data.keys())
152
155
  placeholders = ", ".join(["%s"] * len(data))
153
- sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
156
+ sql = f"INSERT INTO {self.quote_identifier(table)} ({columns}) VALUES ({placeholders})"
154
157
  return self.execute(sql, list(data.values()))
155
158
 
156
159
  def update(self, table: str, data: dict,
157
160
  filter_sql: str = "", params: list = None) -> DatabaseResult:
158
161
  set_clause = ", ".join(f"{k} = %s" for k in data.keys())
159
- sql = f"UPDATE {table} SET {set_clause}"
162
+ sql = f"UPDATE {self.quote_identifier(table)} SET {set_clause}"
160
163
  all_params = list(data.values())
161
164
 
162
165
  if filter_sql:
@@ -168,7 +171,7 @@ class MySQLAdapter(DatabaseAdapter):
168
171
 
169
172
  def delete(self, table: str,
170
173
  filter_sql: str = "", params: list = None) -> DatabaseResult:
171
- sql = f"DELETE FROM {table}"
174
+ sql = f"DELETE FROM {self.quote_identifier(table)}"
172
175
  if filter_sql:
173
176
  translated_filter = SQLTranslator.placeholder_style(filter_sql, "%s")
174
177
  sql += f" WHERE {translated_filter}"
@@ -114,15 +114,15 @@ class ODBCAdapter(DatabaseAdapter):
114
114
  return dict(zip(columns, row))
115
115
 
116
116
  def insert(self, table: str, data: dict) -> DatabaseResult:
117
- columns = ", ".join(data.keys())
117
+ columns = ", ".join(self.quote_identifier(c) for c in data.keys())
118
118
  placeholders = ", ".join(["?"] * len(data))
119
- sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
119
+ sql = f"INSERT INTO {self.quote_identifier(table)} ({columns}) VALUES ({placeholders})"
120
120
  return self.execute(sql, list(data.values()))
121
121
 
122
122
  def update(self, table: str, data: dict,
123
123
  filter_sql: str = "", params: list = None) -> DatabaseResult:
124
- set_clause = ", ".join(f"{k} = ?" for k in data.keys())
125
- sql = f"UPDATE {table} SET {set_clause}"
124
+ set_clause = ", ".join(f"{self.quote_identifier(k)} = ?" for k in data.keys())
125
+ sql = f"UPDATE {self.quote_identifier(table)} SET {set_clause}"
126
126
  all_params = list(data.values())
127
127
  if filter_sql:
128
128
  sql += f" WHERE {filter_sql}"
@@ -131,7 +131,7 @@ class ODBCAdapter(DatabaseAdapter):
131
131
 
132
132
  def delete(self, table: str,
133
133
  filter_sql: str = "", params: list = None) -> DatabaseResult:
134
- sql = f"DELETE FROM {table}"
134
+ sql = f"DELETE FROM {self.quote_identifier(table)}"
135
135
  if filter_sql:
136
136
  sql += f" WHERE {filter_sql}"
137
137
  return self.execute(sql, params or [])
@@ -342,8 +342,6 @@ class PostgreSQLAdapter(DatabaseAdapter):
342
342
 
343
343
  def fetch(self, sql: str, params: list = None,
344
344
  limit: int = 100, offset: int = 0) -> DatabaseResult:
345
- import psycopg2.extras
346
-
347
345
  # v3.13.12: strip trailing `;` before the framework wraps with
348
346
  # COUNT(*) and appends LIMIT/OFFSET — otherwise a user-supplied
349
347
  # `"SELECT * FROM users;"` produces invalid wrapped SQL.
@@ -356,13 +354,22 @@ class PostgreSQLAdapter(DatabaseAdapter):
356
354
  # _exec_with_handling's heal step.
357
355
  self._heal_aborted_txn()
358
356
 
359
- cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
360
-
361
- # Count total rows
357
+ # A PLAIN cursor (tuples), not RealDictCursor. RealDictCursor builds a
358
+ # RealDictRow per row (an OrderedDict subclass -- a full dict allocation
359
+ # with per-row column-name binding) which the old code then COPIED again
360
+ # via dict(row): two dict allocations per row. Reading tuples and building
361
+ # each dict once from the cached column names avoids both. Measured on
362
+ # real PostgreSQL 16.14, a 5,000-row x 6-col fetch: 14.27ms -> 6.27ms
363
+ # (2.28x), byte-identical output incl. native types (int/bool/Decimal/None)
364
+ # -- cursor_factory only chooses the row CONTAINER, never value coercion,
365
+ # so psycopg2's type adaptation is unchanged.
366
+ cursor = self._conn.cursor()
367
+
368
+ # Count total rows (plain cursor -> read the scalar positionally, not ["cnt"])
362
369
  count_sql = f"SELECT COUNT(*) AS cnt FROM ({sql}) AS _count_subquery"
363
370
  try:
364
371
  self._safe_execute(cursor, count_sql, params)
365
- total = cursor.fetchone()["cnt"]
372
+ total = cursor.fetchone()[0]
366
373
  except Exception as e:
367
374
  total = 0
368
375
  # v3.13.11 (issue #49 Gap 1): log the probe failure as a
@@ -393,7 +400,12 @@ class PostgreSQLAdapter(DatabaseAdapter):
393
400
  paginated_sql = f"{sql} LIMIT %s OFFSET %s"
394
401
  paginated_params = (params or []) + [limit, offset]
395
402
  self._exec_with_handling(cursor, paginated_sql, paginated_params)
396
- rows = [self._decode_blobs(dict(row)) for row in cursor.fetchall()]
403
+ columns = [d[0] for d in cursor.description] if cursor.description else []
404
+ indexes = range(len(columns))
405
+ rows = [
406
+ self._decode_blobs({columns[i]: row[i] for i in indexes})
407
+ for row in cursor.fetchall()
408
+ ]
397
409
 
398
410
  # v3.13.15 (#51): rows are materialised above — close the implicit
399
411
  # read transaction so the connection doesn't sit idle-in-transaction.
@@ -434,15 +446,15 @@ class PostgreSQLAdapter(DatabaseAdapter):
434
446
  # 'keys'`` because this override only handled the single-dict case.)
435
447
  if isinstance(data, list):
436
448
  return super().insert(table, data)
437
- columns = ", ".join(data.keys())
449
+ columns = ", ".join(self.quote_identifier(c) for c in data.keys())
438
450
  placeholders = ", ".join(["%s"] * len(data))
439
- sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders}) RETURNING *"
451
+ sql = f"INSERT INTO {self.quote_identifier(table)} ({columns}) VALUES ({placeholders}) RETURNING *"
440
452
  return self.execute(sql, list(data.values()))
441
453
 
442
454
  def update(self, table: str, data: dict,
443
455
  filter_sql: str = "", params: list = None) -> DatabaseResult:
444
456
  set_clause = ", ".join(f"{k} = %s" for k in data.keys())
445
- sql = f"UPDATE {table} SET {set_clause}"
457
+ sql = f"UPDATE {self.quote_identifier(table)} SET {set_clause}"
446
458
  all_params = list(data.values())
447
459
 
448
460
  if filter_sql:
@@ -454,7 +466,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
454
466
 
455
467
  def delete(self, table: str,
456
468
  filter_sql: str = "", params: list = None) -> DatabaseResult:
457
- sql = f"DELETE FROM {table}"
469
+ sql = f"DELETE FROM {self.quote_identifier(table)}"
458
470
  if filter_sql:
459
471
  translated_filter = SQLTranslator.placeholder_style(filter_sql, "%s")
460
472
  sql += f" WHERE {translated_filter}"