tina4-python 3.13.11__tar.gz → 3.13.14__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.11 → tina4_python-3.13.14}/PKG-INFO +1 -1
- {tina4_python-3.13.11 → tina4_python-3.13.14}/pyproject.toml +1 -1
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/__init__.py +1 -1
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/middleware.py +9 -4
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/server.py +37 -1
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/adapter.py +47 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/connection.py +6 -2
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/firebird.py +13 -4
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/mssql.py +31 -12
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/mysql.py +20 -5
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/postgres.py +44 -12
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/sqlite.py +25 -5
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/debug/__init__.py +12 -4
- {tina4_python-3.13.11 → tina4_python-3.13.14}/.gitignore +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/README.md +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/cli/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/env.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4-dev-admin.js +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_alerts.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_badges.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_buttons.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_cards.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_forms.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_grid.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_modals.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_nav.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_reset.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_tables.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_typography.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_utilities.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/_variables.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/base.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/colors.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/scss/tina4css/tina4.scss +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -361,9 +361,15 @@ class RequestLoggerMiddleware:
|
|
|
361
361
|
"""Request logger — stamps start time before the handler and logs elapsed time after.
|
|
362
362
|
|
|
363
363
|
Mirrors the PHP, Ruby and Node.js RequestLogger classes.
|
|
364
|
+
|
|
365
|
+
v3.13.14: routes through the Tina4 ``Log`` class (was stdlib
|
|
366
|
+
``logging.getLogger``, whose ``info()`` is silently dropped by an
|
|
367
|
+
unconfigured root logger — so these lines never reached stdout).
|
|
368
|
+
The dev server also logs every request globally (see
|
|
369
|
+
``server._finalize_response``); this middleware remains for callers
|
|
370
|
+
that want per-route request logging.
|
|
364
371
|
"""
|
|
365
372
|
|
|
366
|
-
_logger = logging.getLogger("tina4.request")
|
|
367
373
|
_start_times: dict = {}
|
|
368
374
|
_lock = threading.Lock()
|
|
369
375
|
|
|
@@ -385,7 +391,6 @@ class RequestLoggerMiddleware:
|
|
|
385
391
|
method = getattr(request, "method", "?")
|
|
386
392
|
path = getattr(request, "url", None) or getattr(request, "path", "/")
|
|
387
393
|
status = getattr(response, "status_code", None) or getattr(response, "status", 0)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
)
|
|
394
|
+
from tina4_python.debug import Log
|
|
395
|
+
Log.info(f"{method} {path} -> {status} ({elapsed_ms}ms)")
|
|
391
396
|
return request, response
|
|
@@ -1404,6 +1404,21 @@ def _handle_no_route(request: Request, response: Response, request_id: str) -> R
|
|
|
1404
1404
|
return response
|
|
1405
1405
|
|
|
1406
1406
|
|
|
1407
|
+
def _request_logging_enabled(is_dev: bool) -> bool:
|
|
1408
|
+
"""Whether to emit a per-request log line (v3.13.14).
|
|
1409
|
+
|
|
1410
|
+
``TINA4_LOG_REQUESTS`` is the explicit control (true/false). When unset,
|
|
1411
|
+
request logging follows dev mode: on under ``TINA4_DEBUG``, off in
|
|
1412
|
+
production (so prod doesn't pay the per-request logging cost unless the
|
|
1413
|
+
operator opts in). Same contract across all four frameworks.
|
|
1414
|
+
"""
|
|
1415
|
+
from tina4_python.dotenv import is_truthy
|
|
1416
|
+
val = os.environ.get("TINA4_LOG_REQUESTS")
|
|
1417
|
+
if val is not None and val != "":
|
|
1418
|
+
return is_truthy(val)
|
|
1419
|
+
return is_dev
|
|
1420
|
+
|
|
1421
|
+
|
|
1407
1422
|
def _finalize_response(
|
|
1408
1423
|
request: Request, response: Response, route: dict | None,
|
|
1409
1424
|
request_id: str, is_dev: bool, req_start: float,
|
|
@@ -1444,6 +1459,22 @@ def _finalize_response(
|
|
|
1444
1459
|
except Exception:
|
|
1445
1460
|
pass
|
|
1446
1461
|
|
|
1462
|
+
# Request log line (v3.13.14). The dev dashboard's RequestInspector above
|
|
1463
|
+
# only feeds the /__dev UI — it never reached stdout, so `tina4 serve`
|
|
1464
|
+
# printed the startup banner then went silent even as requests flowed.
|
|
1465
|
+
# Emit a per-request line through the Tina4 Log so it lands on stdout
|
|
1466
|
+
# (docker logs / k8s) like every other log. On by default in dev; opt-in
|
|
1467
|
+
# in production via TINA4_LOG_REQUESTS to avoid per-request overhead.
|
|
1468
|
+
if _request_logging_enabled(is_dev):
|
|
1469
|
+
try:
|
|
1470
|
+
import time as _time
|
|
1471
|
+
elapsed_ms = round((_time.perf_counter() - req_start) * 1000, 3)
|
|
1472
|
+
Log.info(
|
|
1473
|
+
f"{request.method} {request.path} -> {response.status_code} ({elapsed_ms}ms)"
|
|
1474
|
+
)
|
|
1475
|
+
except Exception:
|
|
1476
|
+
pass
|
|
1477
|
+
|
|
1447
1478
|
# Session save + cookie.
|
|
1448
1479
|
# Skip saving if this was a brand-new session that the route never wrote to —
|
|
1449
1480
|
# that prevents empty orphaned session files accumulating on disk.
|
|
@@ -2081,7 +2112,12 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
|
|
|
2081
2112
|
|
|
2082
2113
|
# Init logger
|
|
2083
2114
|
is_production = os.environ.get("TINA4_ENV", "development") == "production"
|
|
2084
|
-
|
|
2115
|
+
# v3.13.14: default level is INFO (was ERROR). ERROR-by-default meant a
|
|
2116
|
+
# deployed app that logged at info/debug appeared silent — operators
|
|
2117
|
+
# "weren't getting logs". INFO shows request/startup/warn/error without
|
|
2118
|
+
# debug noise, and matches PHP/Ruby/Node defaults. Override per-deploy
|
|
2119
|
+
# with TINA4_LOG_LEVEL.
|
|
2120
|
+
log_level = os.environ.get("TINA4_LOG_LEVEL", "info")
|
|
2085
2121
|
Log.configure(level=log_level, production=is_production)
|
|
2086
2122
|
|
|
2087
2123
|
# Install a top-level exception hook so uncaught exceptions bubbling
|
|
@@ -349,6 +349,53 @@ class DatabaseAdapter:
|
|
|
349
349
|
last_id=last_id,
|
|
350
350
|
)
|
|
351
351
|
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _strip_trailing_semicolons(sql: str) -> str:
|
|
354
|
+
"""v3.13.12: normalize user-supplied SQL by stripping trailing
|
|
355
|
+
semicolons and whitespace.
|
|
356
|
+
|
|
357
|
+
``fetch()`` and ``fetch_one()`` wrap the user's SQL — fetch()
|
|
358
|
+
builds a ``SELECT COUNT(*) FROM ({sql}) AS _count_subquery``
|
|
359
|
+
for the pagination probe and appends ``LIMIT/OFFSET`` to the
|
|
360
|
+
real query. A trailing ``;`` in the user's SQL breaks both:
|
|
361
|
+
|
|
362
|
+
user input: "SELECT * FROM users;"
|
|
363
|
+
wrapped: "SELECT COUNT(*) FROM (SELECT * FROM users;) ..." ← syntax error
|
|
364
|
+
paginated: "SELECT * FROM users; LIMIT 100 OFFSET 0" ← invalid
|
|
365
|
+
|
|
366
|
+
Stripping trailing semicolons before any wrapping is the
|
|
367
|
+
single-line defence. Internal semicolons (between meaningful
|
|
368
|
+
SQL statements) are left alone — the driver will reject those
|
|
369
|
+
if multi-statement isn't supported on the engine.
|
|
370
|
+
"""
|
|
371
|
+
if not sql:
|
|
372
|
+
return sql
|
|
373
|
+
stripped = sql.rstrip()
|
|
374
|
+
while stripped.endswith(";"):
|
|
375
|
+
stripped = stripped[:-1].rstrip()
|
|
376
|
+
return stripped
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def _split_schema(name: str) -> tuple[str | None, str]:
|
|
380
|
+
"""Split a possibly-qualified table name into (schema, table).
|
|
381
|
+
|
|
382
|
+
v3.13.14 (#48): a model whose ``table_name`` is qualified —
|
|
383
|
+
PostgreSQL ``gift_cards.gift_card``, MSSQL ``dbo.widget``,
|
|
384
|
+
MySQL ``otherdb.table``, SQLite ``attached.table`` — lives in
|
|
385
|
+
that schema/catalog, not the default. Each adapter's
|
|
386
|
+
``table_exists`` / ``get_columns`` use this to query the right
|
|
387
|
+
namespace instead of matching the whole dotted string as one
|
|
388
|
+
flat table name. Returns ``(None, name)`` for a bare name so
|
|
389
|
+
callers default to the engine's current namespace. Splits on the
|
|
390
|
+
first dot; quoted identifiers with embedded dots aren't supported
|
|
391
|
+
(they weren't before either). Firebird has no schemas, so its
|
|
392
|
+
adapter ignores this.
|
|
393
|
+
"""
|
|
394
|
+
if "." in name:
|
|
395
|
+
schema, _, table = name.partition(".")
|
|
396
|
+
return schema, table
|
|
397
|
+
return None, name
|
|
398
|
+
|
|
352
399
|
def fetch(self, sql: str, params: list = None,
|
|
353
400
|
limit: int = 100, offset: int = 0) -> DatabaseResult:
|
|
354
401
|
"""Execute a read query and return multiple rows."""
|
|
@@ -457,8 +457,8 @@ class Database:
|
|
|
457
457
|
raise
|
|
458
458
|
|
|
459
459
|
def fetch_all(self, sql: str, params: list = None,
|
|
460
|
-
limit: int =
|
|
461
|
-
"""Fetch rows and return the records list directly.
|
|
460
|
+
limit: int = 0, offset: int = 0) -> list[dict]:
|
|
461
|
+
"""Fetch ALL rows and return the records list directly.
|
|
462
462
|
|
|
463
463
|
Symmetric with ``fetch_one``. For the common case where you just
|
|
464
464
|
want the rows and don't need the ``DatabaseResult`` metadata
|
|
@@ -469,6 +469,10 @@ class Database:
|
|
|
469
469
|
for row in rows:
|
|
470
470
|
print(row["name"])
|
|
471
471
|
|
|
472
|
+
v3.13.12: default ``limit`` is **0** (no truncation) — the method
|
|
473
|
+
name says ``fetch_all``, so it returns all matching rows. Pre-v3.13.12
|
|
474
|
+
silently truncated to 100. Pass an explicit ``limit=N`` to cap.
|
|
475
|
+
|
|
472
476
|
Returns ``[]`` (not ``None``) when no rows match.
|
|
473
477
|
"""
|
|
474
478
|
return self.fetch(sql, params, limit, offset).records
|
|
@@ -283,6 +283,9 @@ class FirebirdAdapter(DatabaseAdapter):
|
|
|
283
283
|
|
|
284
284
|
def fetch(self, sql: str, params: list = None,
|
|
285
285
|
limit: int = 100, offset: int = 0) -> DatabaseResult:
|
|
286
|
+
# v3.13.12: strip trailing `;` — Firebird wraps with COUNT(*)
|
|
287
|
+
# and appends ROWS pagination; a trailing semicolon breaks both.
|
|
288
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
286
289
|
sql = self._translate_sql(sql)
|
|
287
290
|
cursor = self._conn.cursor()
|
|
288
291
|
|
|
@@ -297,10 +300,15 @@ class FirebirdAdapter(DatabaseAdapter):
|
|
|
297
300
|
# paginated query below regardless of whether count succeeded.
|
|
298
301
|
cursor = self._conn.cursor()
|
|
299
302
|
|
|
300
|
-
# Apply Firebird pagination — ROWS start TO end
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
303
|
+
# Apply Firebird pagination — ROWS start TO end.
|
|
304
|
+
# v3.13.12: limit <= 0 means "no pagination" (fetch_all's
|
|
305
|
+
# default — give me ALL rows).
|
|
306
|
+
if limit is None or limit <= 0:
|
|
307
|
+
paginated_sql = sql
|
|
308
|
+
else:
|
|
309
|
+
start = offset + 1
|
|
310
|
+
end = offset + limit
|
|
311
|
+
paginated_sql = f"{sql} ROWS {start} TO {end}"
|
|
304
312
|
cursor = self._safe_cursor_execute(cursor, paginated_sql, params)
|
|
305
313
|
|
|
306
314
|
desc = cursor.description
|
|
@@ -310,6 +318,7 @@ class FirebirdAdapter(DatabaseAdapter):
|
|
|
310
318
|
return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
|
|
311
319
|
|
|
312
320
|
def fetch_one(self, sql: str, params: list = None) -> dict | None:
|
|
321
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
313
322
|
sql = self._translate_sql(sql)
|
|
314
323
|
cursor = self._conn.cursor()
|
|
315
324
|
cursor = self._safe_cursor_execute(cursor, sql, params)
|
|
@@ -105,6 +105,8 @@ class MSSQLAdapter(DatabaseAdapter):
|
|
|
105
105
|
|
|
106
106
|
def fetch(self, sql: str, params: list = None,
|
|
107
107
|
limit: int = 100, offset: int = 0) -> DatabaseResult:
|
|
108
|
+
# v3.13.12: strip trailing `;` — see DatabaseAdapter helper.
|
|
109
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
108
110
|
sql = self._translate_sql(sql)
|
|
109
111
|
cursor = self._conn.cursor(as_dict=True)
|
|
110
112
|
|
|
@@ -116,20 +118,26 @@ class MSSQLAdapter(DatabaseAdapter):
|
|
|
116
118
|
except Exception:
|
|
117
119
|
total = 0
|
|
118
120
|
|
|
119
|
-
# Apply pagination — MSSQL uses OFFSET/FETCH
|
|
120
|
-
#
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
# Apply pagination — MSSQL uses OFFSET/FETCH.
|
|
122
|
+
# v3.13.12: limit <= 0 means "no pagination" (fetch_all's
|
|
123
|
+
# default — give me ALL rows).
|
|
124
|
+
if limit is None or limit <= 0:
|
|
125
|
+
paginated_sql = sql
|
|
126
|
+
paginated_params = tuple(params or [])
|
|
123
127
|
else:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
128
|
+
# OFFSET/FETCH requires an ORDER BY; if none exists, add a default
|
|
129
|
+
if not re.search(r"\bORDER\s+BY\b", sql, re.IGNORECASE):
|
|
130
|
+
paginated_sql = f"{sql} ORDER BY (SELECT NULL) OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
|
|
131
|
+
else:
|
|
132
|
+
paginated_sql = f"{sql} OFFSET %s ROWS FETCH NEXT %s ROWS ONLY"
|
|
133
|
+
paginated_params = tuple(params or []) + (offset, limit)
|
|
127
134
|
cursor.execute(paginated_sql, paginated_params)
|
|
128
135
|
rows = [dict(row) for row in cursor.fetchall()]
|
|
129
136
|
|
|
130
137
|
return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
|
|
131
138
|
|
|
132
139
|
def fetch_one(self, sql: str, params: list = None) -> dict | None:
|
|
140
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
133
141
|
sql = self._translate_sql(sql)
|
|
134
142
|
cursor = self._conn.cursor(as_dict=True)
|
|
135
143
|
cursor.execute(sql, tuple(params) if params else ())
|
|
@@ -182,10 +190,17 @@ class MSSQLAdapter(DatabaseAdapter):
|
|
|
182
190
|
self._in_transaction = False
|
|
183
191
|
|
|
184
192
|
def table_exists(self, name: str) -> bool:
|
|
193
|
+
# v3.13.14 (#48): honour a schema-qualified name ("dbo.widget",
|
|
194
|
+
# "sales.orders"). Schemas are everyday in SQL Server; pre-fix this
|
|
195
|
+
# had no schema filter and matched the whole dotted string as a flat
|
|
196
|
+
# TABLE_NAME, so any qualified table_name was invisible. A bare name
|
|
197
|
+
# still matches in any schema (unchanged behaviour).
|
|
198
|
+
schema, tbl = self._split_schema(name)
|
|
185
199
|
row = self.fetch_one(
|
|
186
200
|
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
|
|
187
|
-
"WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = %s"
|
|
188
|
-
|
|
201
|
+
"WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_NAME = %s "
|
|
202
|
+
"AND (%s IS NULL OR TABLE_SCHEMA = %s)",
|
|
203
|
+
[tbl, schema, schema],
|
|
189
204
|
)
|
|
190
205
|
return row is not None
|
|
191
206
|
|
|
@@ -198,6 +213,9 @@ class MSSQLAdapter(DatabaseAdapter):
|
|
|
198
213
|
return [r["TABLE_NAME"] for r in result.records]
|
|
199
214
|
|
|
200
215
|
def get_columns(self, table: str) -> list[dict]:
|
|
216
|
+
# v3.13.14 (#48): honour a schema-qualified name; a bare name matches
|
|
217
|
+
# in any schema (unchanged).
|
|
218
|
+
schema, tbl = self._split_schema(table)
|
|
201
219
|
sql = """
|
|
202
220
|
SELECT c.COLUMN_NAME, c.DATA_TYPE, c.IS_NULLABLE, c.COLUMN_DEFAULT,
|
|
203
221
|
CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS is_primary
|
|
@@ -207,12 +225,13 @@ class MSSQLAdapter(DatabaseAdapter):
|
|
|
207
225
|
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
|
|
208
226
|
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku
|
|
209
227
|
ON tc.CONSTRAINT_NAME = ku.CONSTRAINT_NAME
|
|
210
|
-
WHERE tc.TABLE_NAME = %s AND tc.
|
|
228
|
+
WHERE tc.TABLE_NAME = %s AND (%s IS NULL OR tc.TABLE_SCHEMA = %s)
|
|
229
|
+
AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
|
|
211
230
|
) pk ON c.COLUMN_NAME = pk.COLUMN_NAME
|
|
212
|
-
WHERE c.TABLE_NAME = %s
|
|
231
|
+
WHERE c.TABLE_NAME = %s AND (%s IS NULL OR c.TABLE_SCHEMA = %s)
|
|
213
232
|
ORDER BY c.ORDINAL_POSITION
|
|
214
233
|
"""
|
|
215
|
-
result = self.fetch(sql, [
|
|
234
|
+
result = self.fetch(sql, [tbl, schema, schema, tbl, schema, schema], limit=10000)
|
|
216
235
|
return [
|
|
217
236
|
{
|
|
218
237
|
"name": r["COLUMN_NAME"],
|
|
@@ -94,6 +94,9 @@ class MySQLAdapter(DatabaseAdapter):
|
|
|
94
94
|
|
|
95
95
|
def fetch(self, sql: str, params: list = None,
|
|
96
96
|
limit: int = 100, offset: int = 0) -> DatabaseResult:
|
|
97
|
+
# v3.13.12: strip trailing `;` before wrapping with COUNT(*)
|
|
98
|
+
# and appending LIMIT/OFFSET — see DatabaseAdapter helper.
|
|
99
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
97
100
|
sql = self._translate_sql(sql)
|
|
98
101
|
cursor = self._conn.cursor(dictionary=True)
|
|
99
102
|
|
|
@@ -105,15 +108,21 @@ class MySQLAdapter(DatabaseAdapter):
|
|
|
105
108
|
except Exception:
|
|
106
109
|
total = 0
|
|
107
110
|
|
|
108
|
-
# Apply pagination
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
# Apply pagination — v3.13.12: limit <= 0 means "no pagination"
|
|
112
|
+
# (fetch_all's default — give me ALL rows).
|
|
113
|
+
if limit is None or limit <= 0:
|
|
114
|
+
paginated_sql = sql
|
|
115
|
+
paginated_params = params or []
|
|
116
|
+
else:
|
|
117
|
+
paginated_sql = f"{sql} LIMIT %s OFFSET %s"
|
|
118
|
+
paginated_params = (params or []) + [limit, offset]
|
|
111
119
|
cursor.execute(paginated_sql, paginated_params)
|
|
112
120
|
rows = [dict(row) for row in cursor.fetchall()]
|
|
113
121
|
|
|
114
122
|
return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
|
|
115
123
|
|
|
116
124
|
def fetch_one(self, sql: str, params: list = None) -> dict | None:
|
|
125
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
117
126
|
sql = self._translate_sql(sql)
|
|
118
127
|
cursor = self._conn.cursor(dictionary=True)
|
|
119
128
|
cursor.execute(sql, params or [])
|
|
@@ -165,10 +174,16 @@ class MySQLAdapter(DatabaseAdapter):
|
|
|
165
174
|
self._in_transaction = False
|
|
166
175
|
|
|
167
176
|
def table_exists(self, name: str) -> bool:
|
|
177
|
+
# v3.13.14 (#48): honour a database-qualified name ("otherdb.table").
|
|
178
|
+
# In MySQL "schema" == database; default to the connected database.
|
|
179
|
+
# Pre-fix this matched the whole dotted string as a flat TABLE_NAME
|
|
180
|
+
# under DATABASE() only, so a cross-database qualified name was never
|
|
181
|
+
# found and create_table()/migrations misfired.
|
|
182
|
+
schema, tbl = self._split_schema(name)
|
|
168
183
|
row = self.fetch_one(
|
|
169
184
|
"SELECT TABLE_NAME FROM information_schema.TABLES "
|
|
170
|
-
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
|
|
171
|
-
[
|
|
185
|
+
"WHERE TABLE_SCHEMA = COALESCE(%s, DATABASE()) AND TABLE_NAME = %s",
|
|
186
|
+
[schema, tbl],
|
|
172
187
|
)
|
|
173
188
|
return row is not None
|
|
174
189
|
|
|
@@ -306,6 +306,10 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
306
306
|
limit: int = 100, offset: int = 0) -> DatabaseResult:
|
|
307
307
|
import psycopg2.extras
|
|
308
308
|
|
|
309
|
+
# v3.13.12: strip trailing `;` before the framework wraps with
|
|
310
|
+
# COUNT(*) and appends LIMIT/OFFSET — otherwise a user-supplied
|
|
311
|
+
# `"SELECT * FROM users;"` produces invalid wrapped SQL.
|
|
312
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
309
313
|
sql = self._translate_sql(sql)
|
|
310
314
|
|
|
311
315
|
# v3.13.8: heal first so the COUNT probe below doesn't open on a
|
|
@@ -342,8 +346,14 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
342
346
|
# Apply pagination — the real query, so use the error-handling
|
|
343
347
|
# wrapper. The count probe above is best-effort and stays on
|
|
344
348
|
# _safe_execute (a failed probe shouldn't taint last_error).
|
|
345
|
-
|
|
346
|
-
|
|
349
|
+
# v3.13.12: limit <= 0 means "no pagination" (fetch_all's
|
|
350
|
+
# default — give me ALL rows, not a silent first-100 slice).
|
|
351
|
+
if limit is None or limit <= 0:
|
|
352
|
+
paginated_sql = sql
|
|
353
|
+
paginated_params = params or []
|
|
354
|
+
else:
|
|
355
|
+
paginated_sql = f"{sql} LIMIT %s OFFSET %s"
|
|
356
|
+
paginated_params = (params or []) + [limit, offset]
|
|
347
357
|
self._exec_with_handling(cursor, paginated_sql, paginated_params)
|
|
348
358
|
rows = [self._decode_blobs(dict(row)) for row in cursor.fetchall()]
|
|
349
359
|
|
|
@@ -352,6 +362,8 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
352
362
|
def fetch_one(self, sql: str, params: list = None) -> dict | None:
|
|
353
363
|
import psycopg2.extras
|
|
354
364
|
|
|
365
|
+
# v3.13.12: see fetch() — trailing semicolons break the wrappers.
|
|
366
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
355
367
|
sql = self._translate_sql(sql)
|
|
356
368
|
cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
|
357
369
|
self._exec_with_handling(cursor, sql, params)
|
|
@@ -411,20 +423,40 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
411
423
|
self._in_transaction = False
|
|
412
424
|
|
|
413
425
|
def table_exists(self, name: str) -> bool:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
426
|
+
# v3.13.x (#48): support schema-qualified names ("schema.table") and the
|
|
427
|
+
# connection search_path. to_regclass() resolves a (possibly qualified)
|
|
428
|
+
# relation name to its OID using the SAME rules Postgres applies to a
|
|
429
|
+
# FROM clause, returning NULL when absent. Pre-fix this hardcoded
|
|
430
|
+
# schemaname='public' AND matched the whole dotted string as a flat
|
|
431
|
+
# tablename, so a table in a non-public schema was invisible —
|
|
432
|
+
# table_exists() always returned False, so create_table()/migrations
|
|
433
|
+
# misfired and ORM introspection saw nothing.
|
|
434
|
+
row = self.fetch_one("SELECT to_regclass(%s) AS oid", [name])
|
|
435
|
+
return bool(row and row.get("oid") is not None)
|
|
419
436
|
|
|
420
437
|
def get_tables(self) -> list[str]:
|
|
438
|
+
# v3.13.x (#48): list tables from every user schema, not just public.
|
|
439
|
+
# public tables stay bare ("users"); tables in other schemas are
|
|
440
|
+
# returned schema-qualified ("gift_cards.gift_card") so the names round
|
|
441
|
+
# -trip back through table_exists()/get_columns().
|
|
421
442
|
result = self.fetch(
|
|
422
|
-
"
|
|
443
|
+
"""
|
|
444
|
+
SELECT schemaname, tablename FROM pg_tables
|
|
445
|
+
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
446
|
+
ORDER BY schemaname, tablename
|
|
447
|
+
""",
|
|
423
448
|
limit=10000,
|
|
424
449
|
)
|
|
425
|
-
return [
|
|
450
|
+
return [
|
|
451
|
+
r["tablename"] if r["schemaname"] == "public"
|
|
452
|
+
else f"{r['schemaname']}.{r['tablename']}"
|
|
453
|
+
for r in result.records
|
|
454
|
+
]
|
|
426
455
|
|
|
427
456
|
def get_columns(self, table: str) -> list[dict]:
|
|
457
|
+
# v3.13.x (#48): honour a schema-qualified name; default to public.
|
|
458
|
+
schema, tbl = self._split_schema(table)
|
|
459
|
+
schema = schema or "public"
|
|
428
460
|
sql = """
|
|
429
461
|
SELECT c.column_name, c.data_type, c.is_nullable, c.column_default,
|
|
430
462
|
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_primary
|
|
@@ -434,12 +466,12 @@ class PostgreSQLAdapter(DatabaseAdapter):
|
|
|
434
466
|
FROM information_schema.table_constraints tc
|
|
435
467
|
JOIN information_schema.key_column_usage ku
|
|
436
468
|
ON tc.constraint_name = ku.constraint_name
|
|
437
|
-
WHERE tc.table_name = %s AND tc.constraint_type = 'PRIMARY KEY'
|
|
469
|
+
WHERE tc.table_name = %s AND tc.table_schema = %s AND tc.constraint_type = 'PRIMARY KEY'
|
|
438
470
|
) pk ON c.column_name = pk.column_name
|
|
439
|
-
WHERE c.table_name = %s AND c.table_schema =
|
|
471
|
+
WHERE c.table_name = %s AND c.table_schema = %s
|
|
440
472
|
ORDER BY c.ordinal_position
|
|
441
473
|
"""
|
|
442
|
-
result = self.fetch(sql, [
|
|
474
|
+
result = self.fetch(sql, [tbl, schema, tbl, schema], limit=10000)
|
|
443
475
|
return [
|
|
444
476
|
{
|
|
445
477
|
"name": r["column_name"],
|
|
@@ -108,6 +108,8 @@ class SQLiteAdapter(DatabaseAdapter):
|
|
|
108
108
|
|
|
109
109
|
def fetch(self, sql: str, params: list = None,
|
|
110
110
|
limit: int = 100, offset: int = 0) -> DatabaseResult:
|
|
111
|
+
# v3.13.12: strip trailing `;` before wrapping — see DatabaseAdapter.
|
|
112
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
111
113
|
# Count total rows (without LIMIT/OFFSET)
|
|
112
114
|
count_sql = f"SELECT COUNT(*) as cnt FROM ({sql})"
|
|
113
115
|
try:
|
|
@@ -115,8 +117,12 @@ class SQLiteAdapter(DatabaseAdapter):
|
|
|
115
117
|
except Exception:
|
|
116
118
|
total = 0
|
|
117
119
|
|
|
118
|
-
# Apply pagination — skip if SQL already has LIMIT
|
|
119
|
-
|
|
120
|
+
# Apply pagination — skip if SQL already has LIMIT, or if
|
|
121
|
+
# limit <= 0 (v3.13.12: fetch_all's "give me all rows" path).
|
|
122
|
+
if limit is None or limit <= 0:
|
|
123
|
+
paginated_sql = sql
|
|
124
|
+
paginated_params = params or []
|
|
125
|
+
elif "LIMIT" in sql.upper().split("--")[0]:
|
|
120
126
|
paginated_sql = sql
|
|
121
127
|
paginated_params = params or []
|
|
122
128
|
else:
|
|
@@ -128,6 +134,7 @@ class SQLiteAdapter(DatabaseAdapter):
|
|
|
128
134
|
return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
|
|
129
135
|
|
|
130
136
|
def fetch_one(self, sql: str, params: list = None) -> dict | None:
|
|
137
|
+
sql = self._strip_trailing_semicolons(sql)
|
|
131
138
|
cursor = self._conn.execute(sql, params or [])
|
|
132
139
|
row = cursor.fetchone()
|
|
133
140
|
return dict(row) if row else None
|
|
@@ -172,9 +179,16 @@ class SQLiteAdapter(DatabaseAdapter):
|
|
|
172
179
|
self._in_transaction = False
|
|
173
180
|
|
|
174
181
|
def table_exists(self, name: str) -> bool:
|
|
182
|
+
# v3.13.14 (#48): honour an attached-database prefix ("attached.table").
|
|
183
|
+
# SQLite's "schema" is an ATTACH alias; each has its own sqlite_master.
|
|
184
|
+
# Bare names use the main database (unchanged). The alias must be a
|
|
185
|
+
# plain identifier (it's developer-supplied, but guard against the dot
|
|
186
|
+
# being part of an odd name).
|
|
187
|
+
schema, tbl = self._split_schema(name)
|
|
188
|
+
master = f"{schema}.sqlite_master" if schema and schema.isidentifier() else "sqlite_master"
|
|
175
189
|
row = self.fetch_one(
|
|
176
|
-
"SELECT name FROM
|
|
177
|
-
[name]
|
|
190
|
+
f"SELECT name FROM {master} WHERE type='table' AND name=?",
|
|
191
|
+
[tbl if (schema and schema.isidentifier()) else name]
|
|
178
192
|
)
|
|
179
193
|
return row is not None
|
|
180
194
|
|
|
@@ -186,7 +200,13 @@ class SQLiteAdapter(DatabaseAdapter):
|
|
|
186
200
|
return [r["name"] for r in result.records]
|
|
187
201
|
|
|
188
202
|
def get_columns(self, table: str) -> list[dict]:
|
|
189
|
-
|
|
203
|
+
# v3.13.14 (#48): honour an attached-database prefix via
|
|
204
|
+
# `PRAGMA <schema>.table_info(<table>)`.
|
|
205
|
+
schema, tbl = self._split_schema(table)
|
|
206
|
+
if schema and schema.isidentifier() and tbl.isidentifier():
|
|
207
|
+
cursor = self._conn.execute(f"PRAGMA {schema}.table_info({tbl})")
|
|
208
|
+
else:
|
|
209
|
+
cursor = self._conn.execute(f"PRAGMA table_info({table})")
|
|
190
210
|
return [
|
|
191
211
|
{
|
|
192
212
|
"name": row["name"],
|
|
@@ -412,10 +412,18 @@ class Log:
|
|
|
412
412
|
# File always gets ALL levels (no filtering for file output)
|
|
413
413
|
line = cls._format_line(level, message, **kwargs)
|
|
414
414
|
|
|
415
|
-
# Console output respects TINA4_LOG_LEVEL and the stdout toggle
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
415
|
+
# Console output respects TINA4_LOG_LEVEL and the stdout toggle.
|
|
416
|
+
# v3.13.14: stdout is NOT suppressed in production — containers
|
|
417
|
+
# treat stdout as the canonical log sink (docker logs / k8s read
|
|
418
|
+
# PID 1 stdout), and the pre-v3.13.14 `not _is_production` gate
|
|
419
|
+
# meant deployed containers got nothing. Production still emits
|
|
420
|
+
# JSON (see _format_line / _is_production), just on stdout now.
|
|
421
|
+
# flush=True so logs appear immediately on a non-TTY pipe rather
|
|
422
|
+
# than sitting in Python's block buffer until the process exits.
|
|
423
|
+
if cls._stdout_enabled and cls._should_log(level):
|
|
424
|
+
color = "" if cls._is_production else cls.COLORS.get(level, "")
|
|
425
|
+
reset = "" if cls._is_production else cls.RESET
|
|
426
|
+
print(f"{color}{line}{reset}", flush=True)
|
|
419
427
|
|
|
420
428
|
# Always write ALL levels to the main file (raw log, no filtering)
|
|
421
429
|
if cls._writer:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/images/tina4-logo-icon.webp
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/public/swagger/oauth2-redirect.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/queue_backends/rabbitmq_backend.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/mongodb_handler.py
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/redis_handler.py
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/session_handlers/valkey_handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/distroless/Dockerfile
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/poetry/Dockerfile
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/templates/docker/python/Dockerfile
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/af/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/en/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/es/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/fr/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/ja/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.mo
RENAMED
|
File without changes
|
{tina4_python-3.13.11 → tina4_python-3.13.14}/tina4_python/translations/zh/LC_MESSAGES/messages.po
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|