tina4-python 3.13.52__tar.gz → 3.13.54__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 (158) hide show
  1. {tina4_python-3.13.52 → tina4_python-3.13.54}/PKG-INFO +1 -1
  2. {tina4_python-3.13.52 → tina4_python-3.13.54}/pyproject.toml +1 -1
  3. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/CLAUDE.md +3 -1
  4. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/__init__.py +1 -1
  5. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/graphql/__init__.py +2 -1
  6. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/migration/runner.py +48 -6
  7. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/orm/__init__.py +2 -2
  8. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/orm/fields.py +86 -0
  9. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/orm/model.py +23 -2
  10. {tina4_python-3.13.52 → tina4_python-3.13.54}/.gitignore +0 -0
  11. {tina4_python-3.13.52 → tina4_python-3.13.54}/README.md +0 -0
  12. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/HtmlElement.py +0 -0
  13. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/Testing.py +0 -0
  14. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/ai/__init__.py +0 -0
  15. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/api/__init__.py +0 -0
  16. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/auth/__init__.py +0 -0
  17. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/cache/__init__.py +0 -0
  18. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/cli/__init__.py +0 -0
  19. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/__init__.py +0 -0
  21. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/cache.py +0 -0
  22. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/constants.py +0 -0
  23. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/events.py +0 -0
  24. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/middleware.py +0 -0
  25. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/rate_limiter.py +0 -0
  26. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/request.py +0 -0
  27. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/response.py +0 -0
  28. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/router.py +0 -0
  29. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/core/server.py +0 -0
  30. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/crud/__init__.py +0 -0
  31. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/__init__.py +0 -0
  32. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/adapter.py +0 -0
  33. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/connection.py +0 -0
  34. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/mssql.py +0 -0
  37. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/mysql.py +0 -0
  38. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/odbc.py +0 -0
  39. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/postgres.py +0 -0
  40. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/database/sqlite.py +0 -0
  41. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/debug/__init__.py +0 -0
  42. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/debug/error_overlay.py +0 -0
  43. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/dev_admin/__init__.py +0 -0
  44. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/dev_admin/metrics.py +0 -0
  45. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/dev_admin/plan.py +0 -0
  46. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/dev_admin/project_index.py +0 -0
  47. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/docs.py +0 -0
  48. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/docstore/__init__.py +0 -0
  49. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/dotenv/__init__.py +0 -0
  50. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/env.py +0 -0
  51. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/frond/FROND.md +0 -0
  52. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/frond/__init__.py +0 -0
  53. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/frond/engine.py +0 -0
  54. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/auth/meta.json +0 -0
  55. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  56. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/database/meta.json +0 -0
  57. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  58. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/error-overlay/meta.json +0 -0
  59. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  60. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/orm/meta.json +0 -0
  61. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  62. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  63. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/queue/meta.json +0 -0
  64. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  65. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/rest-api/meta.json +0 -0
  66. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  67. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/templates/meta.json +0 -0
  68. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  69. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  70. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/i18n/__init__.py +0 -0
  71. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/mcp/__init__.py +0 -0
  72. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/mcp/protocol.py +0 -0
  73. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/mcp/tools.py +0 -0
  74. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/messenger/__init__.py +0 -0
  75. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/migration/__init__.py +0 -0
  76. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/__feedback/widget.js +0 -0
  77. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/css/tina4.css +0 -0
  78. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/css/tina4.min.css +0 -0
  79. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/favicon.ico +0 -0
  80. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/images/logo.svg +0 -0
  81. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  82. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/js/frond.js +0 -0
  83. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/js/frond.min.js +0 -0
  84. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  85. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  86. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/js/tina4.min.js +0 -0
  87. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/js/tina4js.min.js +0 -0
  88. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/swagger/index.html +0 -0
  89. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  90. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/query_builder/__init__.py +0 -0
  91. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue/__init__.py +0 -0
  92. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue/job.py +0 -0
  93. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue/kafka_backend.py +0 -0
  94. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue/lite_backend.py +0 -0
  95. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue/mongo_backend.py +0 -0
  96. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue/rabbitmq_backend.py +0 -0
  97. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue_backends/__init__.py +0 -0
  98. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue_backends/kafka_backend.py +0 -0
  99. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue_backends/mongo_backend.py +0 -0
  100. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  101. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/__init__.py +0 -0
  102. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  103. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_badges.scss +0 -0
  104. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  105. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_cards.scss +0 -0
  106. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_forms.scss +0 -0
  107. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_grid.scss +0 -0
  108. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_modals.scss +0 -0
  109. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_nav.scss +0 -0
  110. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_reset.scss +0 -0
  111. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_tables.scss +0 -0
  112. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_typography.scss +0 -0
  113. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  114. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/_variables.scss +0 -0
  115. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/base.scss +0 -0
  116. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/colors.scss +0 -0
  117. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/scss/tina4css/tina4.scss +0 -0
  118. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/seeder/__init__.py +0 -0
  119. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/service/__init__.py +0 -0
  120. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/session/__init__.py +0 -0
  121. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/session_handlers/__init__.py +0 -0
  122. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  123. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/session_handlers/redis_handler.py +0 -0
  124. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/session_handlers/valkey_handler.py +0 -0
  125. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/swagger/__init__.py +0 -0
  126. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/components/crud.twig +0 -0
  127. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  128. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  129. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/docker/python/Dockerfile +0 -0
  130. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  131. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/302.twig +0 -0
  132. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/401.twig +0 -0
  133. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/403.twig +0 -0
  134. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/404.twig +0 -0
  135. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/500.twig +0 -0
  136. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/502.twig +0 -0
  137. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/503.twig +0 -0
  138. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/errors/base.twig +0 -0
  139. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/frontend/README.md +0 -0
  140. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/templates/readme.md +0 -0
  141. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/test/__init__.py +0 -0
  142. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/test_client/__init__.py +0 -0
  143. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/validator/__init__.py +0 -0
  156. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/websocket/__init__.py +0 -0
  157. {tina4_python-3.13.52 → tina4_python-3.13.54}/tina4_python/websocket/backplane.py +0 -0
  158. {tina4_python-3.13.52 → tina4_python-3.13.54}/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.52
