queryapigate 0.47.0__tar.gz → 0.48.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. {queryapigate-0.47.0/queryapigate.egg-info → queryapigate-0.48.0}/PKG-INFO +1 -1
  2. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/__init__.py +1 -1
  3. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/alerts.py +4 -4
  4. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/app.py +20 -2
  5. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/cli.py +3 -3
  6. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/dataplane.py +33 -3
  7. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/governance.py +15 -2
  8. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/limits.py +3 -2
  9. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/mcp_server.py +3 -1
  10. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/metrics.py +2 -2
  11. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/ratelimit.py +9 -0
  12. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/sqltools.py +6 -2
  13. {queryapigate-0.47.0 → queryapigate-0.48.0/queryapigate.egg-info}/PKG-INFO +1 -1
  14. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_cli.py +13 -0
  15. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_dataplane.py +129 -4
  16. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_integration.py +11 -0
  17. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mcp.py +13 -2
  18. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_queryapigate.py +11 -0
  19. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_quotas.py +12 -0
  20. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_runstore.py +8 -3
  21. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_shared_rate_limit.py +13 -0
  22. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_supply_chain.py +38 -2
  23. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_isolation.py +24 -0
  24. {queryapigate-0.47.0 → queryapigate-0.48.0}/LICENSE +0 -0
  25. {queryapigate-0.47.0 → queryapigate-0.48.0}/NOTICE +0 -0
  26. {queryapigate-0.47.0 → queryapigate-0.48.0}/README.md +0 -0
  27. {queryapigate-0.47.0 → queryapigate-0.48.0}/pyproject.toml +0 -0
  28. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/__main__.py +0 -0
  29. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/accesslog.py +0 -0
  30. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/adminroles.py +0 -0
  31. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/admins.py +0 -0
  32. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/agentwrites.py +0 -0
  33. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/apikeys.py +0 -0
  34. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/auditchain.py +0 -0
  35. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/auditstream.py +0 -0
  36. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/aws.py +0 -0
  37. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/broadcast.py +0 -0
  38. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/bundle.py +0 -0
  39. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/cache.py +0 -0
  40. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/collection_admin.py +0 -0
  41. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/config.py +0 -0
  42. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/configcode.py +0 -0
  43. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console.py +0 -0
  44. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AccessMapPage-CvmP9Sfw.js +0 -0
  45. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AdministratorsPage-B1G7Dgxv.js +0 -0
  46. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AgentsPage-ex_lhxAd.js +0 -0
  47. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AlertsPage-CkFfLPBu.js +0 -0
  48. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApiKeyDetailPage-DluS3aag.js +0 -0
  49. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApiKeysPage-D3xeaQlS.js +0 -0
  50. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ApprovalsPage-BYL5hXGT.js +0 -0
  51. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/AuditLogPage-TAMz5LIn.js +0 -0
  52. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/CachingPage-8OhOUgxW.js +0 -0
  53. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/CodeBox-Cwh5KHLp.js +0 -0
  54. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ConnectionsPage-mHkn5I8c.js +0 -0
  55. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/DesignerPage-CPZneqCQ.js +0 -0
  56. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/DestinationsPage-Dy6NYnv3.js +0 -0
  57. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Experimental-B3dnaeV7.js +0 -0
  58. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/ExportsPage-BzDOR2fh.js +0 -0
  59. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/HelpPage-BV_YH8GV.js +0 -0
  60. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/HomePage-BShpQIil.js +0 -0
  61. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/KeyActivity-DuTyhSGT.js +0 -0
  62. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Managed-CQ0VXNNZ.js +0 -0
  63. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/McpPage-JMpMLJIz.js +0 -0
  64. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/MetricsPage-BOUZqgao.js +0 -0
  65. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/QueryInfo-n4U1MkL1.js +0 -0
  66. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/RepositoryPage-Cj_ZwMQj.js +0 -0
  67. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Results-6QW3JK2B.js +0 -0
  68. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/RolesPage-Dq83u-am.js +0 -0
  69. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/SchemaBrowser-BzZ6DZjO.js +0 -0
  70. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/SettingsPage-CpjtKQ1P.js +0 -0
  71. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/StatTile-Cft4kJPH.js +0 -0
  72. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/Time-DurHvFXj.js +0 -0
  73. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/WorkspacesPage-Bfe_wYYe.js +0 -0
  74. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/api-CgyYL3VS.js +0 -0
  75. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/client-ZHNiMG8H.js +0 -0
  76. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/forms-DR_JyNks.js +0 -0
  77. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/forms-DdFqq646.js +0 -0
  78. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/icons-CFXE8hAz.js +0 -0
  79. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/index-0v-rImZs.js +0 -0
  80. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/index-Cv64XKNs.css +0 -0
  81. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/maskRules-CvKQIPgN.js +0 -0
  82. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/reach-BAOLd75Y.js +0 -0
  83. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/shared-D8E1i8vU.js +0 -0
  84. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
  85. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/assets/useMutation-D4M562eQ.js +0 -0
  86. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/favicon.svg +0 -0
  87. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/console_dist/index.html +0 -0
  88. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/cors.py +0 -0
  89. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/databases.py +0 -0
  90. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/db.py +0 -0
  91. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/definitions.py +0 -0
  92. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/deprecations.py +0 -0
  93. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/descriptions.py +0 -0
  94. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/destinations.py +0 -0
  95. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/duckfiles.py +0 -0
  96. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/embeddings.py +0 -0
  97. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/engine.py +0 -0
  98. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/errors.py +0 -0
  99. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/events.py +0 -0
  100. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/examples.py +0 -0
  101. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/experimental.py +0 -0
  102. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/exporting.py +0 -0
  103. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/exports.py +0 -0
  104. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/formats.py +0 -0
  105. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/gunicorn_conf.py +0 -0
  106. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/history.py +0 -0
  107. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/instances.py +0 -0
  108. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/jwtauth.py +0 -0
  109. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/lib/h2-2.2.224.jar +0 -0
  110. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/logging_setup.py +0 -0
  111. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/masking.py +0 -0
  112. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/metacache.py +0 -0
  113. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/mongodocs.py +0 -0
  114. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/mongotools.py +0 -0
  115. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/oidc.py +0 -0
  116. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/openapi.py +0 -0
  117. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/params.py +0 -0
  118. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/parquet.py +0 -0
  119. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/pool.py +0 -0
  120. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/postman.py +0 -0
  121. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/quotas.py +0 -0
  122. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/rediscache.py +0 -0
  123. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/rekey.py +0 -0
  124. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/runners.py +0 -0
  125. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/runstore.py +0 -0
  126. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/schema.py +0 -0
  127. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/scope.py +0 -0
  128. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/secheaders.py +0 -0
  129. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/secretrefs.py +0 -0
  130. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/__init__.py +0 -0
  131. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/access.py +0 -0
  132. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/administrators.py +0 -0
  133. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/audit.py +0 -0
  134. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/collections.py +0 -0
  135. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/connections.py +0 -0
  136. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/descriptions.py +0 -0
  137. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/destinations.py +0 -0
  138. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/exports.py +0 -0
  139. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/mcp.py +0 -0
  140. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/queries.py +0 -0
  141. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/services/workspaces.py +0 -0
  142. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/sqlflow.py +0 -0
  143. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/sso.py +0 -0
  144. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/store.py +0 -0
  145. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/tableguard.py +0 -0
  146. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/tracing.py +0 -0
  147. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/usage.py +0 -0
  148. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/v1.py +0 -0
  149. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/v1_spec.py +0 -0
  150. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/vault.py +0 -0
  151. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate/workspaces.py +0 -0
  152. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/SOURCES.txt +0 -0
  153. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/dependency_links.txt +0 -0
  154. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/entry_points.txt +0 -0
  155. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/requires.txt +0 -0
  156. {queryapigate-0.47.0 → queryapigate-0.48.0}/queryapigate.egg-info/top_level.txt +0 -0
  157. {queryapigate-0.47.0 → queryapigate-0.48.0}/setup.cfg +0 -0
  158. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_adhoc_history.py +0 -0
  159. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_adminroles.py +0 -0
  160. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_admins.py +0 -0
  161. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_agent_writes.py +0 -0
  162. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_alerts.py +0 -0
  163. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_api_administrators.py +0 -0
  164. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_api_keys.py +0 -0
  165. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_api_v1.py +0 -0
  166. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_chain.py +0 -0
  167. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_log.py +0 -0
  168. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_siem.py +0 -0
  169. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_audit_stream.py +0 -0
  170. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_aws.py +0 -0
  171. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_backup_restore.py +0 -0
  172. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_broadcast.py +0 -0
  173. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_collection_bundle.py +0 -0
  174. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_collection_postman.py +0 -0
  175. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_collections.py +0 -0
  176. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_configcode.py +0 -0
  177. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_connection_encryption.py +0 -0
  178. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_console.py +0 -0
  179. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_cors_and_rate_limit.py +0 -0
  180. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_data_protection.py +0 -0
  181. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_databases.py +0 -0
  182. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_db.py +0 -0
  183. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_dependency_floors.py +0 -0
  184. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_deprecations.py +0 -0
  185. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_descriptions.py +0 -0
  186. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_destinations.py +0 -0
  187. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_docs_site.py +0 -0
  188. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_duckdb.py +0 -0
  189. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_duckdb_files.py +0 -0
  190. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_embeddings.py +0 -0
  191. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_errors.py +0 -0
  192. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_events.py +0 -0
  193. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_events_server.py +0 -0
  194. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_examples.py +0 -0
  195. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_experimental.py +0 -0
  196. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_export_security.py +0 -0
  197. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_exporting.py +0 -0
  198. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_exports.py +0 -0
  199. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_fips.py +0 -0
  200. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_helm.py +0 -0
  201. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_history.py +0 -0
  202. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_howto.py +0 -0
  203. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_instances.py +0 -0
  204. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_jdbc.py +0 -0
  205. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_jwt.py +0 -0
  206. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_key_rotation.py +0 -0
  207. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_limits.py +0 -0
  208. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_masking.py +0 -0
  209. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_masking_requests.py +0 -0
  210. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mcp_keycloak.py +0 -0
  211. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mcp_oauth.py +0 -0
  212. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_metacache.py +0 -0
  213. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mongo_streaming.py +0 -0
  214. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_mongotools.py +0 -0
  215. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_multinode.py +0 -0
  216. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_observability.py +0 -0
  217. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_oidc.py +0 -0
  218. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_params.py +0 -0
  219. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_parquet.py +0 -0
  220. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_pool.py +0 -0
  221. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_publishing.py +0 -0
  222. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_query_window.py +0 -0
  223. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_rekey.py +0 -0
  224. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_release_check.py +0 -0
  225. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_response_cache.py +0 -0
  226. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_roles.py +0 -0
  227. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_rolling_upgrade.py +0 -0
  228. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_runstore_differential.py +0 -0
  229. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_secretrefs.py +0 -0
  230. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_security_headers.py +0 -0
  231. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_settings.py +0 -0
  232. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sql_guard_fuzz.py +0 -0
  233. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sql_guard_writes.py +0 -0
  234. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sqlflow.py +0 -0
  235. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sso.py +0 -0
  236. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_sso_keycloak.py +0 -0
  237. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_tableguard.py +0 -0
  238. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_tracing.py +0 -0
  239. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_upgrades.py +0 -0
  240. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_usage.py +0 -0
  241. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_vault.py +0 -0
  242. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_config_cli.py +0 -0
  243. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_observability.py +0 -0
  244. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspace_store.py +0 -0
  245. {queryapigate-0.47.0 → queryapigate-0.48.0}/tests/test_workspaces_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.47.0
