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