sql2api 0.1.0__tar.gz → 0.2.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 (28) hide show
  1. {sql2api-0.1.0/sql2api.egg-info → sql2api-0.2.0}/PKG-INFO +8 -1
  2. {sql2api-0.1.0 → sql2api-0.2.0}/README.md +7 -0
  3. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/__init__.py +1 -1
  4. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/app.py +23 -4
  5. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/config.py +39 -0
  6. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/engine.py +6 -3
  7. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/openapi.py +4 -1
  8. sql2api-0.2.0/sql2api/pool.py +146 -0
  9. sql2api-0.2.0/sql2api/runners.py +354 -0
  10. {sql2api-0.1.0 → sql2api-0.2.0/sql2api.egg-info}/PKG-INFO +8 -1
  11. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/SOURCES.txt +1 -0
  12. {sql2api-0.1.0 → sql2api-0.2.0}/tests/test_integration.py +75 -1
  13. {sql2api-0.1.0 → sql2api-0.2.0}/tests/test_sql2api.py +404 -1
  14. sql2api-0.1.0/sql2api/runners.py +0 -142
  15. {sql2api-0.1.0 → sql2api-0.2.0}/LICENSE +0 -0
  16. {sql2api-0.1.0 → sql2api-0.2.0}/pyproject.toml +0 -0
  17. {sql2api-0.1.0 → sql2api-0.2.0}/setup.cfg +0 -0
  18. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/__main__.py +0 -0
  19. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/cli.py +0 -0
  20. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/errors.py +0 -0
  21. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/formats.py +0 -0
  22. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/lib/h2-2.2.224.jar +0 -0
  23. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/sqltools.py +0 -0
  24. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api/store.py +0 -0
  25. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/dependency_links.txt +0 -0
  26. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/entry_points.txt +0 -0
  27. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/requires.txt +0 -0
  28. {sql2api-0.1.0 → sql2api-0.2.0}/sql2api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql2api
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Turn SQL into a REST API: run queries against MySQL, PostgreSQL, ClickHouse, SQLite or H2 over HTTP and get JSON, CSV, XML, YAML or XLSX back.
5
5
  Author-email: Anantha Raju C <arcswdev@gmail.com>
6
6
  License: MIT
@@ -75,6 +75,10 @@ film_id,title,rating,length
75
75
  - **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
76
76
  cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
77
77
  - **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
78
+ - **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
79
+ opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
80
+ - **Query time limit** - runaway queries are cancelled on the database (30 s by default, `?timeout=` per request) so
81
+ they cannot tie up the service.
78
82
  - **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
79
83
  opened read-only where the database supports it.
80
84
  - **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
@@ -136,6 +140,9 @@ Everything is configured through environment variables (all optional):
136
140
  | `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
137
141
  | `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
138
142
  | `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
143
+ | `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
144
+ | `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
145
+ | `SQL2API_QUERY_TIMEOUT` | `30` | Seconds a query may run before it is cancelled (HTTP 504). `0` disables the limit. A request can lower it with `?timeout=`, never raise it. |
139
146
  | `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
140
147
  | `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
141
148
  | `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
@@ -34,6 +34,10 @@ film_id,title,rating,length
34
34
  - **Bound parameters** - write `WHERE id = :id` and the value is sent to the database separately from the SQL, so it
35
35
  cannot inject anything. Declare types (`{"id": "int"}`) and query-string values are converted for you.
36
36
  - **Pagination** - `?page=2&page_size=50`, with `X-Has-More` telling you whether another page exists.
37
+ - **Connection pooling** - MySQL, PostgreSQL, ClickHouse and H2 connections are reused between requests instead of
38
+ opened for each one (about 30x lower per-request overhead on MySQL and H2 against a local server; more over a network).
39
+ - **Query time limit** - runaway queries are cancelled on the database (30 s by default, `?timeout=` per request) so
40
+ they cannot tie up the service.
37
41
  - **Read-only by default** - only single `SELECT`/`WITH`/`SHOW`/`DESCRIBE`/`EXPLAIN` statements run, and sessions are
38
42
  opened read-only where the database supports it.
39
43
  - **Secrets stay out of files** - `"password": "${PG_PASSWORD}"` in `db_connections.json` reads the environment.