3
+ Version: 0.48.0
4
4
  Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,5 +1,5 @@
1
1
  """QueryAPIGate - expose SQL databases as a REST API."""
2
- __version__ = "0.47.0"
2
+ __version__ = "0.48.0"
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -44,13 +44,13 @@ _lock = threading.Lock()
44
44
  _refusals: dict[tuple, deque] = {} # ('key' | 'client', who) -> monotonic times of rate-limit refusals
45
45
 
46
46
 
47
- def note_rate_limited(kind, who):
48
- """One rate-limit refusal, for the 'keeps hitting its limit' alert: kind 'key' (a key's own rate_limit) or
49
- 'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
47
+ def note_rate_limited(kind, who, count=1):
48
+ """A rate-limit refusal (`count` of them, now), for the 'keeps hitting its limit' alert: kind 'key' (a key's own
49
+ rate_limit) or 'client' (QUERYAPIGATE_RATE_LIMIT, by address - checked before the caller is known)."""
50
50
  now = time.monotonic()
51
51
  with _lock:
52
52
  times = _refusals.setdefault((kind, who or 'unknown'), deque(maxlen=1000))
53
- times.append(now)
53
+ times.extend([now] * min(count, 1000))
54
54
 
55
55
 
56
56
  # Every kind of alert, the one list: the OpenAPI enum is built from it and tests/test_alerts.py checks API.md's table.
@@ -178,7 +178,8 @@ class _CapturePeer:
178
178
  secret names the client's own socket address in X-QAG-Peer, and that address is taken as the real one - so this
179
179
  server sees the request as if the client had connected directly, ProxyFix and QUERYAPIGATE_TRUST_PROXY included.
180
180
  A peer that isn't an IP address counts as remote, as a Unix socket does. A data plane from another release
181
- (X-QAG-Dataplane-Version) is still the data plane, but is given no plan (dataplane.paired()). Its headers are
181
+ (X-QAG-Dataplane-Version) is still the data plane, but is given no plan (dataplane.paired()). The rate-limit buckets
182
+ it counted a call in before forwarding it (X-QAG-Rate-Counted, ADR 0032) are taken from it alone. Its headers are
182
183
  removed here, so nothing after this layer reads, logs or passes on the secret."""
