sql2api 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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.
sql2api/__init__.py CHANGED
@@ -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
 
sql2api/app.py CHANGED
@@ -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
 
sql2api/config.py CHANGED
@@ -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)))
sql2api/engine.py CHANGED
@@ -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:
sql2api/openapi.py CHANGED
@@ -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):
sql2api/pool.py ADDED
@@ -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
sql2api/runners.py CHANGED
@@ -1,16 +1,35 @@
1
- """One runner per database type. Each connects, runs one statement, closes, and returns (columns, rows).
1
+ """One runner per database type: connect (or borrow a pooled connection), run one statement, return (columns, rows).
2
2
 
3
3
  Runners fetch ``limit + 1`` rows: the extra row is how the caller learns whether another page exists.
4
4
  Database drivers are imported lazily so only the ones you actually use need to be installed.
5
+
6
+ Each network database is a small ``_Driver`` subclass. ``_make_runner`` turns it into the callable stored in
7
+ ``RUNNERS``, which either opens a connection for the single call or borrows one from the pool.
5
8
  """
9
+ import atexit
10
+ import logging
11
+ import math
6
12
  import os
7
13
  import sqlite3
14
+ import time
8
15
  from pathlib import Path
9
16
 
10
17
  from . import config
11
18
  from .errors import ApiError
19
+ from .pool import Session, close_pooled_connections
12
20
  from .sqltools import bind_parameters, is_paginated, paginate
13
21
 
22
+ log = logging.getLogger('sql2api')
23
+
24
+
25
+ def _timed_out(timeout):
26
+ return ApiError(f'The query exceeded the time limit of {timeout:g} seconds and was cancelled', 504,
27
+ timeout=timeout)
28
+
29
+
30
+ def _millis(timeout):
31
+ return max(1, int(timeout * 1000))
32
+
14
33
 
15
34
  def _connect_args(details, **renames):
16
35
  """Pick the standard connection fields out of a stored connection, renaming keys per driver."""
@@ -45,53 +64,250 @@ def _fetch_page(cursor, sql, params, style, limit, offset):
45
64
  return [d[0] for d in cursor.description], rows
46
65
 
47
66
 
48
- def _run_mysql(details, sql, params, limit, offset, read_only):
49
- import mysql.connector
50
- conn = mysql.connector.connect(connection_timeout=config.CONNECT_TIMEOUT, **_connect_args(details))
51
- try:
52
- cursor = conn.cursor()
67
+ # --------------------------------------------------------------------------------------
68
+ # Drivers
69
+ # --------------------------------------------------------------------------------------
70
+
71
+ class _Driver:
72
+ """How to connect to, check, reset, query and close one kind of database."""
73
+
74
+ def connect(self, details, read_only):
75
+ raise NotImplementedError
76
+
77
+ def is_alive(self, session):
78
+ """Cheap check that a connection that sat idle is still usable."""
79
+ return True
80
+
81
+ def reset(self, session):
82
+ """Put a used connection back into a clean state: end its transaction so no stale snapshot lingers."""
83
+
84
+ def close(self, session):
85
+ session.conn.close()
86
+
87
+ def query(self, session, sql, params, limit, offset, read_only, timeout):
88
+ raise NotImplementedError
89
+
90
+
91
+ # MySQL reports 3024 (ER_QUERY_TIMEOUT), MariaDB 1969 (ER_STATEMENT_TIMEOUT)
92
+ _MYSQL_TIMEOUT_ERRNOS = (3024, 1969)
93
+
94
+
95
+ class _MySQL(_Driver):
96
+ def connect(self, details, read_only):
97
+ import mysql.connector
98
+ conn = mysql.connector.connect(connection_timeout=config.CONNECT_TIMEOUT, **_connect_args(details))
53
99
  if read_only:
54
- cursor.execute('SET SESSION TRANSACTION READ ONLY')
55
- result = _fetch_page(cursor, sql, params, 'format', limit, offset)
56
- if not read_only:
57
- conn.commit()
58
- return result
59
- finally:
60
- conn.close()
100
+ try:
101
+ cursor = conn.cursor()
102
+ cursor.execute('SET SESSION TRANSACTION READ ONLY')
103
+ cursor.close()
104
+ except Exception:
105
+ conn.close()
106
+ raise
107
+ return conn
61
108
 
109
+ def is_alive(self, session):
110
+ return session.conn.is_connected()
62
111
 
63
- def _run_postgres(details, sql, params, limit, offset, read_only):
64
- import psycopg2
65
- conn = psycopg2.connect(connect_timeout=config.CONNECT_TIMEOUT, **_connect_args(details, database='dbname'))
66
- try:
112
+ def reset(self, session):
113
+ session.conn.rollback()
114
+
115
+ def query(self, session, sql, params, limit, offset, read_only, timeout):
116
+ import mysql.connector
117
+ conn = session.conn
118
+ cursor = conn.cursor()
119
+ try:
120
+ if timeout != session.state.get('timeout'):
121
+ # MySQL limits SELECT statements in milliseconds; MariaDB uses a differently named variable
122
+ # in seconds. 0 removes a limit that an earlier request applied to this pooled connection.
123
+ try:
124
+ cursor.execute(f'SET SESSION max_execution_time = {_millis(timeout) if timeout else 0}')
125
+ except mysql.connector.Error:
126
+ try:
127
+ cursor.execute(f'SET SESSION max_statement_time = {timeout:g}' if timeout
128
+ else 'SET SESSION max_statement_time = 0')
129
+ except mysql.connector.Error:
130
+ log.warning('This MySQL server supports neither max_execution_time nor '
131
+ 'max_statement_time; the query time limit is not enforced')
132
+ session.state['timeout'] = timeout
133
+ try:
134
+ result = _fetch_page(cursor, sql, params, 'format', limit, offset)
135
+ except mysql.connector.Error as error:
136
+ if getattr(error, 'errno', None) in _MYSQL_TIMEOUT_ERRNOS:
137
+ raise _timed_out(timeout) from None
138
+ raise
139
+ if not read_only:
140
+ conn.commit()
141
+ return result
142
+ finally:
143
+ cursor.close()
144
+
145
+
146
+ class _Postgres(_Driver):
147
+ def connect(self, details, read_only):
148
+ import psycopg2
149
+ conn = psycopg2.connect(connect_timeout=config.CONNECT_TIMEOUT, **_connect_args(details, database='dbname'))
67
150
  if read_only:
68
- conn.set_session(readonly=True)
151
+ try:
152
+ conn.set_session(readonly=True)
153
+ except Exception:
154
+ conn.close()
155
+ raise
156
+ return conn
157
+
158
+ def is_alive(self, session):
159
+ if session.conn.closed:
160
+ return False
161
+ with session.conn.cursor() as cursor:
162
+ cursor.execute('SELECT 1')
163
+ return True
164
+
165
+ def reset(self, session):
166
+ session.conn.rollback()
167
+
168
+ def query(self, session, sql, params, limit, offset, read_only, timeout):
169
+ import psycopg2.errors
170
+ conn = session.conn
69
171
  with conn.cursor() as cursor:
70
- result = _fetch_page(cursor, sql, params, 'format', limit, offset)
172
+ if timeout:
173
+ # LOCAL scopes the limit to this transaction, so nothing leaks to the connection's next user
174
+ cursor.execute(f'SET LOCAL statement_timeout = {_millis(timeout)}')
175
+ try:
176
+ result = _fetch_page(cursor, sql, params, 'format', limit, offset)
177
+ except psycopg2.errors.QueryCanceled:
178
+ raise _timed_out(timeout) from None
71
179
  if not read_only:
72
180
  conn.commit()
73
181
  return result
74
- finally:
75
- conn.close()
76
182
 
77
183
 
78
- def _run_clickhouse(details, sql, params, limit, offset, read_only):
79
- from clickhouse_driver import Client
80
- client = Client(connect_timeout=config.CONNECT_TIMEOUT, **_connect_args(details))
81
- try:
184
+ _CLICKHOUSE_TIMEOUT_EXCEEDED = 159
185
+
186
+
187
+ class _ClickHouse(_Driver):
188
+ # The native-protocol Client pings before each query and reconnects by itself, so it needs no
189
+ # is_alive check, and every setting is sent per query, so there is no session state to reset.
190
+ def connect(self, details, read_only):
191
+ from clickhouse_driver import Client
192
+ client_args = {}
193
+ limit = config.query_timeout()
194
+ if limit:
195
+ # Backstop in case the server never answers; the server-side limit below is what normally fires.
196
+ client_args['send_receive_timeout'] = math.ceil(limit) + 5
197
+ return Client(connect_timeout=config.CONNECT_TIMEOUT, **client_args, **_connect_args(details))
198
+
199
+ def close(self, session):
200
+ session.conn.disconnect()
201
+
202
+ def query(self, session, sql, params, limit, offset, read_only, timeout):
203
+ from clickhouse_driver.errors import ServerException
82
204
  sql, args, sliced = _prepare(sql, params, 'pyformat', limit, offset)
83
- result = client.execute(sql, args, with_column_types=True,
84
- settings={'readonly': 1} if read_only else None)
205
+ settings = {}
206
+ if timeout:
207
+ settings['max_execution_time'] = math.ceil(timeout) # whole seconds
208
+ if read_only:
209
+ settings['readonly'] = 1 # last: it forbids changing settings after it
210
+ try:
211
+ result = session.conn.execute(sql, args, with_column_types=True, settings=settings or None)
212
+ except ServerException as error:
213
+ if error.code == _CLICKHOUSE_TIMEOUT_EXCEEDED:
214
+ raise _timed_out(timeout) from None
215
+ raise
85
216
  # Statements without a result set (DDL, INSERT) may not return a (rows, types) pair.
86
217
  rows, column_types = result if isinstance(result, tuple) else ([], [])
87
218
  if not sliced:
88
219
  rows = rows[offset:offset + limit + 1]
89
220
  return [c[0] for c in column_types or []], rows
90
- finally:
91
- client.disconnect()
92
221
 
93
222
 
94
- def _run_sqlite(details, sql, params, limit, offset, read_only):
223
+ def _attach_thread_as_daemon():
224
+ """Make the calling thread a *daemon* thread as far as the JVM is concerned.
225
+
226
+ jaydebeapi attaches every thread that uses H2 to the JVM as a non-daemon thread, and JPype's shutdown then
227
+ waits for those threads forever - so a server that has handled concurrent H2 requests would hang on exit.
228
+ Attaching them as daemons first (jaydebeapi only attaches threads that are not attached yet) avoids that.
229
+ """
230
+ import jpype
231
+ if jpype.isJVMStarted() and not jpype.java.lang.Thread.isAttached():
232
+ jpype.java.lang.Thread.attachAsDaemon()
233
+
234
+
235
+ class _H2(_Driver):
236
+ _exit_hook_registered = False
237
+
238
+ def connect(self, details, read_only):
239
+ import jaydebeapi
240
+ _attach_thread_as_daemon()
241
+ host = details.get('host') or 'localhost'
242
+ if details.get('port') and ':' not in host:
243
+ host = f"{host}:{details['port']}"
244
+ url = f"jdbc:h2:tcp://{host}/~/{details.get('database')}"
245
+ # Unlike the other drivers, H2's JDBC setReadOnly() is only a hint and does not block writes,
246
+ # so here the read-only guarantee rests on validate_sql() alone.
247
+ conn = jaydebeapi.connect('org.h2.Driver', url, [details.get('user'), details.get('password')],
248
+ [config.h2_jar()])
249
+ if not _H2._exit_hook_registered:
250
+ # JPype shuts the JVM down from its own atexit hook, which it registers when the JVM starts (just
251
+ # now). Hooks run last-in first-out, so registering ours after that makes pooled connections close
252
+ # while the JVM is still alive - closing them afterwards would hang the interpreter at exit.
253
+ atexit.register(close_pooled_connections)
254
+ _H2._exit_hook_registered = True
255
+ return conn
256
+
257
+ def is_alive(self, session):
258
+ _attach_thread_as_daemon()
259
+ return bool(session.conn.jconn.isValid(2))
260
+
261
+ def reset(self, session):
262
+ _attach_thread_as_daemon()
263
+ session.conn.rollback()
264
+
265
+ def close(self, session):
266
+ _attach_thread_as_daemon()
267
+ session.conn.close()
268
+
269
+ def query(self, session, sql, params, limit, offset, read_only, timeout):
270
+ import jaydebeapi
271
+ _attach_thread_as_daemon()
272
+ conn = session.conn
273
+ cursor = conn.cursor()
274
+ if timeout != session.state.get('timeout'):
275
+ cursor.execute(f'SET QUERY_TIMEOUT {_millis(timeout) if timeout else 0}')
276
+ session.state['timeout'] = timeout
277
+ try:
278
+ result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
279
+ except jaydebeapi.Error as error:
280
+ # H2: "Statement was canceled or the session timed out" (error 57014 / 90051)
281
+ if timeout and any(word in str(error).lower() for word in ('canceled', 'timed out')):
282
+ raise _timed_out(timeout) from None
283
+ raise
284
+ if not read_only:
285
+ conn.commit()
286
+ return result
287
+
288
+
289
+ def _make_runner(driver):
290
+ def run(details, sql, params, limit, offset, read_only, timeout=None, pool=None):
291
+ if pool is None:
292
+ session = Session(driver.connect(details, read_only), driver)
293
+ try:
294
+ return driver.query(session, sql, params, limit, offset, read_only, timeout)
295
+ finally:
296
+ session.close()
297
+ with pool.checkout(driver, details, read_only) as session:
298
+ return driver.query(session, sql, params, limit, offset, read_only, timeout)
299
+
300
+ return run
301
+
302
+
303
+ _run_mysql = _make_runner(_MySQL())
304
+ _run_postgres = _make_runner(_Postgres())
305
+ _run_clickhouse = _make_runner(_ClickHouse())
306
+ _run_h2 = _make_runner(_H2())
307
+
308
+
309
+ def _run_sqlite(details, sql, params, limit, offset, read_only, timeout=None, pool=None):
310
+ """SQLite is a local file, so opening it per call is cheap and it is never pooled."""
95
311
  path = details.get('database')
96
312
  if not path:
97
313
  raise ApiError('Database file path not provided')
@@ -104,28 +320,24 @@ def _run_sqlite(details, sql, params, limit, offset, read_only):
104
320
  else:
105
321
  conn = sqlite3.connect(path, timeout=config.CONNECT_TIMEOUT)
106
322
  try:
107
- cursor = conn.cursor()
108
- result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
109
- if not read_only:
110
- conn.commit()
111
- return result
112
- finally:
113
- conn.close()
323
+ expired = []
324
+ if timeout:
325
+ deadline = time.monotonic() + timeout
114
326
 
327
+ def check_deadline():
328
+ if time.monotonic() > deadline:
329
+ expired.append(True)
330
+ return 1 # non-zero aborts the running statement
331
+ return 0
115
332
 
116
- def _run_h2(details, sql, params, limit, offset, read_only):
117
- import jaydebeapi
118
- host = details.get('host') or 'localhost'
119
- if details.get('port') and ':' not in host:
120
- host = f"{host}:{details['port']}"
121
- url = f"jdbc:h2:tcp://{host}/~/{details.get('database')}"
122
- conn = jaydebeapi.connect('org.h2.Driver', url, [details.get('user'), details.get('password')],
123
- [config.h2_jar()])
124
- try:
125
- # Unlike the other drivers, H2's JDBC setReadOnly() is only a hint and does not block writes,
126
- # so here the read-only guarantee rests on validate_sql() alone.
333
+ conn.set_progress_handler(check_deadline, 10000) # called every 10 000 VM instructions
127
334
  cursor = conn.cursor()
128
- result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
335
+ try:
336
+ result = _fetch_page(cursor, sql, params, 'qmark', limit, offset)
337
+ except sqlite3.OperationalError:
338
+ if expired:
339
+ raise _timed_out(timeout) from None
340
+ raise
129
341
  if not read_only:
130
342
  conn.commit()
131
343
  return result
@@ -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. |
@@ -0,0 +1,20 @@
1
+ sql2api/__init__.py,sha256=aVq6e6s7KZt7JlbdHpxY4q_3dOZSuzf8wFrLLggZyQc,185
2
+ sql2api/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ sql2api/app.py,sha256=AcYUNu31OJ_aW9_kbXtuYJDAJH5UW3paNuv52UgMWOs,12335
4
+ sql2api/cli.py,sha256=1L3RYiCgnh1w_speXB4RPqxNWs5kR7kT68X9L-aqDLk,2712
5
+ sql2api/config.py,sha256=ACMeLisww_6WmzdbIysmZjBui6QxQLWh1evv8z1l9aw,3663
6
+ sql2api/engine.py,sha256=hys-ArxL4n3WWIsrk8hcERaLB11io_f5Wpr5x9zVBr4,1691
7
+ sql2api/errors.py,sha256=nJddO1gAjxQZH7-IXf3Bt8ruxv3EhVw6t6viFKfluG4,264
8
+ sql2api/formats.py,sha256=SbLDZ-yQIt7pcWXYi8WNYEmG6JNqoeeGeRwIg5XHmos,4817
9
+ sql2api/openapi.py,sha256=O7iRvEic1L2OXAp-P3N1FXpPdpTM1w0rAEShWEcpjfk,8905
10
+ sql2api/pool.py,sha256=hIxkaVkDnjQEV_4f62yq_HtZkCEHiq_qR_fUGyHGhiA,5170
11
+ sql2api/runners.py,sha256=ANYrGluet1HOV_Fi1IvJFZMEj1CFtjKEbQzuksqLi0g,13782
12
+ sql2api/sqltools.py,sha256=HPNC2hCSkBqiJ8O-WYWj7gwILnNOtyVD5A_58SG3lik,6657
13
+ sql2api/store.py,sha256=OHLE1PU7ZrqQcPJec4jJbqNeFBDSt7TOUO8NfH7QLJg,9890
14
+ sql2api/lib/h2-2.2.224.jar,sha256=udjxk1itqCpPbrWxdMbP4yCjdbWpy1pP5FbWI-blVJc,2614933
15
+ sql2api-0.2.0.dist-info/licenses/LICENSE,sha256=ufgSbLEx8zM6J0kFgDoK-6NNb0gqrsFfCb-CnNbZGg0,1071
16
+ sql2api-0.2.0.dist-info/METADATA,sha256=YgkgbcA2O-BWhcS77nr8YzltW3YB8PO9NftPfBoEQmU,10241
17
+ sql2api-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ sql2api-0.2.0.dist-info/entry_points.txt,sha256=63gZjCA84qTkErE2iiuTiGS7GtdamvOqi2elEXwy_zE,45
19
+ sql2api-0.2.0.dist-info/top_level.txt,sha256=dicWZ6j0AE1xBDKr0-qh2CkuP7Yc1CWQf_T6r8LS6ms,8
20
+ sql2api-0.2.0.dist-info/RECORD,,
@@ -1,19 +0,0 @@
1
- sql2api/__init__.py,sha256=FeKzfwtM7UszmZ7HBQ8AQi_WNHabfDeAIpWARwhqfys,185
2
- sql2api/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
- sql2api/app.py,sha256=VwoiN9TC2a8wPbCyf3IFMuWsnjteH5scrItLKIgbOoI,11447
4
- sql2api/cli.py,sha256=1L3RYiCgnh1w_speXB4RPqxNWs5kR7kT68X9L-aqDLk,2712
5
- sql2api/config.py,sha256=goaJF810BKzyeENR-6lc1l6k1OdSaF4-3CJkpMWv4gU,2222
6
- sql2api/engine.py,sha256=b4uS0FZAWuUipr_CpAWjyVYPyzTtiDD2HDnfvI1PNP0,1551
7
- sql2api/errors.py,sha256=nJddO1gAjxQZH7-IXf3Bt8ruxv3EhVw6t6viFKfluG4,264
8
- sql2api/formats.py,sha256=SbLDZ-yQIt7pcWXYi8WNYEmG6JNqoeeGeRwIg5XHmos,4817
9
- sql2api/openapi.py,sha256=evoO6KsPN0UjvdxYR0dwHTMygAI-7FIPxhURh7i4rXY,8597
10
- sql2api/runners.py,sha256=tiP4-zNaD-bSdTycr1OBiHx00A-KESpLXXudhAhwBFw,5218
11
- sql2api/sqltools.py,sha256=HPNC2hCSkBqiJ8O-WYWj7gwILnNOtyVD5A_58SG3lik,6657
12
- sql2api/store.py,sha256=OHLE1PU7ZrqQcPJec4jJbqNeFBDSt7TOUO8NfH7QLJg,9890
13
- sql2api/lib/h2-2.2.224.jar,sha256=udjxk1itqCpPbrWxdMbP4yCjdbWpy1pP5FbWI-blVJc,2614933
14
- sql2api-0.1.0.dist-info/licenses/LICENSE,sha256=ufgSbLEx8zM6J0kFgDoK-6NNb0gqrsFfCb-CnNbZGg0,1071
15
- sql2api-0.1.0.dist-info/METADATA,sha256=xrC-ppNP7rufNkYTdi_eFDmkieHx_jEjirZOFsf8F38,9461
16
- sql2api-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
- sql2api-0.1.0.dist-info/entry_points.txt,sha256=63gZjCA84qTkErE2iiuTiGS7GtdamvOqi2elEXwy_zE,45
18
- sql2api-0.1.0.dist-info/top_level.txt,sha256=dicWZ6j0AE1xBDKr0-qh2CkuP7Yc1CWQf_T6r8LS6ms,8
19
- sql2api-0.1.0.dist-info/RECORD,,