@@ -95,6 +99,9 @@ Everything is configured through environment variables (all optional):
95
99
  | `SQL2API_ALLOW_WRITES` | off | Allow `INSERT`/`UPDATE`/DDL. Otherwise only single read-only statements are accepted. |
96
100
  | `SQL2API_API_KEY` | unset | When set, every request (except `/health` and `/docs`) needs a matching `X-API-Key` header. |
97
101
  | `SQL2API_MAX_PAGE_SIZE` | `1000` | Upper limit for `page_size`. |
102
+ | `SQL2API_POOL_SIZE` | `5` | Idle connections kept per distinct connection setting. `0` turns pooling off. |
103
+ | `SQL2API_POOL_IDLE_TIMEOUT` | `300` | Seconds an idle pooled connection is kept before it is closed. |
104
+ | `SQL2API_QUERY_TIMEOUT` | `30` | Seconds a query may run before it is cancelled (HTTP 504). `0` disables the limit. A request can lower it with `?timeout=`, never raise it. |
98
105
  | `SQL2API_HOST` / `SQL2API_PORT` | `127.0.0.1` / `5000` | Bind address for `sql2api serve`. |
99
106
  | `SQL2API_DEBUG` | off | Flask debug mode. Never enable on a reachable host. |
100
107
  | `SQL2API_H2_JAR` | bundled | Path to a different H2 JDBC jar. |
@@ -1,5 +1,5 @@
1
1
  """SQL2API - expose SQL databases as a REST API."""
2
- __version__ = '0.1.0'
2
+ __version__ = '0.2.0'
3
3
 
4
4
  from .app import create_app # noqa: E402 (app imports __version__)
5
5
 
@@ -1,12 +1,13 @@
1
1
  """The Flask application: HTTP routes on top of the store, engine and formatters."""
2
2
  import hmac
3
3
  import logging
4
+ import math
4
5
 
5
6
  from flask import Blueprint, Flask, Response, jsonify, redirect, request, url_for
6
7
  from flask.json.provider import DefaultJSONProvider
7
8
  from werkzeug.exceptions import HTTPException
8
9
 
9
- from . import config, engine, openapi, sqltools, store
10
+ from . import config, engine, openapi, pool, sqltools, store
10
11
  from .errors import ApiError
11
12
  from .formats import FORMATTERS, json_default
12
13
 
@@ -14,7 +15,7 @@ log = logging.getLogger('sql2api')
14
15
  bp = Blueprint('api', __name__)
15
16
 
16
17
  # Query-string arguments that control a request rather than supplying query parameters.
17
- RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version'}
18
+ RESERVED_ARGS = {'format', 'page', 'page_size', 'connection_name', 'version', 'timeout'}
18
19
  PUBLIC_ENDPOINTS = {'api.index', 'api.favicon', 'api.health', 'api.docs', 'api.openapi_spec'}
19
20
 
20
21
 
@@ -105,6 +106,20 @@ def get_output_format(body=None):
105
106
  return output_format
106
107
 
107
108
 
109
+ def get_timeout(body=None):
110
+ """Seconds allowed for the query: ?timeout= may lower the server limit but never raise it."""
111
+ raw = request.args.get('timeout', (body or {}).get('timeout'))
112
+ if raw in (None, ''):
113
+ return config.effective_timeout(None)
114
+ try:
115
+ value = float(raw)
116
+ except (TypeError, ValueError):
117
+ raise ApiError('timeout must be a number of seconds') from None
118
+ if not math.isfinite(value) or value <= 0:
119
+ raise ApiError('timeout must be a positive number of seconds')
120
+ return config.effective_timeout(value)
121
+
122
+
108
123
  def render(result, output_format, page, page_size):
109
124
  response = jsonify({'message': 'No results returned'}) if not result else FORMATTERS[output_format](result)
110
125
  response.headers['X-Page'] = str(page)
@@ -126,8 +141,9 @@ def execute_sql_endpoint():
126
141
  raise ApiError('Connection name is missing')
127
142
  params = get_object(data.get('params'), 'params')
128
143
  output_format = get_output_format(data)
144
+ timeout = get_timeout(data)
129
145
  limit, offset, page = get_pagination()