3
+ Version: 3.13.54
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.52"
3
+ version = "3.13.54"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -901,7 +901,9 @@ User.query().where("name = ?", ["Alice"]).to_mongo()
901
901
  ```
902
902
 
903
903
  ### Available field types
904
- `IntegerField`, `StringField`, `TextField`, `DateTimeField`, `NumericField`, `BlobField`, `BooleanField`, `FloatField`, `ForeignKeyField`
904
+ `IntegerField`, `StringField`, `TextField`, `DateTimeField`, `NumericField`, `BlobField`, `BooleanField`, `FloatField`, `JSONField`, `ForeignKeyField`
905
+
906
+ `JSONField()` stores a `dict`/`list` as JSON. You work with a plain Python object; the ORM serializes to a JSON string on save and parses it back on load, so `model.field` is always a `dict`/`list`. Engine-aware column: `JSONB` (PostgreSQL), `JSON` (MySQL), `NVARCHAR(MAX)` (MSSQL), `TEXT` (SQLite), `BLOB SUB_TYPE TEXT` (Firebird). Use it for a JSON column inside a relational row; use DocStore for whole-document storage with Mongo-style queries.
905
907
 
906
908
  Import from `tina4_python` or `tina4_python.orm.fields`.
907
909
 
@@ -36,7 +36,7 @@ from tina4_python.database import Database # noqa: E402, F401
36
36
  from tina4_python.orm import ( # noqa: E402, F401
37
37
  ORM, bind_database, Field,
38
38
  IntegerField, StringField, BooleanField, FloatField,
39
- DateTimeField, TextField, BlobField, NumericField,
39
+ DateTimeField, TextField, BlobField, NumericField, JSONField,
40
40
  ForeignKeyField,
41
41
  IntField, StrField, BoolField, # short aliases
42
42
  has_many, has_one, belongs_to, # relationship descriptors
@@ -382,7 +382,8 @@ class Schema:
382
382
  gql_type = "Float"
383
383
  elif ftype == "BooleanField":
384
384
  gql_type = "Boolean"
385
- elif ftype in ("StringField", "TextField", "DateTimeField"):
385
+ elif ftype in ("StringField", "TextField", "DateTimeField", "JSONField"):
386
+ # JSONField serializes to a JSON string in the GraphQL payload.
386
387
  gql_type = "String"
387
388
  else:
388
389
  gql_type = "String"
@@ -192,6 +192,16 @@ def _upgrade_v2_to_v3(db, migration_folder: str | None) -> None:
192
192
  "Detected legacy v2 tina4_migration schema — performing in-place upgrade to v3"
193
193
  )
194
194
 
195
+ # A v2 table has no id generator; on Firebird, ids for rows recorded after
196
+ # the upgrade come from GEN_TINA4_MIGRATION_ID (see _record_migration), so
197
+ # make sure it exists. Fresh v3 tables get it in _create_v3_table.
198
+ if _is_firebird(db):
199
+ try:
200
+ db.execute("CREATE GENERATOR GEN_TINA4_MIGRATION_ID")
201
+ db.commit()
202
+ except Exception:
203
+ pass # Generator may already exist
204
+
195
205
  cols = _column_names(db)
196
206
 
197
207
  if "migration_id" not in cols:
@@ -331,6 +341,27 @@ def _normalize_quotes(sql: str) -> str:
331
341
  return _SMART_QUOTE_RE.sub(lambda m: _SMART_QUOTES[m.group(0)], sql)
332
342
 
333
343
 
344
+ _SET_TERM_RE = re.compile(r"^SET\s+TERM\s+(\S+)$", re.IGNORECASE)
345
+
346
+
347
+ def _parse_set_term(statement: str) -> str | None:
348
+ """Return the new terminator from a ``SET TERM <new> <current>`` directive.
349
+
350
+ ``SET TERM`` is a script-level directive (recognised by isql and other
351
+ InterBase/Firebird tooling, not run by the engine) that changes the
352
+ terminator separating statements. Recognising it lets a statement whose own
353
+ body contains the default ``;`` terminator — a trigger, stored procedure or
354
+ ``EXECUTE BLOCK`` — be kept intact rather than split on those inner ``;``.
355
+ The terminator may be more than one character (e.g. ``!!``).
356
+
357
+ :param statement: A single, already-stripped statement.
358
+ :returns: The new terminator, or ``None`` when *statement* is not a
359
+ ``SET TERM`` directive.
360
+ """
361
+ match = _SET_TERM_RE.match(statement.strip())
362
+ return match.group(1) if match else None
363
+
364
+
334
365
  def _split_statements(sql: str, delimiter: str = ";") -> list[str]:
335
366
  """Split SQL into individual statements with a single-pass, quote- and
336
367
  comment-aware scanner.
@@ -356,6 +387,10 @@ def _split_statements(sql: str, delimiter: str = ";") -> list[str]:
356
387
  a delimiter or comment, so it is preserved untouched.
357
388
  - The **delimiter** ends a statement only when reached outside all of the
358
389
  above. Empty statements are dropped; each kept statement is trimmed.
390
+ - A **SET TERM directive** (``SET TERM <new> <current>``) switches the active
391
+ terminator and is consumed, never emitted, so a statement whose own body
392
+ contains the default terminator survives as one. Multi-character
393
+ terminators (e.g. ``!!``) are supported.
359
394
 
360
395
  Smart/curly quotes are normalized to straight ASCII first. Mirrors the
361
396
  tina4-php ``Migration::splitStatements`` scanner for cross-framework parity.
@@ -367,7 +402,6 @@ def _split_statements(sql: str, delimiter: str = ";") -> list[str]:
367
402
  statements: list[str] = []
368
403
  current: list[str] = []
369
404
  n = len(sql)
370
- dlen = len(delimiter)
371
405
  i = 0
372
406
  in_dollar_block = False
373
407
  in_slash_block = False
@@ -448,20 +482,28 @@ def _split_statements(sql: str, delimiter: str = ";") -> list[str]:
448
482
  i += 1
449
483
  continue
450
484
 
451
- # Statement delimiter — only reached outside blocks/comments/strings.
485
+ # Statement delimiter — only reached outside blocks/comments/strings. A
486
+ # SET TERM directive switches the active terminator and is consumed
487
+ # (never emitted); any other completed statement is collected.
452
488
  if delimiter and sql.startswith(delimiter, i):
489
+ i += len(delimiter)
453
490
  stmt = "".join(current).strip()
454
- if stmt:
455
- statements.append(stmt)
456
491
  current = []
457
- i += dlen
492
+ if stmt:
493
+ new_term = _parse_set_term(stmt)
494
+ if new_term is not None:
495
+ delimiter = new_term
496
+ else:
497
+ statements.append(stmt)
458
498
  continue
459
499
 
460
500
  current.append(ch)
461
501
  i += 1
462
502
 
503
+ # Trailing statement (may not end with a delimiter). A trailing SET TERM
504
+ # directive is a no-op — consume it, don't emit it.
463
505
  stmt = "".join(current).strip()
464
- if stmt:
506
+ if stmt and _parse_set_term(stmt) is None:
465
507
  statements.append(stmt)
466
508
  return statements
467
509
 
@@ -19,7 +19,7 @@ Active Record ORM with SQL-first paradigm.
19
19
  from tina4_python.orm.fields import (
20
20
  Field,
21
21
  IntegerField, StringField, BooleanField, FloatField,
22
- DateTimeField, TextField, BlobField, NumericField,
22
+ DateTimeField, TextField, BlobField, NumericField, JSONField,
23
23
  ForeignKeyField,
24
24
  IntField, StrField, BoolField, # short aliases
25
25
  has_many, has_one, belongs_to, # relationship descriptors
@@ -31,7 +31,7 @@ __all__ = [
31
31
  "Field",
32
32
  # Verbose (preferred)
33
33
  "IntegerField", "StringField", "BooleanField", "FloatField",
34
- "DateTimeField", "TextField", "BlobField", "NumericField",
34
+ "DateTimeField", "TextField", "BlobField", "NumericField", "JSONField",
35
35
  "ForeignKeyField",
36
36
  # Short aliases (backwards compat)
37
37
  "IntField", "StrField", "BoolField",
@@ -9,6 +9,8 @@ Fields define column types, constraints, and defaults for ORM models.
9
9
  role = Field(str, choices=["admin", "user", "guest"])
10
10
  active = Field(bool, default=True)
11
11
  """
12
+ import copy as _copy
13
+ import json as _json
12
14
  import re as _re
13
15
  from datetime import datetime
14
16
 
@@ -165,6 +167,16 @@ class Field:
165
167
 
166
168
  return value
167
169
 
170
+ def to_db(self, value):
171
+ """Serialize an in-memory value to the form the driver should store.
172
+
173
+ Identity by default — the value already round-trips through the driver
174
+ as-is. Overridden by fields whose Python representation differs from
175
+ their column representation (e.g. ``JSONField`` stores a JSON string).
176
+ Called by ``ORM.save()`` when building the row for INSERT/UPDATE.
177
+ """
178
+ return value
179
+
168
180
  def __repr__(self):
169
181
  parts = [self.field_type.__name__]
170
182
  if self.primary_key:
@@ -212,6 +224,80 @@ def BlobField(**kwargs):
212
224
  def NumericField(**kwargs):
213
225
  return _make_field(float, "NumericField", **kwargs)
214
226
 
227
+ class JSONField(Field):
228
+ """A column that stores a JSON document (a ``dict`` or a ``list``).
229
+
230
+ You work with a plain Python ``dict``/``list``; the ORM serializes it to a
231
+ JSON string on save and parses it back on load. The column DDL is
232
+ engine-aware (see :meth:`ORM.create_table`): ``JSONB`` on PostgreSQL,
233
+ ``JSON`` on MySQL, ``NVARCHAR(MAX)`` on MSSQL, ``TEXT`` on SQLite/ODBC,
234
+ ``BLOB SUB_TYPE TEXT`` on Firebird.
235
+
236
+ Read normalization: a native-JSON driver (PostgreSQL JSONB, MySQL JSON) may
237
+ hand back an already-parsed object, while a string-backed engine (SQLite)
238
+ hands back a JSON string. :meth:`validate` normalizes both to a Python
239
+ object, so ``model.data`` is always a ``dict``/``list`` regardless of engine.
240
+
241
+ Usage::
242
+
243
+ class Event(ORM):
244
+ id = IntegerField(primary_key=True, auto_increment=True)
245
+ payload = JSONField()
246
+
247
+ Event({"payload": {"type": "click", "tags": ["a", "b"]}}).save()
248
+ e = Event.find(1)
249
+ e.payload["type"] # "click" — a real dict, not a string
250
+ """
251
+
252
+ def __init__(self, **kwargs):
253
+ super().__init__(dict, **kwargs)
254
+ self.kind = "JSONField"
255
+
256
+ def _resolve_default(self):
257
+ # Deep-copy a mutable JSON default (``default={}`` / ``default=[]``) so
258
+ # separate instances never alias the same object — the classic
259
+ # shared-default footgun. This runs on BOTH the __init__ default path
260
+ # (which calls _resolve_default directly) and the validate() None path.
261
+ d = super()._resolve_default()
262
+ return _copy.deepcopy(d) if isinstance(d, (dict, list)) else d
263
+
264
+ def validate(self, value):
265
+ # None -> required check + default (mirrors base Field).
266
+ if value is None:
267
+ if self.required and self.default is None:
268
+ raise ValueError(f"Field '{self.name}' is required")
269
+ return self._resolve_default()
270
+
271
+ # A driver returns a JSON string (SQLite/TEXT) or an already-parsed
272
+ # dict/list (PostgreSQL JSONB, MySQL JSON). Normalize to a Python object.
273
+ if isinstance(value, (bytes, bytearray)):
274
+ value = value.decode("utf-8")
275
+ if isinstance(value, str):
276
+ try:
277
+ value = _json.loads(value)
278
+ except (ValueError, TypeError) as e:
279
+ raise ValueError(f"Field '{self.name}': value is not valid JSON") from e
280
+ if not isinstance(value, (dict, list)):
281
+ raise ValueError(
282
+ f"Field '{self.name}': expected a dict or list, got {type(value).__name__}"
283
+ )
284
+ if self.validator is not None:
285
+ self.validator(value)
286
+ return value
287
+
288
+ def to_db(self, value):
289
+ # Serialize to a JSON string for the driver. A TEXT column stores it
290
+ # verbatim; a native JSON column (JSONB/JSON) casts the string literal.
291
+ if value is None:
292
+ return None
293
+ if isinstance(value, str):
294
+ return value # already serialized (defensive)
295
+ try:
296
+ return _json.dumps(value)
297
+ except (TypeError, ValueError) as e:
298
+ raise ValueError(f"Field '{self.name}': value is not JSON-serializable") from e
299
+
300
+
215
301
  class ForeignKeyField(Field):
216
302
  """Integer field that references another model's primary key.
217
303
 
@@ -390,7 +390,9 @@ class ORM(metaclass=ORMMeta):
390
390
  if value is not None or not field.auto_increment:
391
391
  # Use field_mapping for the column name, fall back to field.column
392
392
  db_col = self.field_mapping.get(name, field.column)
393
- data[db_col] = value
393
+ # Serialize to the column's storage form: identity for most
394
+ # fields, JSON string for JSONField (see Field.to_db).
395
+ data[db_col] = field.to_db(value)
394
396
 
395
397
  # v3.13.11 (issue #50): pick INSERT vs UPDATE on row existence
396
398
  # for non-auto-increment PKs. Auto-increment keeps the legacy
@@ -840,6 +842,23 @@ class ORM(metaclass=ORMMeta):
840
842
  else:
841
843
  datetime_sql = "DATETIME"
842
844
 
845
+ # JSONField -> the engine's native JSON type where it has one, else a
846
+ # text column. PostgreSQL JSONB (binary, indexable, canonical form);
847
+ # MySQL JSON; MSSQL has no JSON type so NVARCHAR(MAX) (its documented
848
+ # JSON storage); SQLite/ODBC TEXT; Firebird has no TEXT/JSON type so
849
+ # BLOB SUB_TYPE TEXT. The ORM stores a JSON string in every case, so a
850
+ # text-backed column round-trips identically to a native one.
851
+ if engine in ("postgres", "postgresql"):
852
+ json_sql = "JSONB"
853
+ elif engine == "mysql":
854
+ json_sql = "JSON"
855
+ elif engine == "mssql":
856
+ json_sql = "NVARCHAR(MAX)"
857
+ elif engine == "firebird":
858
+ json_sql = "BLOB SUB_TYPE TEXT"
859
+ else:
860
+ json_sql = "TEXT"
861
+
843
862
  # Don't recreate if table already exists
844
863
  if db.table_exists(table):
845
864
  return True
@@ -866,6 +885,8 @@ class ORM(metaclass=ORMMeta):
866
885
  sql_type = datetime_sql
867
886
  elif kind == "BlobField":
868
887
  sql_type = "BLOB"
888
+ elif kind == "JSONField":
889
+ sql_type = json_sql
869
890
  else:
870
891
  # Fallback based on field_type
871
892
  ft = field_obj.field_type
@@ -886,7 +907,7 @@ class ORM(metaclass=ORMMeta):
886
907
  parts.append("AUTOINCREMENT")
887
908
  if field_obj.required and not field_obj.primary_key:
888
909
  parts.append("NOT NULL")
889
- if field_obj.default is not None and not field_obj.auto_increment:
910
+ if field_obj.default is not None and not field_obj.auto_increment and kind != "JSONField":
890
911
  default_val = field_obj.default
891
912
  if isinstance(default_val, str):
892
913
  parts.append(f"DEFAULT '{default_val}'")
File without changes