183
184
 
184
185
  def __init__(self, app):
@@ -189,10 +190,13 @@ class _CapturePeer:
189
190
  peer = environ.pop(dataplane.PEER_ENVIRON, None)
190
191
  wanted = environ.pop(dataplane.WANTED_ENVIRON, None)
191
192
  version = environ.pop(dataplane.VERSION_ENVIRON, None)
193
+ rate_counted = environ.pop(dataplane.COUNTED_ENVIRON, None)
192
194
  if supplied is not None and dataplane.matches(supplied):
193
195
  is_paired = dataplane.paired(version)
194
196
  environ['queryapigate.dataplane'] = 'plan' if wanted == '1' and is_paired else True
195
197
  environ['queryapigate.dataplane_paired'] = is_paired
198
+ if rate_counted is not None and is_paired:
199
+ environ['queryapigate.rate_counted'] = dataplane.counted(rate_counted)
196
200
  environ['REMOTE_ADDR'] = peer if dataplane.is_address(peer) else ''
197
201
  environ['queryapigate.peer_addr'] = environ.get('REMOTE_ADDR')
198
202
  return self.app(environ, start_response)
@@ -475,6 +479,10 @@ def check_rate_limit():
475
479
  """Count this request against its client's quota (governance.py); returns a 429 response when it is over."""
476
480
  if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
477
481
  return None
482
+ counted = request.environ.get('queryapigate.rate_counted', {}).get('client')
483
+ if counted is not None and config.rate_limit() is not None: # by the data plane, which then forwarded the call
484
+ g.rate_limit = counted
485
+ return None
478
486
  verdict = governance.check_client_limit(current_app, request.remote_addr)
479
487
  if verdict is None:
480
488
  return None
@@ -488,6 +496,10 @@ def check_key_rate_limit():
488
496
  per-key identity to limit by before then; a no-op for the admin key and the open/no-key case."""
489
497
  if request.method == 'OPTIONS' or request.endpoint in RATE_LIMIT_EXEMPT:
490
498
  return None
499
+ counted = request.environ.get('queryapigate.rate_counted', {}).get('key')
500
+ if counted is not None and getattr(g.get('permission'), 'rate_limit', None) is not None: # as the client's, above
501
+ g.key_rate_limit = counted
502
+ return None
491
503
  verdict = governance.check_key_limit(current_app, g.get('permission'))
492
504
  if verdict is None:
493
505
  return None
@@ -1236,9 +1248,15 @@ def dataplane_runs():
1236
1248
 
1237
1249
  Each run comes with its history entry already JSON (`entry`), and the labels and timings /metrics counts it by: at
1238
1250
  the data plane's rates Python's own work per run - encoding it, and its labels - is what it can't keep up with, so
1239
- a batch is queued at once (history.record_reported()) and counted once per set of labels."""
1251
+ a batch is queued at once (history.record_reported()) and counted once per set of labels.
1252
+
1253
+ `rate_limited`: the calls it refused itself with a 429 (ADR 0032) - by bucket, caller and method, one duration
1254
+ each - counted as gate()'s own refusals are: in the rejections, the alerts and the request counters. Not runs."""
1240
1255
  report = request.get_json(silent=True) or {}
1241
1256
  runs = report.get('runs') or []
1257
+ for refused in report.get('rate_limited') or []:
1258
+ governance.note_refused(refused['scope'], refused.get('client'), refused.get('workspace'), refused.get('key'),
1259
+ refused['method'], refused['seconds'])
1242
1260
  broadcaster = current_app.extensions['queryapigate_broadcaster']
1243
1261
  with scope.using(scope.current()): # resolved once, not by each of a busy batch's few thousand label lookups
1244
1262
  if report.get('recorded'): # in the run store already: written by the data plane itself (ADR 0029 §4)
@@ -127,8 +127,6 @@ def _init(args):
127
127
  inactive) now that connections are SQLite-backed - no more db_connections.json/saved_sql/ for a new
128
128
  install. On a home whose store already has connections, this is a no-op - "already exists - left
129
129
  untouched"."""
130
- home = config.home()
131
- home.mkdir(parents=True, exist_ok=True)
132
130
  if db.connection().execute('SELECT 1 FROM connections LIMIT 1').fetchone() is not None:
133
131
  print(f'{db.describe()} already has connections - left untouched')
134
132
  return 0
@@ -903,7 +901,7 @@ def build_parser():
903
901
  collection_import.add_argument('--dry-run', action='store_true', help='report what would happen; write nothing')
904
902
  collection_import.set_defaults(func=_collection_import)
905
903
 
906
- config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014, experimental): "
904
+ config_parser = commands.add_parser('config', help="the server's configuration as a file (ADR 0014): "
907
905
  'export it, see what applying one would change, apply it')
908
906
  config_parser.set_defaults(func=lambda a: config_parser.print_help() or 2)
909
907
  config_actions = config_parser.add_subparsers(dest='action')
@@ -1041,6 +1039,8 @@ def main(argv=None):
1041
1039
  os.environ['QUERYAPIGATE_HOME'] = args.home
1042
1040
  if getattr(args, 'server', None) or getattr(args, 'func', None) is _config_new_key:
1043
1041
  return args.func(args) # nothing of this home's: no store opened, so none made in a CI checkout