130
- result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params)
146
+ result = engine.execute_sql(data['sql'], data['connection_name'], limit, offset, params, timeout)
131
147
  return render(result, output_format, page, limit)
132
148
 
133
149
 
@@ -148,11 +164,12 @@ def run_saved(ref, body, url_params):
148
164
  params = sqltools.coerce_params(saved.get('query_parameters'), raw)
149
165
  sql = sqltools.fill_placeholders(saved['sql_query'], params)
150
166
  output_format = get_output_format(body)
167
+ timeout = get_timeout(body)
151
168
  limit, offset, page = get_pagination()
152
169
 
153
170
  entry = {'executed_at': store.now(), 'connection_name': connection_name}
154
171
  try:
155
- result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params)
172
+ result, elapsed_ms = engine.timed(engine.execute_sql, sql, connection_name, limit, offset, params, timeout)
156
173
  except ApiError as error:
157
174
  store.record_execution(path, number, {**entry, 'status': 'error', 'error': error.message})
158
175
  raise
@@ -256,12 +273,14 @@ def update_connections():
256
273
  if not connections or not isinstance(connections, dict):
257
274
  raise ApiError('Connections data is missing')
258
275
  store.update_connections(connections)
276
+ pool.close_pooled_connections() # new settings or credentials must not be served by old connections
259
277
  return jsonify({'message': 'Connections updated successfully'}), 200
260
278
 
261
279
 
262
280
  @bp.route('/connections/<name>', methods=['DELETE'])
263
281
  def delete_connection(name):
264
282
  store.delete_connection(name)
283
+ pool.close_pooled_connections() # a removed connection must not keep serving from idle sockets
265
284
  return jsonify({'message': f"Connection '{name}' deleted"}), 200
266
285
 
267
286
 
@@ -6,6 +6,9 @@ SUPPORTED_DB_TYPES = ('mysql', 'postgres', 'clickhouse', 'sqlite', 'h2')
6
6
  PASSWORD_MASK = '********'
7
7
  CONNECT_TIMEOUT = 10 # seconds
8
8
  HISTORY_LIMIT = 50 # executions remembered per saved-query version
9
+ DEFAULT_QUERY_TIMEOUT = 30.0 # seconds
10
+ DEFAULT_POOL_SIZE = 5 # idle connections kept per distinct connection
11
+ DEFAULT_POOL_IDLE_TIMEOUT = 300.0 # seconds
9
12
 
10
13
  # Ships with the package; override with SQL2API_H2_JAR to use a different H2 version.
11
14
  BUNDLED_H2_JAR = Path(__file__).parent / 'lib' / 'h2-2.2.224.jar'
@@ -55,6 +58,42 @@ def api_key():
55
58
  return os.environ.get('SQL2API_API_KEY') or None
56
59
 
57
60
 
61
+ def query_timeout():
62
+ """Server-wide statement time limit in seconds (SQL2API_QUERY_TIMEOUT); None when disabled (set to 0)."""
63
+ try:
64
+ value = float(os.environ.get('SQL2API_QUERY_TIMEOUT', DEFAULT_QUERY_TIMEOUT))
65
+ except ValueError:
66
+ return DEFAULT_QUERY_TIMEOUT
67
+ if value == 0:
68
+ return None
69
+ return value if value > 0 else DEFAULT_QUERY_TIMEOUT
70
+
71
+
72
+ def effective_timeout(requested=None):
73
+ """The limit to enforce: a request may ask for less time than the server allows, never more."""
74
+ limit = query_timeout()
75
+ if requested is None:
76
+ return limit
77
+ return requested if limit is None else min(requested, limit)
78
+
79
+
80
+ def pool_size():
81
+ """Idle connections kept per distinct connection (SQL2API_POOL_SIZE); 0 disables pooling."""
82
+ try:
83
+ return max(0, int(os.environ.get('SQL2API_POOL_SIZE', DEFAULT_POOL_SIZE)))
84
+ except ValueError:
85
+ return DEFAULT_POOL_SIZE
86
+
87
+
88
+ def pool_idle_timeout():
89
+ """Seconds an idle pooled connection is kept before it is closed (SQL2API_POOL_IDLE_TIMEOUT)."""
90
+ try:
91
+ value = float(os.environ.get('SQL2API_POOL_IDLE_TIMEOUT', DEFAULT_POOL_IDLE_TIMEOUT))
92
+ except ValueError:
93
+ return DEFAULT_POOL_IDLE_TIMEOUT
94
+ return value if value > 0 else DEFAULT_POOL_IDLE_TIMEOUT
95
+
96
+
58
97
  def max_page_size():
