tina4-python 3.13.103__tar.gz → 3.13.104__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 (163) hide show
  1. {tina4_python-3.13.103 → tina4_python-3.13.104}/PKG-INFO +1 -1
  2. {tina4_python-3.13.103 → tina4_python-3.13.104}/pyproject.toml +1 -1
  3. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/__init__.py +8 -1
  4. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/server.py +13 -1
  5. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/__init__.py +6 -2
  6. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/adapter.py +3 -2
  7. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/sql_translator.py +122 -0
  8. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/orm/__init__.py +6 -3
  9. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/orm/fields.py +125 -0
  10. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/orm/model.py +195 -2
  11. tina4_python-3.13.104/tina4_python/orm/point.py +421 -0
  12. tina4_python-3.13.104/tina4_python/query_builder/__init__.py +757 -0
  13. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session/__init__.py +4 -1
  14. tina4_python-3.13.104/tina4_python/sso.py +319 -0
  15. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/swagger/__init__.py +13 -1
  16. tina4_python-3.13.103/tina4_python/query_builder/__init__.py +0 -445
  17. {tina4_python-3.13.103 → tina4_python-3.13.104}/.gitignore +0 -0
  18. {tina4_python-3.13.103 → tina4_python-3.13.104}/README.md +0 -0
  19. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/CLAUDE.md +0 -0
  20. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/HtmlElement.py +0 -0
  21. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/Testing.py +0 -0
  22. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/ai/__init__.py +0 -0
  23. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/ai/client.py +0 -0
  24. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/api/__init__.py +0 -0
  25. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/auth/__init__.py +0 -0
  26. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/cache/__init__.py +0 -0
  27. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/cli/__init__.py +0 -0
  28. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/container/__init__.py +0 -0
  29. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/context/__init__.py +0 -0
  30. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/context/chunker.py +0 -0
  31. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/__init__.py +0 -0
  32. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/cache.py +0 -0
  33. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/constants.py +0 -0
  34. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/events.py +0 -0
  35. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/middleware.py +0 -0
  36. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/port_takeover.py +0 -0
  37. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/rate_limiter.py +0 -0
  38. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/request.py +0 -0
  39. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/response.py +0 -0
  40. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/router.py +0 -0
  41. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/crud/__init__.py +0 -0
  42. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/connection.py +0 -0
  43. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/database_url.py +0 -0
  44. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/firebird.py +0 -0
  45. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/mongodb.py +0 -0
  46. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/mssql.py +0 -0
  47. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/mysql.py +0 -0
  48. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/odbc.py +0 -0
  49. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/postgres.py +0 -0
  50. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/sqlite.py +0 -0
  51. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/debug/__init__.py +0 -0
  52. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/debug/error_overlay.py +0 -0
  53. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/__init__.py +0 -0
  54. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/metrics.py +0 -0
  55. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/plan.py +0 -0
  56. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/project_index.py +0 -0
  57. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/docs.py +0 -0
  58. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/docstore/__init__.py +0 -0
  59. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dotenv/__init__.py +0 -0
  60. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/env.py +0 -0
  61. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/FROND.md +0 -0
  62. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/__init__.py +0 -0
  63. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/compiler.py +0 -0
  64. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/engine.py +0 -0
  65. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/parser.py +0 -0
  66. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/auth/meta.json +0 -0
  67. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  68. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/database/meta.json +0 -0
  69. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  70. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/error-overlay/meta.json +0 -0
  71. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  72. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/orm/meta.json +0 -0
  73. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  74. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  75. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/queue/meta.json +0 -0
  76. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  77. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/rest-api/meta.json +0 -0
  78. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  79. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/templates/meta.json +0 -0
  80. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  81. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  82. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/graphql/__init__.py +0 -0
  83. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/i18n/__init__.py +0 -0
  84. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mcp/__init__.py +0 -0
  85. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mcp/protocol.py +0 -0
  86. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mcp/tools.py +0 -0
  87. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/messenger/__init__.py +0 -0
  88. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/migration/__init__.py +0 -0
  89. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/migration/runner.py +0 -0
  90. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mqtt/__init__.py +0 -0
  91. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mqtt/message.py +0 -0
  92. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/__feedback/widget.js +0 -0
  93. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/css/tina4.css +0 -0
  94. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/css/tina4.min.css +0 -0
  95. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/favicon.ico +0 -0
  96. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/images/logo.svg +0 -0
  97. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  98. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/frond.js +0 -0
  99. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/frond.min.js +0 -0
  100. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  101. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/tina4.min.js +0 -0
  102. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/tina4js.min.js +0 -0
  103. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/swagger/index.html +0 -0
  104. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  105. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/__init__.py +0 -0
  106. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/amqp_url.py +0 -0
  107. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/job.py +0 -0
  108. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/kafka_backend.py +0 -0
  109. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/lite_backend.py +0 -0
  110. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/mongo_backend.py +0 -0
  111. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/rabbitmq_backend.py +0 -0
  112. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/__init__.py +0 -0
  113. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/kafka_backend.py +0 -0
  114. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/mongo_backend.py +0 -0
  115. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  116. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/__init__.py +0 -0
  117. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Attachment.py +0 -0
  118. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Channel.py +0 -0
  119. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/ChannelMember.py +0 -0
  120. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Message.py +0 -0
  121. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Workspace.py +0 -0
  122. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/__init__.py +0 -0
  123. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/storage.py +0 -0
  124. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/seeder/__init__.py +0 -0
  125. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/service/__init__.py +0 -0
  126. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/__init__.py +0 -0
  127. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/memcached_handler.py +0 -0
  128. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  129. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/redis_handler.py +0 -0
  130. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/valkey_handler.py +0 -0
  131. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/components/crud.twig +0 -0
  132. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  133. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  134. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/python/Dockerfile +0 -0
  135. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  136. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/302.twig +0 -0
  137. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/401.twig +0 -0
  138. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/403.twig +0 -0
  139. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/404.twig +0 -0
  140. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/500.twig +0 -0
  141. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/502.twig +0 -0
  142. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/503.twig +0 -0
  143. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/base.twig +0 -0
  144. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/frontend/README.md +0 -0
  145. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/readme.md +0 -0
  146. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/test/__init__.py +0 -0
  147. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/test_client/__init__.py +0 -0
  148. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/validator/__init__.py +0 -0
  161. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/websocket/__init__.py +0 -0
  162. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/websocket/backplane.py +0 -0
  163. {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.103
3
+ Version: 3.13.104
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.103"
3
+ version = "3.13.104"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.103"
54
+ return "3.13.104"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -108,6 +108,9 @@ _LAZY: dict[str, tuple[str, str]] = {
108
108
  "NumericField": ("tina4_python.orm", "NumericField"),
109
109
  "DecimalField": ("tina4_python.orm", "DecimalField"),
110
110
  "JSONField": ("tina4_python.orm", "JSONField"),
111
+ "PointField": ("tina4_python.orm", "PointField"),
112
+ "Point": ("tina4_python.orm", "Point"),
113
+ "feature_collection": ("tina4_python.orm", "feature_collection"),
111
114
  "ForeignKeyField": ("tina4_python.orm", "ForeignKeyField"),
112
115
  "IntField": ("tina4_python.orm", "IntField"),
113
116
  "StrField": ("tina4_python.orm", "StrField"),
@@ -141,6 +144,10 @@ _LAZY: dict[str, tuple[str, str]] = {
141
144
  "AiHTTPError": ("tina4_python.ai", "AiHTTPError"),
142
145
  "AiTimeoutError": ("tina4_python.ai", "AiTimeoutError"),
143
146
  "AiParseError": ("tina4_python.ai", "AiParseError"),
147
+ # Provider-neutral OpenID Connect SSO (ADR-0056)
148
+ "Sso": ("tina4_python.sso", "Sso"),
149
+ "SSO": ("tina4_python.sso", "SSO"),
150
+ "SsoError": ("tina4_python.sso", "SsoError"),
144
151
  # SOAP / WSDL
145
152
  "WSDL": ("tina4_python.wsdl", "WSDL"),
146
153
  "wsdl_operation": ("tina4_python.wsdl", "wsdl_operation"),
@@ -1691,8 +1691,16 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
1691
1691
  if not _auth_ok:
1692
1692
  _session = getattr(request, "session", None)
1693
1693
  if _session:
1694
+ # A provider-verified OIDC identity is handed into the SAME auth
1695
+ # gate as JWT. Provider credentials stay in the reserved Session
1696
+ # value and never enter request.user.
1697
+ _sso = _session.get("_tina4_sso")
1698
+ _sso_identity = _sso.get("identity") if isinstance(_sso, dict) else None
1699
+ if isinstance(_sso_identity, dict) and _sso_identity.get("issuer") and _sso_identity.get("subject"):
1700
+ _auth_ok = True
1701
+ request.user = _sso_identity
1694
1702
  _session_token = _session.get("token") if _session else ""
1695
- if _session_token:
1703
+ if not _auth_ok and _session_token:
1696
1704
  try:
1697
1705
  from tina4_python.auth import Auth
1698
1706
  _payload = Auth.valid_token_static(_session_token)
@@ -3669,6 +3677,10 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
3669
3677
 
3670
3678
  # Auto-discover routes
3671
3679
  _auto_discover("src")
3680
+ # Configuration-first OIDC: canonical routes appear only when configured,
3681
+ # after app discovery so collisions fail loudly rather than overwrite.
3682
+ from tina4_python.sso import Sso as _Sso
3683
+ _Sso.mount_configured()
3672
3684
  route_count = len(Router.get_routes())
3673
3685
  Log.info(f"Discovered {route_count} routes")
3674
3686
 
@@ -11,7 +11,11 @@ SQL-first database layer. One interface, many drivers.
11
11
  row = db.fetch_one("SELECT * FROM users WHERE id = ?", [42])
12
12
  db.execute("INSERT INTO users (name) VALUES (?)", ["Alice"])
13
13
  """
14
- from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult, SQLTranslator
14
+ from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult
15
+ from tina4_python.database.sql_translator import SQLTranslator, SpatialNotSupportedError
15
16
  from tina4_python.database.connection import Database
16
17
 
17
- __all__ = ["Database", "DatabaseAdapter", "DatabaseResult", "SQLTranslator"]
18
+ __all__ = [
19
+ "Database", "DatabaseAdapter", "DatabaseResult", "SQLTranslator",
20
+ "SpatialNotSupportedError",
21
+ ]
@@ -1131,9 +1131,10 @@ class SqlCrudMixin:
1131
1131
  # ── SQL Translation Rules ──────────────────────────────────────
1132
1132
  # Reusable translation functions for common cross-engine quirks.
1133
1133
 
1134
-
1135
1134
  # SQLTranslator moved to sql_translator.py (feature 3: the adapter module is the
1136
1135
  # adapter contract and nothing else). Re-exported here because the sqlite,
1137
1136
  # firebird, mssql and odbc adapters import it from this module, and a file move
1138
1137
  # is not the place to churn their imports.
1139
- from tina4_python.database.sql_translator import SQLTranslator # noqa: E402,F401
1138
+ from tina4_python.database.sql_translator import ( # noqa: E402,F401
1139
+ SQLTranslator, SpatialNotSupportedError,
1140
+ )
@@ -16,6 +16,10 @@ by engine; moving it here would flatten that override.
16
16
  import re
17
17
 
18
18
 
19
+ class SpatialNotSupportedError(NotImplementedError):
20
+ """The selected database engine cannot honor the GIS contract."""
21
+
22
+
19
23
  class SQLTranslator:
20
24
  """Cross-engine SQL translator.
21
25
 
@@ -23,6 +27,124 @@ class SQLTranslator:
23
27
  and stateless — just string transforms.
24
28
  """
25
29
 
30
+ SPATIAL_ENGINES = ("postgres", "postgresql")
31
+ _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
32
+
33
+ @classmethod
34
+ def supports_spatial(cls, engine: str) -> bool:
35
+ """Return whether Tina4 has a spatial provider for ``engine``."""
36
+ name = (engine or "").lower()
37
+ name = cls.ENGINE_ALIASES.get(name, name)
38
+ return name in cls.SPATIAL_ENGINES
39
+
40
+ @classmethod
41
+ def require_spatial(cls, engine: str, feature: str) -> str:
42
+ """Return the normalized spatial engine or fail with an actionable error."""
43
+ name = (engine or "unknown").lower()
44
+ name = cls.ENGINE_ALIASES.get(name, name)
45
+ if cls.supports_spatial(name):
46
+ return name
47
+ raise SpatialNotSupportedError(
48
+ f"{feature} is not supported on the '{name}' database engine. "
49
+ "Tina4 GIS support is PostGIS-first: use PostgreSQL with the "
50
+ "PostGIS extension (CREATE EXTENSION postgis). Tina4 will not "
51
+ "replace a spatial query with an approximate latitude/longitude query. "
52
+ "If the application needs storage only and no GIS behavior, declare "
53
+ "separate longitude and latitude FloatField columns instead."
54
+ )
55
+
56
+ @classmethod
57
+ def identifier(cls, name: str, what: str = "column") -> str:
58
+ """Validate an identifier before placing it in spatial SQL."""
59
+ if not isinstance(name, str) or not cls._IDENTIFIER.fullmatch(name):
60
+ raise ValueError(
61
+ f"Spatial {what} name {name!r} is not a valid SQL identifier"
62
+ )
63
+ return name
64
+
65
+ @staticmethod
66
+ def _srid(srid) -> int:
67
+ """Coerce the interpolated SRID to an integer."""
68
+ if isinstance(srid, bool):
69
+ raise ValueError(f"Spatial SRID must be an integer, got {srid!r}")
70
+ try:
71
+ value = int(srid)
72
+ except (TypeError, ValueError) as error:
73
+ raise ValueError(f"Spatial SRID must be an integer, got {srid!r}") from error
74
+ if value <= 0:
75
+ raise ValueError(f"Spatial SRID must be positive, got {value}")
76
+ return value
77
+
78
+ @classmethod
79
+ def point_column_type(cls, engine: str, srid: int = 4326) -> str:
80
+ """Return PostGIS geography Point DDL."""
81
+ cls.require_spatial(engine, "PointField")
82
+ return f"geography(Point,{cls._srid(srid)})"
83
+
84
+ @classmethod
85
+ def spatial_index(cls, engine: str, table: str, column: str) -> str:
86
+ """Return idempotent PostGIS GiST-index DDL."""
87
+ cls.require_spatial(engine, "spatial index creation")
88
+ table = cls.identifier(table, "table")
89
+ column = cls.identifier(column)
90
+ index = f"{table.replace('.', '_')}_{column}_gist"
91
+ return f"CREATE INDEX IF NOT EXISTS {index} ON {table} USING GIST ({column})"
92
+
93
+ @classmethod
94
+ def point_literal(cls, engine: str, srid: int = 4326) -> str:
95
+ """Return a bound PostGIS point expression in longitude/latitude order."""
96
+ cls.require_spatial(engine, "spatial predicates")
97
+ return f"ST_SetSRID(ST_MakePoint(?, ?), {cls._srid(srid)})::geography"
98
+
99
+ @classmethod
100
+ def within_distance(cls, engine: str, column: str, srid: int = 4326) -> str:
101
+ """Return a bound radius predicate whose distance uses metres."""
102
+ cls.require_spatial(engine, "within_distance()")
103
+ column = cls.identifier(column)
104
+ return f"ST_DWithin({column}, {cls.point_literal(engine, srid)}, ?)"
105
+
106
+ @classmethod
107
+ def distance(cls, engine: str, column: str, srid: int = 4326) -> str:
108
+ """Return a bound spheroid-distance expression in metres."""
109
+ cls.require_spatial(engine, "order_by_distance()")
110
+ column = cls.identifier(column)
111
+ return f"ST_Distance({column}, {cls.point_literal(engine, srid)})"
112
+
113
+ @classmethod
114
+ def distance_as(cls, engine: str, column: str, alias: str, srid: int = 4326) -> str:
115
+ """Return an aliased bound distance expression for a SELECT list."""
116
+ alias = cls.identifier(alias, "result alias")
117
+ return f"{cls.distance(engine, column, srid)} AS {alias}"
118
+
119
+ @classmethod
120
+ def geometry_literal(cls, engine: str, form: str = "ewkt", srid: int = 4326) -> str:
121
+ """Return a one-parameter PostGIS geometry expression."""
122
+ cls.require_spatial(engine, "spatial predicates")
123
+ if form == "ewkt":
124
+ return "ST_GeogFromText(?)"
125
+ if form == "geojson":
126
+ return f"ST_SetSRID(ST_GeomFromGeoJSON(?), {cls._srid(srid)})::geography"
127
+ raise ValueError(
128
+ f"Spatial geometry form {form!r} is not supported; use 'ewkt' or 'geojson'"
129
+ )
130
+
131
+ @classmethod
132
+ def intersects(cls, engine: str, column: str, form: str = "ewkt",
133
+ srid: int = 4326) -> str:
134
+ """Return a bound PostGIS intersection predicate."""
135
+ column = cls.identifier(column)
136
+ return f"ST_Intersects({column}, {cls.geometry_literal(engine, form, srid)})"
137
+
138
+ @classmethod
139
+ def bbox(cls, engine: str, column: str, srid: int = 4326) -> str:
140
+ """Return a bound PostGIS bounding-box predicate."""
141
+ cls.require_spatial(engine, "bbox()")
142
+ column = cls.identifier(column)
143
+ return (
144
+ f"ST_Intersects({column}, "
145
+ f"ST_MakeEnvelope(?, ?, ?, ?, {cls._srid(srid)})::geography)"
146
+ )
147
+
26
148
  @staticmethod
27
149
  def limit_to_rows(sql: str) -> str:
28
150
  """Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
@@ -20,11 +20,12 @@ from tina4_python.orm.fields import (
20
20
  Field,
21
21
  IntegerField, StringField, BooleanField, FloatField,
22
22
  DateTimeField, TextField, BlobField, NumericField, DecimalField, JSONField,
23
- ForeignKeyField,
23
+ PointField, ForeignKeyField,
24
24
  IntField, StrField, BoolField, # short aliases
25
25
  has_many, has_one, belongs_to, # relationship descriptors
26
26
  )
27
- from tina4_python.orm.model import ORM, bind_database
27
+ from tina4_python.orm.point import Point
28
+ from tina4_python.orm.model import ORM, bind_database, feature_collection
28
29
 
29
30
  __all__ = [
30
31
  "ORM", "bind_database",
@@ -32,9 +33,11 @@ __all__ = [
32
33
  # Verbose (preferred)
33
34
  "IntegerField", "StringField", "BooleanField", "FloatField",
34
35
  "DateTimeField", "TextField", "BlobField", "NumericField", "DecimalField", "JSONField",
35
- "ForeignKeyField",
36
+ "PointField", "ForeignKeyField",
36
37
  # Short aliases (backwards compat)
37
38
  "IntField", "StrField", "BoolField",
38
39
  # Relationships
39
40
  "has_many", "has_one", "belongs_to",
41
+ # Spatial
42
+ "Point", "feature_collection",
40
43
  ]
@@ -422,6 +422,131 @@ class JSONField(Field):
422
422
  raise ValueError(f"Field '{self.name}': value is not JSON-serializable") from e
423
423
 
424
424
 
425
+ class PointField(Field):
426
+ """A column that stores a single geographic point, SRID-aware.
427
+
428
+ You work with a :class:`~tina4_python.orm.point.Point` value object; the ORM
429
+ writes EWKT on save and parses the engine's (HEX)EWKB back into a ``Point``
430
+ on load, so ``model.location.lat`` always works regardless of engine wire
431
+ format.
432
+
433
+ Assignment accepts any of these and normalises to ``Point``::
434
+
435
+ site.location = (18.4241, -33.9249) # (lon, lat) tuple
436
+ site.location = "POINT(18.4241 -33.9249)" # WKT
437
+ site.location = "SRID=4326;POINT(18.4241 -33.9249)" # EWKT
438
+ site.location = {"type": "Point", "coordinates": [18.42, -33.9]} # GeoJSON
439
+
440
+ Reading it back gives the value object::
441
+
442
+ site.location.lon # 18.4241
443
+ site.location.lat # -33.9249
444
+ site.location.wkt # 'POINT(18.4241 -33.9249)'
445
+ site.location.geojson # {'type': 'Point', 'coordinates': [18.4241, -33.9249]}
446
+
447
+ ``to_dict()`` / ``to_json()`` / ``response()`` emit the GeoJSON form, so a
448
+ map front end consumes a Tina4 route with no translation layer.
449
+
450
+ **Engine support is PostGIS-first and never silently wrong.** The column DDL
451
+ is ``geography(Point,<srid>)`` on PostgreSQL, and
452
+ :meth:`~tina4_python.orm.model.ORM.create_table` also creates a GiST index
453
+ for it. On any engine without spatial support, ``create_table()`` raises
454
+ :class:`~tina4_python.database.adapter.SpatialNotSupportedError` naming that
455
+ engine rather than creating a wrong column type.
456
+
457
+ Usage::
458
+
459
+ class ChargePoint(ORM):
460
+ id = IntegerField(primary_key=True, auto_increment=True)
461
+ name = StringField()
462
+ location = PointField() # geography(Point,4326) + GiST index
463
+
464
+ ChargePoint({"name": "V&A", "location": (18.4241, -33.9249)}).save()
465
+
466
+ near = ChargePoint.query() \\
467
+ .within_distance("location", (18.42, -33.92), 5000) \\
468
+ .order_by_distance("location", (18.42, -33.92)) \\
469
+ .get()
470
+
471
+ Args:
472
+ srid: Spatial reference id for the column. Defaults to 4326 (WGS 84).
473
+ spatial_index: Create the engine's spatial index for this column in
474
+ ``create_table()``. Defaults to True — a radius query without one
475
+ is a full table scan.
476
+ """
477
+
478
+ def __init__(self, srid: int = None, spatial_index: bool = True, **kwargs):
479
+ from tina4_python.orm.point import DEFAULT_SRID, Point
480
+ super().__init__(Point, **kwargs)
481
+ self.kind = "PointField"
482
+ self.srid = DEFAULT_SRID if srid is None else int(srid)
483
+ self.spatial_index = spatial_index
484
+
485
+ def _resolve_default(self):
486
+ # ORM.__init__ seeds attributes straight from _resolve_default (it does
487
+ # not route through validate), so coerce here too — otherwise a
488
+ # ``default=(lon, lat)`` would leave ``model.location`` as a raw tuple on
489
+ # an unsaved instance and ``.lat`` would fail.
490
+ from tina4_python.orm.point import Point
491
+
492
+ default = super()._resolve_default()
493
+ if default is None:
494
+ return default
495
+ return self._parse_point(default)
496
+
497
+ def _parse_point(self, value):
498
+ from tina4_python.orm.point import Point
499
+
500
+ try:
501
+ point = Point.parse(value, self.srid)
502
+ except ValueError as e:
503
+ raise ValueError(f"Field '{self.name}': {e}") from e
504
+ if point.srid != self.srid:
505
+ raise ValueError(
506
+ f"Field '{self.name}' expects SRID {self.srid}; received {point.srid}. "
507
+ "Tina4 never silently reprojects or restamps spatial data."
508
+ )
509
+ return point
510
+
511
+ def coerce(self, value):
512
+ """Hydrate/assign through the Point parser, not ``Point(value)``."""
513
+ return None if value is None else self._parse_point(value)
514
+
515
+ def validate(self, value):
516
+ """Coerce any supported point representation into a ``Point``.
517
+
518
+ Runs on BOTH the write path (tuple / WKT / GeoJSON from a route) and the
519
+ read path (the engine's HEXEWKB), which is what makes the column
520
+ round-trip identically no matter how it was assigned.
521
+ """
522
+ from tina4_python.orm.point import Point
523
+
524
+ if value is None:
525
+ if self.required and self.default is None:
526
+ raise ValueError(f"Field '{self.name}' is required")
527
+ default = self._resolve_default()
528
+ return None if default is None else Point.parse(default, self.srid)
529
+
530
+ point = self._parse_point(value)
531
+
532
+ if self.validator is not None:
533
+ self.validator(point)
534
+ return point
535
+
536
+ def to_db(self, value):
537
+ """Serialise to EWKT — ``SRID=4326;POINT(lon lat)``.
538
+
539
+ EWKT carries the SRID with the geometry, so the engine never has to
540
+ assume one, and it is accepted verbatim by PostGIS geography input. The
541
+ value is bound as a normal parameter; it is never formatted into SQL.
542
+ """
543
+ from tina4_python.orm.point import Point
544
+
545
+ if value is None:
546
+ return None
547
+ return self._parse_point(value).ewkt
548
+
549
+
425
550
  class ForeignKeyField(Field):
426
551
  """Integer field that references another model's primary key.
427
552
 
@@ -186,6 +186,12 @@ class ORMMeta(type):
186
186
 
187
187
  namespace["_fields"] = fields
188
188
  namespace["_relationships"] = relationships
189
+ # Spatial (PointField) attribute names, resolved once at class creation.
190
+ # to_dict() and create_table() branch on this, so a model with no
191
+ # geometry pays a single falsy check and nothing else.
192
+ namespace["_spatial_fields"] = tuple(
193
+ key for key, f in fields.items() if getattr(f, "kind", None) == "PointField"
194
+ )
189
195
  cls = super().__new__(mcs, name, bases, namespace)
190
196
 
191
197
  # Auto-register for CRUD if flagged
@@ -217,6 +223,9 @@ class ORM(metaclass=ORMMeta):
217
223
  auto_crud: bool = False # Set True to auto-register CRUD routes
218
224
  _db: str | object | None = None # Per-model database override
219
225
  _fields: dict[str, Field] = {}
226
+ # PointField attribute names on this model, filled by ORMMeta. Empty for
227
+ # every non-spatial model, which is what keeps the spatial branches free.
228
+ _spatial_fields: tuple[str, ...] = ()
220
229
  # Last save() failure cause (validation message or DB error). None when
221
230
  # the most recent save() succeeded. Mirrors db.get_error() so a caller
222
231
  # that checks ``if not model.save():`` can still recover the real cause
@@ -333,11 +342,18 @@ class ORM(metaclass=ORMMeta):
333
342
  Usage:
334
343
  results = User.query().where("active = ?", [1]).order_by("name").get()
335
344
 
345
+ The model's primary-key column travels with the builder so
346
+ ``order_by_distance()`` can break exact distance ties on it — without a
347
+ tie-break the row order for equidistant rows is engine-defined and
348
+ pagination can skip or repeat rows.
349
+
336
350
  Returns:
337
351
  A QueryBuilder instance bound to this model's table and database.
338
352
  """
339
353
  from tina4_python.query_builder import QueryBuilder
340
- return QueryBuilder.from_table(cls._get_table(), cls._get_db())
354
+ return QueryBuilder.from_table(
355
+ cls._get_table(), cls._get_db(), cls._get_pk_column()
356
+ )
341
357
 
342
358
  @classmethod
343
359
  def _get_table(cls) -> str:
@@ -451,6 +467,17 @@ class ORM(metaclass=ORMMeta):
451
467
  params.append(getattr(self, name, None))
452
468
  return " AND ".join(clauses), params
453
469
 
470
+ @classmethod
471
+ def _get_pk_column(cls) -> str:
472
+ """Database COLUMN name of the primary key (field_mapping aware).
473
+
474
+ ``_get_pk()`` returns the attribute name; SQL needs the column it maps
475
+ to. QueryBuilder uses this as the stable ORDER BY tie-break.
476
+ """
477
+ pk = cls._get_pk()
478
+ field = cls._fields.get(pk)
479
+ return cls.field_mapping.get(pk, (field.column if field else None) or pk)
480
+
454
481
  # ── CRUD ────────────────────────────────────────────────────
455
482
 
456
483
  def save(self) -> Self | bool:
@@ -1063,7 +1090,28 @@ class ORM(metaclass=ORMMeta):
1063
1090
  is the safer choice on Firebird)
1064
1091
 
1065
1092
  Auto-increment primary keys use engine-appropriate syntax.
1066
- Returns True on success.
1093
+
1094
+ Returns:
1095
+ True on success, False if the DDL failed (the cause is logged).
1096
+
1097
+ Raises:
1098
+ SpatialNotSupportedError: **narrowed contract — a spatial model on
1099
+ a non-spatial engine RAISES, it does not return False.** Every
1100
+ other failure here is recoverable and reported as ``False``,
1101
+ but there is no safe fallback column type for geometry: a
1102
+ ``TEXT`` stand-in would accept writes, return rows, and be
1103
+ silently wrong for every distance query afterwards. The
1104
+ exception names the engine and the alternative. It is resolved
1105
+ BEFORE the ``table_exists`` short-circuit, so the refusal never
1106
+ depends on whether the table happens to exist yet, and it fires
1107
+ on the engine — not on the field — so a PointField model is
1108
+ portable source that simply cannot be deployed onto an engine
1109
+ that would lie about it.
1110
+
1111
+ Mirrors (PHP / Ruby / Node) must THROW here too. Returning
1112
+ false would collapse "this engine cannot do spatial" into the
1113
+ same signal as "the DDL failed", and the caller would create
1114
+ the table by hand and carry on.
1067
1115
  """
1068
1116
  from tina4_python.database.adapter import SQLTranslator
1069
1117
 
@@ -1116,6 +1164,22 @@ class ORM(metaclass=ORMMeta):
1116
1164
  else:
1117
1165
  json_sql = "TEXT"
1118
1166
 
1167
+ # PointField -> the engine's spatial type via the SQLTranslator dialect
1168
+ # seam (``geography(Point,<srid>)`` on PostGIS). Resolved BEFORE the
1169
+ # table_exists short-circuit so a spatial model on a non-spatial engine
1170
+ # ALWAYS raises SpatialNotSupportedError naming that engine — a wrong
1171
+ # column type is never created, and the error does not depend on whether
1172
+ # the table happens to exist yet. This is the loud-not-silent contract:
1173
+ # unlike the other type mappings there is no safe fallback for geometry.
1174
+ point_sql: dict[str, str] = {}
1175
+ for name, field_obj in cls._fields.items():
1176
+ if getattr(field_obj, "kind", None) != "PointField":
1177
+ continue
1178
+ col_name = cls.field_mapping.get(name, field_obj.column or name)
1179
+ point_sql[col_name] = SQLTranslator.point_column_type(
1180
+ engine, getattr(field_obj, "srid", 4326)
1181
+ )
1182
+
1119
1183
  # Don't recreate if table already exists
1120
1184
  if db.table_exists(table):
1121
1185
  return True
@@ -1151,6 +1215,8 @@ class ORM(metaclass=ORMMeta):
1151
1215
  sql_type = "BLOB"
1152
1216
  elif kind == "JSONField":
1153
1217
  sql_type = json_sql
1218
+ elif kind == "PointField":
1219
+ sql_type = point_sql[col_name]
1154
1220
  else:
1155
1221
  # Fallback based on field_type
1156
1222
  ft = field_obj.field_type
@@ -1240,6 +1306,16 @@ class ORM(metaclass=ORMMeta):
1240
1306
  # it propagate out of create_table().
1241
1307
  try:
1242
1308
  db.execute(sql)
1309
+ # A spatial predicate without a spatial index is a full table scan,
1310
+ # so the index ships WITH the column rather than as a thing the
1311
+ # developer must remember. IF NOT EXISTS keeps it idempotent.
1312
+ for name, field_obj in cls._fields.items():
1313
+ if getattr(field_obj, "kind", None) != "PointField":
1314
+ continue
1315
+ if not getattr(field_obj, "spatial_index", True):
1316
+ continue
1317
+ col_name = cls.field_mapping.get(name, field_obj.column or name)
1318
+ db.execute(SQLTranslator.spatial_index(engine, table, col_name))
1243
1319
  db.commit()
1244
1320
  except Exception as e:
1245
1321
  from tina4_python.debug import Log
@@ -1593,6 +1669,16 @@ class ORM(metaclass=ORMMeta):
1593
1669
  else:
1594
1670
  result = {name: getattr(self, name) for name in self._fields}
1595
1671
 
1672
+ # Spatial fields serialise as GeoJSON geometry, so to_dict() / to_json()
1673
+ # / response() all emit map-ready output from one place. `_spatial_fields`
1674
+ # is empty for every non-spatial model, so this costs one falsy check.
1675
+ if self._spatial_fields:
1676
+ for name in self._spatial_fields:
1677
+ key = snake_to_camel(name) if case == "camel" else name
1678
+ point = result.get(key)
1679
+ if point is not None:
1680
+ result[key] = point.geojson
1681
+
1596
1682
  if include:
1597
1683
  # Group includes: top-level and nested
1598
1684
  top_level = {}
@@ -1639,6 +1725,57 @@ class ORM(metaclass=ORMMeta):
1639
1725
  """Convert to a list of values (alias for to_array)."""
1640
1726
  return self.to_array()
1641
1727
 
1728
+ def to_feature(self, geometry_field: str = None, include: list[str] = None) -> dict:
1729
+ """Convert to an RFC 7946 GeoJSON ``Feature``.
1730
+
1731
+ The model's point field becomes the feature ``geometry``; every other
1732
+ field becomes a ``properties`` entry, with the primary key also lifted to
1733
+ the feature ``id`` (where GIS clients look for it).
1734
+
1735
+ ChargePoint.find(1).to_feature()
1736
+ # {"type": "Feature", "id": 1,
1737
+ # "geometry": {"type": "Point", "coordinates": [18.4241, -33.9249]},
1738
+ # "properties": {"name": "V&A"}}
1739
+
1740
+ Args:
1741
+ geometry_field: Which PointField to use as the geometry. Required
1742
+ only when the model declares more than one.
1743
+ include: Relationships to include in ``properties`` (as for
1744
+ :meth:`to_dict`).
1745
+
1746
+ Raises:
1747
+ ValueError: if the model has no PointField, if ``geometry_field`` is
1748
+ not one, or if it is ambiguous with several point fields.
1749
+ """
1750
+ if not self._spatial_fields:
1751
+ raise ValueError(
1752
+ f"{type(self).__name__}.to_feature(): the model has no PointField, "
1753
+ f"so it has no geometry. Add a PointField, or use to_dict()."
1754
+ )
1755
+ if geometry_field is None:
1756
+ if len(self._spatial_fields) > 1:
1757
+ raise ValueError(
1758
+ f"{type(self).__name__}.to_feature(): the model has several "
1759
+ f"point fields {list(self._spatial_fields)} — pass "
1760
+ f"geometry_field= to choose one."
1761
+ )
1762
+ geometry_field = self._spatial_fields[0]
1763
+ elif geometry_field not in self._spatial_fields:
1764
+ raise ValueError(
1765
+ f"{type(self).__name__}.to_feature(): {geometry_field!r} is not a "
1766
+ f"PointField on this model. Point fields: "
1767
+ f"{list(self._spatial_fields)}."
1768
+ )
1769
+
1770
+ properties = self.to_dict(include=include)
1771
+ geometry = properties.pop(geometry_field, None)
1772
+ feature = {"type": "Feature", "geometry": geometry, "properties": properties}
1773
+ pk = self._get_pk()
1774
+ pk_value = properties.get(pk)
1775
+ if pk_value is not None:
1776
+ feature["id"] = pk_value
1777
+ return feature
1778
+
1642
1779
  def to_json(self, include: list[str] = None) -> str:
1643
1780
  """Convert to JSON string."""
1644
1781
  import json
@@ -1656,3 +1793,59 @@ class ORM(metaclass=ORMMeta):
1656
1793
  pk = self._get_pk()
1657
1794
  pk_val = getattr(self, pk, None)
1658
1795
  return f"<{self.__class__.__name__} {pk}={pk_val}>"
1796
+
1797
+
1798
+ def feature_collection(models, geometry_field: str = None, include: list[str] = None) -> dict:
1799
+ """Wrap models with a PointField into an RFC 7946 ``FeatureCollection``.
1800
+
1801
+ This is the shape a map front end (Leaflet, MapLibre, OpenLayers, tina4-js on
1802
+ a map) consumes directly, so a route becomes a one-liner::
1803
+
1804
+ from tina4_python.orm import feature_collection
1805
+
1806
+ @get("/api/charge-points.geojson")
1807
+ async def charge_points(request, response):
1808
+ return response(feature_collection(ChargePoint.all()))
1809
+
1810
+ ``response()`` then serialises the returned dict as JSON exactly as it does
1811
+ any other dict — nothing new on the response path.
1812
+
1813
+ It is deliberately **explicit** rather than an automatic transform of
1814
+ ``response(list_of_models)``: a list of models always serialises to a JSON
1815
+ array, and silently switching that to a FeatureCollection because a field
1816
+ type is present would be exactly the kind of magic Tina4 avoids.
1817
+
1818
+ Args:
1819
+ models: An ORM instance or an iterable of them — e.g. ``Model.all()``,
1820
+ ``Model.where(...)``, ``Model.select(...)``.
1821
+ geometry_field: Which PointField supplies the geometry (only needed when
1822
+ a model declares more than one).
1823
+ include: Relationships to include in each feature's ``properties``.
1824
+
1825
+ Returns:
1826
+ ``{"type": "FeatureCollection", "features": [...]}`` — an empty
1827
+ ``features`` list for empty input, never None.
1828
+
1829
+ Raises:
1830
+ TypeError: naming what it got, if any element is not an ORM model
1831
+ (``QueryBuilder.get()`` returns raw row dicts, which carry no field
1832
+ definitions and therefore no geometry — use ``Model.where()`` /
1833
+ ``Model.select()`` for GeoJSON output).
1834
+ """
1835
+ if models is None:
1836
+ rows = []
1837
+ elif isinstance(models, ORM):
1838
+ rows = [models]
1839
+ else:
1840
+ rows = models
1841
+ features = []
1842
+ for row in rows:
1843
+ if not isinstance(row, ORM):
1844
+ raise TypeError(
1845
+ f"feature_collection(): expected ORM model instances, got "
1846
+ f"{type(row).__name__}. Pass Model.all() / Model.where(...) / a "
1847
+ f"list of models — a raw row dict has no field definitions, so "
1848
+ f"there is no geometry field to find."
1849
+ )
1850
+ features.append(row.to_feature(geometry_field=geometry_field, include=include))
1851
+ return {"type": "FeatureCollection", "features": features}