1042
+ if getattr(args, 'func', None) is _init: # the one command that makes a home: the store below opens inside it
1043
+ config.home().mkdir(parents=True, exist_ok=True)
1044
1044
  try:
1045
1045
  config.check_database_url()
1046
1046
  db.init_schema()
@@ -21,6 +21,11 @@ which is slower but answers as Python does.
21
21
 
22
22
  Saved queries with `{name}` text placeholders never get a plan: their values are part of the SQL text, so the verdict
23
23
  can change from one call to the next.
24
+
25
+ With Redis (QUERYAPIGATE_REDIS_URL), a key's rate limit and the server-wide QUERYAPIGATE_RATE_LIMIT are counted by the
26
+ data plane too, in the same buckets (ADR 0032): the plan carries the key's limit, and the data plane counts a call
27
+ only once it is going to serve it. A call it forwards after counting says so (X-QAG-Rate-Counted), and is not counted
28
+ again here. Its refusals reach /metrics and the alerts with its runs report.
24
29
  """
25
30
  import base64
26
31
  import hmac
@@ -37,6 +42,7 @@ PEER_HEADER = 'X-QAG-Peer'
37
42
  WANTED_HEADER = 'X-QAG-Plan-Wanted'
38
43
  VERSION_HEADER = 'X-QAG-Dataplane-Version' # the data plane's release, sent with its secret
39
44
  PLAN_HEADER = 'X-QAG-Plan'
45
+ COUNTED_HEADER = 'X-QAG-Rate-Counted' # the rate-limit buckets a call the data plane forwards was counted in already
40
46
  VERSION = 1
41
47
  DIALECTS = ('postgres', 'mysql') # the two the prototype serves - every other connection is forwarded
42
48
  _ROWS = ('api_key', 'saved_query', 'connection') # what a plan names the digests of, one row set each
@@ -47,6 +53,7 @@ PEER_ENVIRON = 'HTTP_X_QAG_PEER'
47
53
  # The data plane asks for a plan only when it would keep one: other calls it forwards keep the metadata cache
48
54
  WANTED_ENVIRON = 'HTTP_X_QAG_PLAN_WANTED'
49
55
  VERSION_ENVIRON = 'HTTP_X_QAG_DATAPLANE_VERSION'
56
+ COUNTED_ENVIRON = 'HTTP_X_QAG_RATE_COUNTED'
50
57
  UNPAIRED = ('This data plane is from another release than this server: it gets no plans and no run store lease '
51
58
  'until both are the same release')
52
59
  _unpaired_seen: set = set()
@@ -80,11 +87,29 @@ def is_address(peer):
80
87
 
81
88
  def eligible_caller(permission):
82
89
  """A caller the data plane may serve: a scoped API key - not an administrator, a signed-in user or an open server -