59
98
  try:
60
99
  return max(1, int(os.environ.get('SQL2API_MAX_PAGE_SIZE', 1000)))
@@ -5,19 +5,22 @@ import time
5
5
  from . import config, store
6
6
  from .errors import ApiError
7
7
  from .formats import ResultSetDTO
8
+ from .pool import get_pool
8
9
  from .runners import RUNNERS
9
10
  from .sqltools import validate_sql
10
11
 
11
12
  log = logging.getLogger('sql2api')
12
13
 
13
14
 
14
- def execute_sql(sql, connection_name, limit, offset, params=None):
15
+ def execute_sql(sql, connection_name, limit, offset, params=None, timeout=None):
15
16
  """Run ``sql`` on a named connection and return the requested page as a ResultSetDTO."""
16
17
  details = store.get_connection(connection_name)
17
18
  sql = validate_sql(sql)
18
- log.info('Executing on %s (%s), limit=%s offset=%s: %s', connection_name, details['db'], limit, offset, sql)
19
+ log.info('Executing on %s (%s), limit=%s offset=%s timeout=%s: %s',
20
+ connection_name, details['db'], limit, offset, timeout, sql)
19
21
  try:
20
- columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes())
22
+ columns, rows = RUNNERS[details['db']](details, sql, params, limit, offset, not config.allow_writes(), timeout,
23
+ get_pool())
21
24
  except ApiError:
22
25
  raise
23
26
  except ImportError as error:
@@ -7,12 +7,15 @@ _PAGE_PARAMS = [
7
7
  {'name': 'page', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 1}},
8
8
  {'name': 'page_size', 'in': 'query', 'schema': {'type': 'integer', 'minimum': 1, 'default': 10},
9
9
  'description': 'Upper limit is SQL2API_MAX_PAGE_SIZE (default 1000).'},
10
+ {'name': 'timeout', 'in': 'query', 'schema': {'type': 'number', 'exclusiveMinimum': 0},
11
+ 'description': 'Seconds the query may run before it is cancelled (504). Can lower, never raise, the server '
12
+ 'limit SQL2API_QUERY_TIMEOUT (default 30; 0 disables the server limit).'},
10
13
  ]
11
14
  _ROWS = {'description': 'The requested page. Header X-Has-More says whether another page follows.',
12
15
  'headers': {'X-Page': {'schema': {'type': 'integer'}}, 'X-Page-Size': {'schema': {'type': 'integer'}},
13
16
  'X-Has-More': {'schema': {'type': 'string', 'enum': ['true', 'false']}}},
14
17
  'content': {'application/json': {'schema': {'type': 'array', 'items': {'type': 'object'}}}}}
15
- _ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '500')}
18
+ _ERRORS = {c: {'$ref': '#/components/responses/Error'} for c in ('400', '401', '403', '404', '500', '504')}
16
19
 
17
20
 
18
21
  def _body(properties, required):
