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.
- {tina4_python-3.13.103 → tina4_python-3.13.104}/PKG-INFO +1 -1
- {tina4_python-3.13.103 → tina4_python-3.13.104}/pyproject.toml +1 -1
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/__init__.py +8 -1
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/server.py +13 -1
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/__init__.py +6 -2
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/adapter.py +3 -2
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/sql_translator.py +122 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/orm/__init__.py +6 -3
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/orm/fields.py +125 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/orm/model.py +195 -2
- tina4_python-3.13.104/tina4_python/orm/point.py +421 -0
- tina4_python-3.13.104/tina4_python/query_builder/__init__.py +757 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session/__init__.py +4 -1
- tina4_python-3.13.104/tina4_python/sso.py +319 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/swagger/__init__.py +13 -1
- tina4_python-3.13.103/tina4_python/query_builder/__init__.py +0 -445
- {tina4_python-3.13.103 → tina4_python-3.13.104}/.gitignore +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/README.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/ai/client.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/env.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.104}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -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.
|
|
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
|
|
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__ = [
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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}
|