83
- in the default workspace, with none of the limits that keep their own state (rate limit, quota, ADR 0011's)."""
90
+ in the default workspace, without the limits that keep state: no quota or max_concurrent, and a rate limit only
91
+ when Redis holds its bucket, where the data plane counts it too (ADR 0032). Without Redis each process would count
92
+ its own budget. A query_timeout and max_rows keep none: the plan carries them, and the data plane applies each as
93
+ limits.py does."""
84
94
  return (permission is not None and not permission.admin and permission.claims is None and permission.role is None
85
95
  and not permission.claim_binding and permission.workspace == 'default'
86
- and permission.rate_limit is None and permission.quota is None and permission.max_concurrent is None
87
- and permission.query_timeout is None and permission.max_rows is None)
96
+ and (permission.rate_limit is None or config.redis_url() is not None)
97
+ and permission.quota is None and permission.max_concurrent is None)
98
+
99
+
100
+ def counted(value):
101
+ """X-QAG-Rate-Counted (`client=<limit>,<remaining>; key=<limit>,<remaining>`, either or both) as
102
+ {'client' | 'key': (limit, remaining)}: the buckets the data plane counted this call in before forwarding it, and
103
+ what they said. {} for a value of any other shape."""
104
+ found = {}
105
+ for part in (value or '').split(';'):
106
+ bucket, _, numbers = part.strip().partition('=')
107
+ limit, _, remaining = numbers.partition(',')
108
+ if bucket not in ('client', 'key') or bucket in found or not (limit.isascii() and limit.isdigit()
109
+ and remaining.isascii() and remaining.isdigit()):
110
+ return {}
111
+ found[bucket] = (int(limit), int(remaining))
112
+ return found
88
113
 
89
114
 
90
115
  def digests(record):
@@ -161,6 +186,11 @@ def build(record, permission, query_name, number, saved, connection_name, result
161
186
  return None
162
187
  mask = {'columns': len(masked.traced), 'actions': masked.actions, 'fired': masked.fired}
163
188
  return {'v': VERSION, 'rows': rows, 'key': {'workspace': permission.workspace, 'name': permission.name},
189
+ # (count, period in seconds): counted by the data plane in Redis, in the bucket Python counts it in
190
+ 'rate_limit': list(permission.rate_limit) if permission.rate_limit else None,
191
+ # the key's own limits (ADR 0011), applied as limits.timeout() and limits.rows() apply them; an int
192
+ # query_timeout stays one, as min() keeps it
193
+ 'query_timeout': permission.query_timeout, 'max_rows': permission.max_rows,
164
194
  'query': {'name': query_name, 'version': number}, 'connection': {'name': connection_name,
165
195
  'dialect': dialect}, 'statement': sent, 'search_path': prepared['search_path'], 'mask': mask,
166
196
  'expires_in': expires_in,
@@ -13,7 +13,7 @@ process - `queryapigate serve`, `queryapigate mcp`, `queryapigate events` - shar
13
13
  import ipaddress
14
14
  from dataclasses import dataclass
15
15
 
16
- from . import admins, alerts, apikeys, config, jwtauth, metrics
16
+ from . import admins, alerts, apikeys, config, jwtauth, metrics, ratelimit
17
17
 
18
18
  OPEN_ACCESS_REMOTE = ('This server has no API key set, so it answers only local clients. Set QUERYAPIGATE_API_KEY '
19
19
  'to allow access from other hosts.')
@@ -98,13 +98,26 @@ def check_key_limit(flask_app, permission):
98
98
  return None
99
99
  count, period = permission.rate_limit
100
100
  allowed, remaining, retry_after = flask_app.extensions['queryapigate_key_limiter'].hit(
101
- permission.name, count, period)
101
+ ratelimit.budget_name(permission), count, period)
102
102
  if not allowed:
103
103
  metrics.inc_rate_limit_rejection()
104
104
  alerts.note_rate_limited('key', (getattr(permission, 'workspace', 'default'), permission.name))
105
105
  return Verdict(allowed, count, remaining, retry_after)
106
106
 
107
107
 
108
+ def note_refused(scope, client_ip, workspace, key, method, elapsed):
109
+ """429s the data plane answered itself (ADR 0032), one duration each: counted as check_client_limit() and
110
+ check_key_limit() count their own - the rejections and the alerts - and in the request counters, as the calls they
111
+ were. `scope` is 'client' (by `client_ip`, before the caller is known: no key label) or 'key'."""
112
+ if scope == 'client':
113
+ who, label = client_ip, '-'
114
+ else:
115
+ who, label = (workspace or 'default', key), key or '-'
116
+ metrics.inc_rate_limit_rejection(len(elapsed))
117
+ alerts.note_rate_limited(scope, who, len(elapsed))
118
+ metrics.observe_requests(method, 'api.run_named_query', '429', elapsed, label)
119
+
120
+
108
121
  def observe(method, endpoint, status, elapsed, key):
109
122
  """One finished call in /metrics' request counters and latency histograms. REST passes the HTTP method and its
110
123
  route's endpoint; MCP passes method 'MCP' and an 'mcp.*' endpoint, so the two can be told apart."""
@@ -229,10 +229,11 @@ def acquire(query_timeout=None):
229
229
  lease = (query_timeout or LEASE_WITHOUT_TIMEOUT) + LEASE_MARGIN
230
230
  token = secrets.token_hex(8)
231
231
  store = counter()
232
- if not store.acquire(permission.name, maximum, token, lease):
232
+ name = ratelimit.budget_name(permission)
233
+ if not store.acquire(name, maximum, token, lease):
233
234
  raise ApiError(f'This caller already has {maximum} queries running, its max_concurrent - retry when one '
234
235
  'finishes', 429, code='too_many_concurrent', retry_after=1, max_concurrent=maximum)
235
- return Slot(permission.name, token, lease, store)
236
+ return Slot(name, token, lease, store)
236
237
 
237
238
 
238
239
  def effective(grants):
@@ -344,7 +344,9 @@ def call_tool_for(flask_app, permission, name, arguments):
344
344
  structured = g.agent_write_result
345
345
  return {'content': [{'type': 'text', 'text': json.dumps(structured, default=str)}],
346
346
  'structuredContent': structured}
347
- structured = {'rows': response.get_json(), 'truncated': response.headers.get('X-Has-More') == 'true'}
347
+ # REST answers no rows with {"message": "No results returned"}; a tool's rows are a list (its outputSchema)
348
+ rows = response.get_json() if response.headers.get('X-Row-Count') != '0' else []
349
+ structured = {'rows': rows, 'truncated': response.headers.get('X-Has-More') == 'true'}
348
350
  return _rows_result(structured)
349
351
 
350
352
 
@@ -180,10 +180,10 @@ def dec_active_query():
180
180
  _active_queries -= 1
181
181
 
182
182
 
183
- def inc_rate_limit_rejection():
183
+ def inc_rate_limit_rejection(count=1):
184
184
  global _rate_limit_rejections
185
185
  with _lock:
186
- _rate_limit_rejections += 1
186
+ _rate_limit_rejections += count
187
187
 
188
188
 
189
189
  def inc_rate_limit_fallback():
@@ -120,6 +120,15 @@ end
120
120
  return {allowed, math.floor(tokens), retry}
121
121
  """
122
122
  _PREFIX = 'qag:ratelimit:'
123
+
124
+
125
+ def budget_name(permission):
126
+ """What a caller's own budgets - its rate limit's bucket, its max_concurrent slots (limits.py) - are counted
127
+ under: `<workspace>~<name>`, as quotas count theirs. A name is unique only within its workspace, so two workspaces'
128
+ keys of one name are two callers; no workspace name has a `~`."""
129
+ return f"{getattr(permission, 'workspace', None) or 'default'}~{permission.name}"
130
+
131
+
123
132
  _DEGRADED_FOR = 600 # seconds a Redis failure keeps the "limits are per instance right now" alert up
124
133
  _degraded_at = None
125
134
  _last_warned = 0.0
@@ -270,7 +270,11 @@ def paginate(sql, limit, offset, params=None, dialect=None):
270
270
  # Parameters
271
271
  # --------------------------------------------------------------------------------------
272
272
 
273
- def _check_value(name, value):
273
+ def _check_value(name, value, dialect=None):
274
+ if isinstance(value, str) and dialect == 'postgres' and '\x00' in value:
275
+ # psycopg2 refuses it ("A string literal cannot contain NUL") - the caller's value, not the query, is wrong
276
+ raise ApiError(f"Parameter '{name}' contains a NUL character, which PostgreSQL text can't hold",
277
+ code='param_invalid')
274
278
  if value is None or isinstance(value, (bool, str)):
275
279
  return
276
280
  if isinstance(value, (int, float)) and math.isfinite(value):
@@ -344,7 +348,7 @@ def bind_parameters(sql, params, style, dialect=None):
344
348
  if missing:
345
349
  raise ApiError(f"No value provided for parameter(s): {', '.join(missing)}", code='param_required')
346
350
  for name in set(names):
347
- _check_value(name, params[name])
351
+ _check_value(name, params[name], dialect)
348
352
 
349
353
  escape = (lambda text: text.replace('%', '%%')) if style in ('format', 'pyformat') else (lambda text: text)
350
354
  pieces, position = [], 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: queryapigate
3
- Version: 0.47.0
3
+ Version: 0.48.0
4
4
  Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -199,5 +199,18 @@ class LegacySettingsTests(unittest.TestCase):
199
199
  self.assertEqual(config.api_key(), 'secret')
200
200
 
201
201
 
202
+ class InitTests(unittest.TestCase):
203
+ def test_init_makes_a_home_that_doesn_t_exist_yet(self):
204
+ tmp = tempfile.TemporaryDirectory()
205
+ self.addCleanup(tmp.cleanup)
206
+ home = os.path.join(tmp.name, 'new', 'home')
207
+ out = io.StringIO()
208
+ with mock.patch.dict(os.environ, {'QUERYAPIGATE_HOME': home}), redirect_stdout(out):
209
+ self.assertEqual(cli.main(['init']), 0)
210
+ self.assertTrue(store.read_connections()) # the templates, in the store it made
211
+ self.assertTrue(os.path.isdir(home))
212
+ self.assertIn('Created', out.getvalue())
213
+
214
+
202
215
  if __name__ == '__main__':
203
216
  unittest.main()
@@ -115,9 +115,38 @@ class WhoGetsAPlanTests(PlanTestCase):
115
115
  **PAIRED})
116
116
  self.assertIsNone(res.headers.get(dataplane.PLAN_HEADER))
117
117
 
118
- def test_never_for_a_key_with_a_rate_limit(self):
118
+ def test_never_for_a_key_with_a_rate_limit_without_redis(self):
119
119
  self.assertIsNone(self.call('/q/by_id?id=1', self.limited)[1])
120
120
 
121
+ def test_with_redis_a_rate_limited_key_gets_one_that_carries_its_limit(self):
122
+ # Its bucket is in Redis, where the data plane counts it too (ADR 0032): the plan says what the limit is
123
+ with mock.patch.dict(os.environ, {'QUERYAPIGATE_REDIS_URL': 'redis://127.0.0.1:1/0'}):
124
+ plan = self.call('/q/by_id?id=1', self.limited)[1]
125
+ self.assertEqual(plan['rate_limit'], [100, 60])
126
+ self.assertIsNone(self.call('/q/by_id?id=1', self.plain)[1]['rate_limit'])
127
+
128
+ def test_never_for_the_limits_that_keep_their_own_state_with_redis_or_without(self):
129
+ with mock.patch.dict(os.environ, {'QUERYAPIGATE_REDIS_URL': 'redis://127.0.0.1:1/0'}):
130
+ for name, grants in (('quota', {'quota': {'calls': 100, 'per': 'day'}}), ('slots', {'max_concurrent': 2})):
131
+ with self.subTest(name):
132
+ key = apikeys.create_key(name, connections=['pg'], **grants)
133
+ self.assertIsNone(self.call('/q/by_id?id=1', key)[1])
134
+
135
+ def test_a_key_s_own_time_and_row_limits_travel_in_its_plan(self):
136
+ # They keep no state: the data plane applies each as limits.py does. A whole-number timeout stays an int,
137
+ # as Python's min() keeps it (its 504 says 5, not 5.0)
138
+ for name, grants, expected in (('timeout', {'query_timeout': 5}, (5, None)),
139
+ ('fraction', {'query_timeout': 0.5}, (0.5, None)),
140
+ ('rows', {'max_rows': 10}, (None, 10)),
141
+ ('both', {'query_timeout': 2, 'max_rows': 3}, (2, 3))):
142
+ with self.subTest(name):
143
+ key = apikeys.create_key(name, connections=['pg'], **grants)
144
+ plan = self.call('/q/by_id?id=1', key)[1]
145
+ self.assertEqual((plan['query_timeout'], plan['max_rows']), expected)
146
+ self.assertIs(type(plan['query_timeout']), type(expected[0]))
147
+ plain = self.call('/q/by_id?id=1', self.plain)[1]
148
+ self.assertEqual((plain['query_timeout'], plain['max_rows']), (None, None))
149
+
121
150
  def test_never_on_a_connection_the_prototype_does_not_serve(self):
122
151
  self.assertIsNone(self.call('/q/lite_by_id?id=1', self.plain)[1])
123
152
 
@@ -179,11 +208,11 @@ class WhoGetsAPlanTests(PlanTestCase):
179
208
  dockerfile = f.read()
180
209
  rust = re.search(r'^FROM --platform=\$BUILDPLATFORM rust:([\d.]+)-slim-(\w+)@sha256:\w+ AS dataplane$',
181
210
  dockerfile, re.M)
182
- python = re.search(r'^FROM python:[\d.]+-slim@sha256:\w+$', dockerfile, re.M)
211
+ image = re.search(r'^FROM debian:(\w+)-slim@sha256:\w+$', dockerfile, re.M) # Debian's Python (ADR 0031)
183
212
  self.assertIsNotNone(rust, 'the data plane stage: rust:<channel>-slim-<debian>@sha256:..., by digest')
184
- self.assertIsNotNone(python)
213
+ self.assertIsNotNone(image, 'the image: debian:<release>-slim@sha256:..., by digest')
185
214
  self.assertEqual(rust.group(1), channel)
186
- self.assertEqual(rust.group(2), 'trixie', 'the Debian release python:3.12-slim is on')
215
+ self.assertEqual(rust.group(2), image.group(1), 'the data plane is built on the Debian release it runs on')
187
216
  self.assertNotIn('COPY dataplane/ ', dockerfile)
188
217
  self.assertIn('COPY --from=dataplane /queryapigate-dataplane /usr/local/bin/queryapigate-dataplane',
189
218
  dockerfile)
@@ -398,6 +427,57 @@ class ClientAddressTests(unittest.TestCase):
398
427
  self.assertEqual(self.get('/catalog', peer='192.0.2.1', **{'X-API-Key': key}).status_code, 401)
399
428
 
400
429
 
430
+ class CountedByTheDataPlaneTests(PlanTestCase):
431
+ """A call the data plane forwards after counting it (ADR 0032, section 4): X-QAG-Rate-Counted names the buckets
432
+ and what they said, and Python counts them no more - but only beside the data plane's secret."""
433
+
434
+ def get(self, key, counted, secret=SECRET):
435
+ headers = {'X-API-Key': key, dataplane.COUNTED_HEADER: counted}
436
+ if secret:
437
+ headers.update({dataplane.SECRET_HEADER: secret, **PAIRED})
438
+ return self.client.get('/q/by_id?id=1', headers=headers)
439
+
440
+ def test_the_header_as_the_data_plane_writes_it(self):
441
+ self.assertEqual(dataplane.counted('client=5,3; key=100,42'), {'client': (5, 3), 'key': (100, 42)})
442
+ self.assertEqual(dataplane.counted('key=100,0'), {'key': (100, 0)})
443
+ for odd in ('', 'key=100', 'key=-1,2', 'key=1,2; key=1,2', 'other=1,2', 'key=١,2', 'key=1,2;', None):
444
+ with self.subTest(odd):
445
+ self.assertEqual(dataplane.counted(odd), {})
446
+
447
+ def test_a_key_s_bucket_counted_by_the_data_plane_is_not_counted_again(self):
448
+ res = self.get(self.limited, 'key=100,42')
449
+ self.assertEqual(res.status_code, 200)
450
+ self.assertEqual((res.headers['X-RateLimit-Key-Limit'], res.headers['X-RateLimit-Key-Remaining']),
451
+ ('100', '42'))
452
+ res = self.get(self.limited, None, secret=None)
453
+ self.assertEqual(res.headers['X-RateLimit-Key-Remaining'], '99') # Python's own bucket: one call, this one
454
+
455
+ def test_nor_the_client_s(self):
456
+ with mock.patch.dict(os.environ, {'QUERYAPIGATE_RATE_LIMIT': '5/minute'}):
457
+ res = self.get(self.limited, 'client=5,0; key=100,7')
458
+ self.assertEqual(res.status_code, 200)
459
+ names = ('Limit', 'Remaining', 'Key-Limit', 'Key-Remaining')
460
+ self.assertEqual([res.headers[f'X-RateLimit-{name}'] for name in names], ['5', '0', '100', '7'])
461
+ # Only the client's counted there (Redis failed for the key's): Python counts the key's
462
+ res = self.get(self.limited, 'client=5,4')
463
+ self.assertEqual((res.headers['X-RateLimit-Remaining'], res.headers['X-RateLimit-Key-Remaining']),
464
+ ('4', '99'))
465
+
466
+ def test_without_the_secret_python_counts_as_ever(self):
467
+ remaining = [self.get(self.limited, 'key=100,42', secret=secret).headers['X-RateLimit-Key-Remaining']
468
+ for secret in (None, 'wrong')]
469
+ self.assertEqual(remaining, ['99', '98'])
470
+
471
+ def test_never_from_a_data_plane_of_another_release(self):
472
+ res = self.client.get('/q/by_id?id=1', headers={'X-API-Key': self.limited, dataplane.SECRET_HEADER: SECRET,
473
+ dataplane.COUNTED_HEADER: 'key=100,50'})
474
+ self.assertEqual(res.headers['X-RateLimit-Key-Remaining'], '99')
475
+
476
+ def test_a_key_without_a_limit_gets_no_limit_headers_from_it(self):
477
+ res = self.get(self.plain, 'key=100,42')
478
+ self.assertNotIn('X-RateLimit-Key-Limit', res.headers)
479
+
480
+
401
481
  class RunReportTests(PlanTestCase):
402
482
  """The data plane reports the runs it served (POST /internal/dataplane/runs): each recorded as run_saved() records
403
483
  one, and counted in /metrics - and nothing without its secret."""
@@ -410,6 +490,11 @@ class RunReportTests(PlanTestCase):
410
490
  'dialect': 'postgres', 'method': 'GET', 'http_status': 200, 'seconds': 0.002, 'query_seconds': 0.001,
411
491
  'serialization_seconds': 0.0001}