@@ -0,0 +1,146 @@
1
+ """A small thread-safe pool of live database connections.
2
+
3
+ Only *idle* connections are pooled: a connection is checked out exclusively by one request, and on the way
4
+ back it is reset (its transaction ended) and kept for reuse - unless the request failed, in which case its
5
+ state is unknown and it is closed instead. The number of concurrent connections is not limited here; the
6
+ pool only bounds how many idle ones are kept around.
7
+ """
8
+ import atexit
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import threading
13
+ import time
14
+ from collections import deque
15
+ from contextlib import contextmanager
16
+
17
+ from . import config
18
+
19
+ log = logging.getLogger('sql2api')
20
+
21
+ # A connection that has been idle for less than this is trusted without a round trip to check it.
22
+ VALIDATE_AFTER = 5.0 # seconds
23
+
24
+
25
+ class Session:
26
+ """A connection plus what the driver wants to remember about it (e.g. limits already applied)."""
27
+
28
+ def __init__(self, conn, driver):
29
+ self.conn = conn
30
+ self.driver = driver
31
+ self.state = {}
32
+ self.idle_since = time.monotonic()
33
+
34
+ def close(self):
35
+ try:
36
+ self.driver.close(self)
37
+ except Exception: # a connection that cannot be closed cleanly is gone anyway
38
+ log.debug('Error while closing a connection', exc_info=True)
39
+
40
+
41
+ def fingerprint(details, read_only):
42
+ """Pool key: connections are interchangeable when their settings and read-only mode are identical."""
43
+ blob = json.dumps(details, sort_keys=True, default=str)
44
+ return hashlib.sha256(blob.encode('utf-8')).hexdigest(), bool(read_only)
45
+
46
+
47
+ class ConnectionPool:
48
+ def __init__(self):
49
+ self._idle = {} # key -> deque of Sessions, least recently used on the left
50
+ self._lock = threading.Lock()
51
+
52
+ @contextmanager
53
+ def checkout(self, driver, details, read_only):
54
+ key = fingerprint(details, read_only)
55
+ session = self._acquire(key)
56
+ if session is None:
57
+ session = Session(driver.connect(details, read_only), driver)
58
+ try:
59
+ yield session
60
+ except BaseException:
61
+ session.close() # after an error its state is unknown - never reuse it
62
+ raise
63
+ self._release(key, session)
64
+
65
+ # ---- internals ------------------------------------------------------------------
66
+
67
+ def _acquire(self, key):
68
+ while True:
69
+ with self._lock:
70
+ queue = self._idle.get(key)
71
+ if not queue:
72
+ return None
73
+ session = queue.pop() # most recently used first, so spare connections age out
74
+ if not queue:
75
+ del self._idle[key]
76
+ idle_for = time.monotonic() - session.idle_since
77
+ if idle_for > config.pool_idle_timeout():
78
+ session.close()
79
+ continue
80
+ if idle_for > VALIDATE_AFTER:
81
+ try:
82
+ alive = session.driver.is_alive(session)
83
+ except Exception:
84
+ alive = False
85
+ if not alive:
86
+ log.info('Discarding a dead pooled connection')
87
+ session.close()
88
+ continue
89
+ return session
90
+
91
+ def _release(self, key, session):
92
+ try:
93
+ session.driver.reset(session) # ends the transaction so the next user sees fresh data
94
+ except Exception:
95
+ log.debug('Could not reset a connection; closing it', exc_info=True)
96
+ session.close()
97
+ return
98
+ session.idle_since = time.monotonic()
99
+ surplus = []
100
+ with self._lock:
101
+ queue = self._idle.setdefault(key, deque())
102
+ queue.append(session)
103
+ while len(queue) > config.pool_size():
104
+ surplus.append(queue.popleft())
105
+ surplus.extend(self._expired_locked())
106
+ for old in surplus:
107
+ old.close()
108
+
109
+ def _expired_locked(self):
110
+ """Remove and return idle sessions past the idle timeout, across every key (caller holds the lock)."""
111
+ cutoff = time.monotonic() - config.pool_idle_timeout()
112
+ expired = []
113
+ for key in list(self._idle):
114
+ queue = self._idle[key]
115
+ while queue and queue[0].idle_since < cutoff:
116
+ expired.append(queue.popleft())
117
+ if not queue:
118
+ del self._idle[key]
119
+ return expired
120
+
121
+ # ---- housekeeping ---------------------------------------------------------------
122
+
123
+ def idle_count(self):
124
+ with self._lock:
125
+ return sum(len(q) for q in self._idle.values())
126
+
127
+ def close_all(self):
128
+ with self._lock:
129
+ sessions = [s for queue in self._idle.values() for s in queue]
130
+ self._idle.clear()
131
+ for session in sessions:
132
+ session.close()
133
+
134
+
135
+ _default = ConnectionPool()
136
+ atexit.register(_default.close_all)
137
+
138
+
139
+ def close_pooled_connections():
140
+ """Close every idle connection in the shared pool (also runs at interpreter exit)."""
141
+ _default.close_all()
142
+
143
+
144
+ def get_pool():
145
+ """The shared pool, or None when pooling is disabled (SQL2API_POOL_SIZE=0)."""
146
+ return _default if config.pool_size() > 0 else None