412
492
 
493
+ def setUp(self):
494
+ super().setUp()
495
+ from queryapigate import alerts
496
+ self.addCleanup(alerts._refusals.clear) # the refusals reported here are the process's, for its alerts
497
+
413
498
  def report(self, secret=SECRET):
414
499
  headers = {dataplane.SECRET_HEADER: secret} if secret else {}
415
500
  return self.client.post('/internal/dataplane/runs', json={'runs': [self.RUN]}, headers=headers)
@@ -453,6 +538,46 @@ class RunReportTests(PlanTestCase):
453
538
  self.assertEqual(metrics.history_counts().get('dropped', 0) - before, 7)
454
539
  self.assertEqual(self.history(), [self.ENTRY])
455
540
 
541
+ def test_the_429s_it_answered_are_counted_as_python_s_own(self):
542
+ # ADR 0032, section 5: in the rejections, the alerts and the request counters - and none is a run
543
+ from queryapigate import alerts
544
+
545
+ def counts():
546
+ text = self.client.get('/metrics', headers=ADMIN).get_data(as_text=True)
547
+ found = []
548
+ for name in ('queryapigate_rate_limit_rejections_total',
549
+ 'queryapigate_requests_total{method="GET",endpoint="api.run_named_query",status="429",'
550
+ 'key="limited"}',
551
+ 'queryapigate_requests_total{method="POST",endpoint="api.run_named_query",status="429",'
552
+ 'key="-"}'):
553
+ match = re.search(re.escape(name) + r' (\d+)', text)
554
+ found.append(int(match.group(1)) if match else 0)
555
+ return found
556
+
557
+ def refusals(who):
558
+ with alerts._lock:
559
+ return len(alerts._refusals.get(who, ()))
560
+ before, key, client = counts(), refusals(('key', ('default', 'limited'))), refusals(('client', '203.0.113.9'))
561
+ res = self.client.post('/internal/dataplane/runs', headers={dataplane.SECRET_HEADER: SECRET}, json={
562
+ 'runs': [], 'dropped': 0, 'rate_limited': [
563
+ {'scope': 'key', 'workspace': 'default', 'key': 'limited', 'method': 'GET', 'seconds': [0.001, 0.002]},
564
+ {'scope': 'client', 'client': '203.0.113.9', 'method': 'POST', 'seconds': [0.001]}]})
565
+ self.assertEqual(res.status_code, 204)
566
+ self.assertEqual([after - was for after, was in zip(counts(), before, strict=True)], [3, 2, 1])
567
+ self.assertEqual(refusals(('key', ('default', 'limited'))) - key, 2)
568
+ self.assertEqual(refusals(('client', '203.0.113.9')) - client, 1)
569
+ self.assertEqual(self.history(), [])
570
+
571
+ def test_a_key_that_keeps_getting_them_raises_its_alert(self):
572
+ from queryapigate import alerts
573
+ seconds = [0.001] * alerts.RATE_LIMITED_PER_HOUR
574
+ self.client.post('/internal/dataplane/runs', headers={dataplane.SECRET_HEADER: SECRET}, json={
575
+ 'runs': [], 'rate_limited': [{'scope': 'key', 'workspace': 'default', 'key': 'limited', 'method': 'GET',
576
+ 'seconds': seconds}]})
577
+ raised = [a for a in self.client.get('/api/v1/alerts', headers=ADMIN).get_json()['items']
578
+ if a['kind'] == 'key_rate_limited']
579
+ self.assertEqual([a['id'] for a in raised], ['key_rate_limited:limited'])
580
+
456
581
  def test_its_event_is_the_one_python_writes(self):
457
582
  from queryapigate import events
458
583
  self.assertEqual(self.report().status_code, 204)
@@ -153,6 +153,17 @@ class IntegrationBase:
153
153
  self.assertEqual(len(res.get_json()), 1)
154
154
  self.assertEqual(len(self.query('SELECT * FROM queryapigate_it', '?page_size=100').get_json()), 7)
155
155
 
156
+ def test_a_nul_in_a_bound_parameter_is_never_a_500(self):
157
+ """A NUL in a string parameter: a value the database compares as it is, or - PostgreSQL's text can't hold
158
+ one, and psycopg2 refuses to send it - the caller's 400 naming the parameter."""
159
+ res = self.query('SELECT id FROM queryapigate_it WHERE name = :name', params={'name': 'Item\x003'})
160
+ if self.db == 'postgres':
161
+ self.assertEqual((res.status_code, res.get_json()['code']), (400, 'param_invalid'))
162
+ self.assertIn("'name'", res.get_json()['error'])
163
+ else:
164
+ self.assertEqual(res.status_code, 200, res.get_data(as_text=True)) # and no row: none has a NUL
165
+ self.assertNotIsInstance(res.get_json(), list)
166
+
156
167
  def test_all_formats(self):
157
168
  for fmt in ('json', 'ndjson', 'csv', 'tsv', 'xml', 'yaml', 'xlsx'):
158
169
  res = self.query('SELECT * FROM queryapigate_it ORDER BY id', f'?format={fmt}&page_size=2')
@@ -275,6 +275,12 @@ class CallToolForTests(McpTestCase):
275
275
  result = mcp_server.call_tool_for(self.app, apikeys.OPEN, 'by_id', {'id': 3})
276
276
  self.assertEqual(result['structuredContent']['rows'], [{'id': 3, 'name': 'row3'}])
277
277
 
278
+ def test_no_rows_is_an_empty_list_not_rest_s_message(self):
279
+ self.save('by_id', sql='SELECT * FROM t WHERE id = :id')
280
+ result = mcp_server.call_tool_for(self.app, apikeys.OPEN, 'by_id', {'id': 99})
281
+ self.assertEqual(result['structuredContent'], {'rows': [], 'truncated': False})
282
+ self.assertEqual(json.loads(result['content'][0]['text']), result['structuredContent'])
283
+
278
284
  def test_a_connection_grant_violation_is_reported_as_an_error_result(self):
279
285
  self.save('q1')
280
286
  key = self.create_scoped_key(connections=[]) # no reach to connection 'a' or the query by name
@@ -553,6 +559,7 @@ class EndToEndMcpTests(McpTestCase):
553
559
 
554
560
  def test_a_real_client_can_list_and_call_a_tool(self):
555
561
  self.save('q1')
562
+ self.save('by_id', sql='SELECT * FROM t WHERE id = :id')
556
563
  port = self.start_server()
557
564
 
558
565
  import asyncio
@@ -568,9 +575,13 @@ class EndToEndMcpTests(McpTestCase):
568
575
  tools = await session.list_tools()
569
576
  names = [t.name for t in tools.tools]
570
577
  result = await session.call_tool('q1', {})
571
- return names, result
578
+ # no rows: the SDK checks structuredContent against the outputSchema, which wants a list
579
+ empty = await session.call_tool('by_id', {'id': '99'})
580
+ return names, result, empty
572
581
 
573
- names, result = asyncio.run(talk())
582
+ names, result, empty = asyncio.run(talk())
583
+ self.assertFalse(empty.isError, empty.content)
584
+ self.assertEqual(empty.structuredContent, {'rows': [], 'truncated': False})
574
585
  self.assertIn('q1', names)
575
586
  self.assertIn('list_tables', names)
576
587
  self.assertIn('execute_sql', names)
@@ -1499,6 +1499,17 @@ class ParameterTests(ApiTestCase):
1499
1499
  # no parameters: statement is untouched so drivers do not apply % formatting to it
1500
1500
  self.assertEqual(sqltools.bind_parameters("SELECT '50%'", {}, 'format'), ("SELECT '50%'", None))
1501
1501
 
1502
+ def test_a_nul_in_a_string_parameter_is_refused_for_postgresql_only(self):
1503
+ """psycopg2 can't send one (PostgreSQL text can't hold it), so it is the caller's 400, not a failed query's
1504
+ 500; the other databases take the value as it is."""
1505
+ with self.assertRaises(ApiError) as caught:
1506
+ sqltools.bind_parameters('SELECT :a, :b', {'a': 'fine', 'b': 'x\x00y'}, 'format', 'postgres')
1507
+ self.assertEqual((caught.exception.status, caught.exception.code), (400, 'param_invalid'))
1508
+ self.assertIn("'b'", caught.exception.message)
1509
+ for dialect in ('mysql', 'duckdb', 'clickhouse', None):
1510
+ self.assertEqual(sqltools.bind_parameters('SELECT :b', {'b': 'x\x00y'}, 'qmark', dialect)[1], ['x\x00y'])
1511
+ self.assertEqual(sqltools.bind_parameters('SELECT :b', {'b': 'x'}, 'format', 'postgres')[1], ['x'])
1512
+
1502
1513
 
1503
1514
  FOREVER = 'WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT count(*) FROM c'
1504
1515
 
@@ -136,6 +136,18 @@ class CountingTests(QuotaTestCase):
136
136
  self.assertEqual((refused['isError'], refused['structuredContent']['code']), (True, 'quota_exceeded'))
137
137
 
138
138
 
139
+ def test_an_mcp_call_with_no_rows_counts_none(self):
140
+ res = save_query(self.client, {'author': 'a', 'description': 'd', 'filename': 'none', 'connection_name': 'lite',
141
+ 'sql_query': 'SELECT id FROM t WHERE id < 0'}, headers=ADMIN)
142
+ self.assertEqual(res.status_code, 201, res.get_data(as_text=True))
143
+ quota = {'rows': 100, 'per': 'day'}
144
+ permission = apikeys.authenticate(secret_of(create_key(self.client, ADMIN, name='agent', queries=['none'],
145
+ connections=['lite'], quota=quota)))
146
+ self.assertFalse(mcp_server.call_as(self.app, permission, 'none', {}).get('isError'))
147
+ used = quotas.usage('agent', quota)
148
+ self.assertEqual((used['calls'], used['rows']), (1, 0)) # not 1: REST's "No results returned" isn't a row
149
+
150
+
139
151
  class StoreTests(QuotaTestCase):
140
152
  env = {'QUERYAPIGATE_HISTORY_FLUSH_INTERVAL': '5'}
141
153