queryapigate 0.5.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.
- queryapigate/__init__.py +6 -0
- queryapigate/__main__.py +3 -0
- queryapigate/apikeys.py +535 -0
- queryapigate/app.py +844 -0
- queryapigate/cache.py +61 -0
- queryapigate/cli.py +168 -0
- queryapigate/config.py +268 -0
- queryapigate/cors.py +51 -0
- queryapigate/engine.py +166 -0
- queryapigate/errors.py +8 -0
- queryapigate/formats.py +193 -0
- queryapigate/lib/h2-2.2.224.jar +0 -0
- queryapigate/logging_setup.py +75 -0
- queryapigate/metrics.py +215 -0
- queryapigate/openapi.py +410 -0
- queryapigate/params.py +275 -0
- queryapigate/pool.py +146 -0
- queryapigate/ratelimit.py +88 -0
- queryapigate/runners.py +795 -0
- queryapigate/schema.py +99 -0
- queryapigate/sqltools.py +184 -0
- queryapigate/store.py +433 -0
- queryapigate/ui.py +2371 -0
- queryapigate-0.5.0.dist-info/METADATA +708 -0
- queryapigate-0.5.0.dist-info/RECORD +29 -0
- queryapigate-0.5.0.dist-info/WHEEL +5 -0
- queryapigate-0.5.0.dist-info/entry_points.txt +2 -0
- queryapigate-0.5.0.dist-info/licenses/LICENSE +21 -0
- queryapigate-0.5.0.dist-info/top_level.txt +1 -0
queryapigate/schema.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Schema introspection: list a connection's tables/views and their columns.
|
|
2
|
+
|
|
3
|
+
One query per dialect against its catalogue (``information_schema`` for MySQL/PostgreSQL/H2/DuckDB,
|
|
4
|
+
``system.tables``/``system.columns`` for ClickHouse, ``sqlite_master``/``pragma_table_info`` for SQLite),
|
|
5
|
+
run through the normal execution pipeline (the SQL guard, pagination, pooling) like any other query - there
|
|
6
|
+
is no separate code path or driver logic to introspect a database. Nothing here is user-controlled, so the
|
|
7
|
+
per-dialect SQL is a plain literal string, not a bound parameter.
|
|
8
|
+
|
|
9
|
+
Drivers disagree on the case of unaliased and even aliased result column names (MySQL and H2 report
|
|
10
|
+
catalogue columns in upper case; PostgreSQL, ClickHouse and SQLite report them as written), so rows are
|
|
11
|
+
matched up case-insensitively rather than by relying on any one driver's convention.
|
|
12
|
+
"""
|
|
13
|
+
from . import engine, store
|
|
14
|
+
from .errors import ApiError
|
|
15
|
+
|
|
16
|
+
# Large enough that no real schema is ever truncated, small enough to bound one request's memory use.
|
|
17
|
+
ROW_CAP = 5000
|
|
18
|
+
|
|
19
|
+
_QUERIES = {
|
|
20
|
+
'mysql': """
|
|
21
|
+
SELECT t.table_name AS table_name, t.table_type AS table_type, c.column_name AS column_name,
|
|
22
|
+
c.data_type AS data_type, c.is_nullable AS is_nullable, c.ordinal_position AS ordinal_position
|
|
23
|
+
FROM information_schema.tables t
|
|
24
|
+
JOIN information_schema.columns c
|
|
25
|
+
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
|
|
26
|
+
WHERE t.table_schema = DATABASE()
|
|
27
|
+
ORDER BY t.table_name, c.ordinal_position
|
|
28
|
+
""",
|
|
29
|
+
'postgres': """
|
|
30
|
+
SELECT t.table_name AS table_name, t.table_type AS table_type, c.column_name AS column_name,
|
|
31
|
+
c.data_type AS data_type, c.is_nullable AS is_nullable, c.ordinal_position AS ordinal_position
|
|
32
|
+
FROM information_schema.tables t
|
|
33
|
+
JOIN information_schema.columns c
|
|
34
|
+
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
|
|
35
|
+
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
36
|
+
ORDER BY t.table_name, c.ordinal_position
|
|
37
|
+
""",
|
|
38
|
+
'clickhouse': """
|
|
39
|
+
SELECT t.name AS table_name, t.engine AS table_type, c.name AS column_name, c.type AS data_type,
|
|
40
|
+
if(startsWith(c.type, 'Nullable('), 'YES', 'NO') AS is_nullable, c.position AS ordinal_position
|
|
41
|
+
FROM system.tables t
|
|
42
|
+
JOIN system.columns c ON c.database = t.database AND c.table = t.name
|
|
43
|
+
WHERE t.database = currentDatabase()
|
|
44
|
+
ORDER BY t.name, c.position
|
|
45
|
+
""",
|
|
46
|
+
'sqlite': """
|
|
47
|
+
SELECT m.name AS table_name, m.type AS table_type, ti.name AS column_name, ti.type AS data_type,
|
|
48
|
+
CASE ti."notnull" WHEN 0 THEN 'YES' ELSE 'NO' END AS is_nullable, ti.cid + 1 AS ordinal_position
|
|
49
|
+
FROM sqlite_master m
|
|
50
|
+
JOIN pragma_table_info(m.name) ti
|
|
51
|
+
WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite_%'
|
|
52
|
+
ORDER BY m.name, ti.cid
|
|
53
|
+
""",
|
|
54
|
+
'h2': """
|
|
55
|
+
SELECT t.table_name AS table_name, t.table_type AS table_type, c.column_name AS column_name,
|
|
56
|
+
c.data_type AS data_type, c.is_nullable AS is_nullable, c.ordinal_position AS ordinal_position
|
|
57
|
+
FROM information_schema.tables t
|
|
58
|
+
JOIN information_schema.columns c
|
|
59
|
+
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
|
|
60
|
+
WHERE t.table_schema NOT IN ('INFORMATION_SCHEMA')
|
|
61
|
+
ORDER BY t.table_name, c.ordinal_position
|
|
62
|
+
""",
|
|
63
|
+
'duckdb': """
|
|
64
|
+
SELECT t.table_name AS table_name, t.table_type AS table_type, c.column_name AS column_name,
|
|
65
|
+
c.data_type AS data_type, c.is_nullable AS is_nullable, c.ordinal_position AS ordinal_position
|
|
66
|
+
FROM information_schema.tables t
|
|
67
|
+
JOIN information_schema.columns c
|
|
68
|
+
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
|
|
69
|
+
WHERE t.table_schema = 'main'
|
|
70
|
+
ORDER BY t.table_name, c.ordinal_position
|
|
71
|
+
""",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _normalize_table_type(value):
|
|
76
|
+
"""'BASE TABLE'/'table' -> table; 'VIEW'/'View'/'MaterializedView' (ClickHouse engine) -> view."""
|
|
77
|
+
return 'view' if 'view' in (value or '').lower() else 'table'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fetch_schema(connection_name):
|
|
81
|
+
"""Return {'tables': [{'name', 'type', 'columns': [{'name', 'type', 'nullable', 'position'}]}], 'truncated'}."""
|
|
82
|
+
dialect = store.get_connection(connection_name)['db']
|
|
83
|
+
if dialect not in _QUERIES:
|
|
84
|
+
raise ApiError(f"Schema introspection isn't supported for '{dialect}' connections yet")
|
|
85
|
+
result = engine.execute_sql(_QUERIES[dialect], connection_name, ROW_CAP, 0)
|
|
86
|
+
lower_columns = [str(c).lower() for c in result.columns]
|
|
87
|
+
tables = {}
|
|
88
|
+
order = []
|
|
89
|
+
for row in result.rows:
|
|
90
|
+
record = dict(zip(lower_columns, row))
|
|
91
|
+
name = record['table_name']
|
|
92
|
+
if name not in tables:
|
|
93
|
+
tables[name] = {'name': name, 'type': _normalize_table_type(record['table_type']), 'columns': []}
|
|
94
|
+
order.append(name)
|
|
95
|
+
tables[name]['columns'].append({
|
|
96
|
+
'name': record['column_name'], 'type': record['data_type'],
|
|
97
|
+
'nullable': str(record['is_nullable']).upper() == 'YES', 'position': record['ordinal_position'],
|
|
98
|
+
})
|
|
99
|
+
return {'tables': [tables[name] for name in order], 'truncated': result.has_more}
|
queryapigate/sqltools.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Pure SQL text handling: validation, pagination, and parameter substitution/binding."""
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from . import config
|
|
6
|
+
from .errors import ApiError
|
|
7
|
+
|
|
8
|
+
# Quoted strings, quoted identifiers and comments - text inside these is never inspected.
|
|
9
|
+
#
|
|
10
|
+
# Two variants exist because MySQL and ClickHouse honour a C-style backslash escape inside '...' and "..."
|
|
11
|
+
# string literals by default (\' does not close the string), while PostgreSQL, SQLite and H2 do not (under
|
|
12
|
+
# their standard/default settings, a quote only escapes via doubling, '' or ""). Using the doubling-only
|
|
13
|
+
# rule for a backslash-honouring database under-counts how long a string stays open: a value ending in an
|
|
14
|
+
# odd number of backslashes before a quote can make that database treat what we consider "outside the
|
|
15
|
+
# string" as still inside it, or vice versa, letting a semicolon or a second statement hide from this
|
|
16
|
+
# guard's semicolon check while the database executes it as separate, live SQL. This was verified against
|
|
17
|
+
# real MySQL and ClickHouse servers, see tests/test_sql_guard_fuzz.py. `--` comments also require the
|
|
18
|
+
# character after `--` to be whitespace or end of input, matching real SQL comment syntax; a bare `--x`
|
|
19
|
+
# is not a comment to any of our dialects and must stay visible to this guard.
|
|
20
|
+
_COMMENT = r'--(?=[ \t\n]|\Z)[^\n]*|/\*.*?\*/'
|
|
21
|
+
_ANSI_LITERAL = rf"'(?:[^']|'')*'|\"(?:[^\"]|\"\")*\"|`(?:[^`]|``)*`|{_COMMENT}"
|
|
22
|
+
_BACKSLASH_LITERAL = rf"'(?:[^'\\]|\\.|'')*'|\"(?:[^\"\\]|\\.|\"\")*\"|`(?:[^`]|``)*`|{_COMMENT}"
|
|
23
|
+
_ANSI_LITERALS_RE = re.compile(_ANSI_LITERAL, re.S)
|
|
24
|
+
_BACKSLASH_LITERALS_RE = re.compile(_BACKSLASH_LITERAL, re.S)
|
|
25
|
+
# Dialects whose string literals honour backslash escapes (\') by default - see the comment above.
|
|
26
|
+
BACKSLASH_ESCAPE_DIALECTS = frozenset({'mysql', 'clickhouse'})
|
|
27
|
+
# A ":name" marker, skipping literals and Postgres "::" casts. Built per dialect for the same reason.
|
|
28
|
+
_PARAM_DIALECT_LITERALS = ((None, _ANSI_LITERAL), *((d, _BACKSLASH_LITERAL) for d in BACKSLASH_ESCAPE_DIALECTS))
|
|
29
|
+
_PARAM_RE = {dialect: re.compile(rf'(?P<skip>{literal}|::)|:(?P<name>[A-Za-z_]\w*)', re.S)
|
|
30
|
+
for dialect, literal in _PARAM_DIALECT_LITERALS}
|
|
31
|
+
_BRACE_RE = re.compile(r'\{(\w+)\}')
|
|
32
|
+
_TRAILING_LIMIT_RE = re.compile(r'\s+LIMIT\s+\d+(?:\s*,\s*\d+|\s+OFFSET\s+\d+)?\s*$', re.I)
|
|
33
|
+
_SAFE_TEXT_RE = re.compile(r'^[\w\s.,:@%+/\-]*$')
|
|
34
|
+
|
|
35
|
+
READ_ONLY_STATEMENTS = ('select', 'with', 'show', 'describe', 'desc', 'explain', 'values', 'table')
|
|
36
|
+
PAGINATED_STATEMENTS = ('select', 'with')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _literals_re(dialect):
|
|
40
|
+
return _BACKSLASH_LITERALS_RE if dialect in BACKSLASH_ESCAPE_DIALECTS else _ANSI_LITERALS_RE
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _param_re(dialect):
|
|
44
|
+
return _PARAM_RE[dialect] if dialect in _PARAM_RE else _PARAM_RE[None]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def first_keyword(sql, dialect=None):
|
|
48
|
+
match = re.match(r'[\s(]*([A-Za-z]+)', _literals_re(dialect).sub(' ', sql))
|
|
49
|
+
return match.group(1).lower() if match else ''
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_paginated(sql, dialect=None):
|
|
53
|
+
"""Whether the runner should append its own LIMIT/OFFSET. Always False for a generic 'jdbc' connection:
|
|
54
|
+
that syntax is not portable across arbitrary vendors (Oracle and SQL Server use OFFSET/FETCH, not
|
|
55
|
+
LIMIT), so paging for those connections instead falls back to fetching the window and slicing it in
|
|
56
|
+
Python - correct for any vendor, just not server-side-optimal. See runners._JDBC."""
|
|
57
|
+
return dialect != 'jdbc' and first_keyword(sql, dialect) in PAGINATED_STATEMENTS
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_sql(sql, dialect=None, allow_writes=None, allowed_write_ops=None):
|
|
61
|
+
"""Normalise a client-supplied statement and enforce the single-statement/read-only rules.
|
|
62
|
+
|
|
63
|
+
``dialect`` should be the target connection's ``db`` value, so literals are read with the rules that
|
|
64
|
+
database actually applies (see the module docstring above _ANSI_LITERAL). ``allow_writes`` defaults to
|
|
65
|
+
the server-wide setting; a caller may pass an already-narrower effective value (e.g. a scoped API key
|
|
66
|
+
without write access) but never a more permissive one - see ``engine.execute_sql``.
|
|
67
|
+
|
|
68
|
+
``allowed_write_ops`` (a key's own grant - see apikeys.py's module docstring) narrows *which* write
|
|
69
|
+
statements are allowed once writes are otherwise permitted - e.g. INSERT but not DELETE/DROP on the
|
|
70
|
+
same key. It never restricts a read-only statement, only a write one; ``None`` (the default) means no
|
|
71
|
+
such narrowing, exactly today's behaviour.
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
74
|
+
raise ApiError('SQL query is missing')
|
|
75
|
+
sql = re.sub(r'[\s;]+$', '', sql.strip())
|
|
76
|
+
if ';' in _literals_re(dialect).sub(' ', sql):
|
|
77
|
+
raise ApiError('Only a single SQL statement can be executed at a time')
|
|
78
|
+
if not (config.allow_writes() if allow_writes is None else allow_writes):
|
|
79
|
+
if first_keyword(sql, dialect) not in READ_ONLY_STATEMENTS or '/*!' in sql:
|
|
80
|
+
raise ApiError('Only read-only statements (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN) are allowed. '
|
|
81
|
+
'Set QUERYAPIGATE_ALLOW_WRITES=1 to lift this restriction.', 403)
|
|
82
|
+
elif allowed_write_ops is not None:
|
|
83
|
+
keyword = first_keyword(sql, dialect)
|
|
84
|
+
if keyword not in READ_ONLY_STATEMENTS and keyword not in allowed_write_ops:
|
|
85
|
+
raise ApiError(f"This API key may only perform these write operations: "
|
|
86
|
+
f"{', '.join(sorted(allowed_write_ops))}.", 403)
|
|
87
|
+
return sql
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def paginate(sql, limit, offset):
|
|
91
|
+
"""Replace any trailing LIMIT/OFFSET on a SELECT with the requested window."""
|
|
92
|
+
sql = _TRAILING_LIMIT_RE.sub('', sql)
|
|
93
|
+
return f'{sql}\nLIMIT {int(limit)} OFFSET {int(offset)}'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------------------
|
|
97
|
+
# Parameters
|
|
98
|
+
# --------------------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _check_value(name, value):
|
|
101
|
+
if value is None or isinstance(value, (bool, str)):
|
|
102
|
+
return
|
|
103
|
+
if isinstance(value, (int, float)) and math.isfinite(value):
|
|
104
|
+
return
|
|
105
|
+
raise ApiError(f"Parameter '{name}' must be a string, finite number, boolean or null")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fill_placeholders(sql, values):
|
|
109
|
+
"""Substitute ``{name}`` text placeholders (legacy style; prefer bound ``:name`` parameters).
|
|
110
|
+
|
|
111
|
+
Because the value becomes part of the SQL text it is restricted to numbers, booleans and
|
|
112
|
+
plain text so it cannot break out of the query. Only placeholders present in the SQL are
|
|
113
|
+
validated, so the same dict can also carry values for bound parameters.
|
|
114
|
+
"""
|
|
115
|
+
for key, value in values.items():
|
|
116
|
+
token = f'{{{key}}}'
|
|
117
|
+
if token not in sql:
|
|
118
|
+
continue
|
|
119
|
+
if isinstance(value, bool):
|
|
120
|
+
text = 'TRUE' if value else 'FALSE'
|
|
121
|
+
elif isinstance(value, (int, float)):
|
|
122
|
+
if not math.isfinite(value):
|
|
123
|
+
raise ApiError(f"Placeholder '{key}' must be a finite number")
|
|
124
|
+
text = str(value)
|
|
125
|
+
elif isinstance(value, str) and _SAFE_TEXT_RE.match(value) and '--' not in value:
|
|
126
|
+
text = value
|
|
127
|
+
else:
|
|
128
|
+
raise ApiError(f"Placeholder '{key}' is substituted into the SQL text, so it must be a number, "
|
|
129
|
+
"boolean or a string containing only letters, digits, whitespace and . , : @ % + / - "
|
|
130
|
+
f"(use a bound :{key} parameter for arbitrary text)")
|
|
131
|
+
sql = sql.replace(token, text)
|
|
132
|
+
missing = sorted(set(_BRACE_RE.findall(sql)))
|
|
133
|
+
if missing:
|
|
134
|
+
raise ApiError(f"No value provided for placeholder(s): {', '.join(missing)}")
|
|
135
|
+
return sql
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def named_parameters(sql, dialect=None):
|
|
139
|
+
"""Names of the ``:name`` bound parameters used by ``sql``, in order of appearance."""
|
|
140
|
+
return [m.group('name') for m in _param_re(dialect).finditer(sql) if m.group('name')]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def placeholder_names(sql, dialect=None):
|
|
144
|
+
"""Every parameter name ``sql`` uses - bound ``:name`` first, then ``{name}`` text placeholders - once each.
|
|
145
|
+
|
|
146
|
+
app.py calls this without a dialect (bookkeeping: which declared parameters need a value, and the
|
|
147
|
+
OpenAPI catalogue) even where a connection - and so a dialect - is known, because getting the dialect
|
|
148
|
+
wrong here can only make that bookkeeping slightly inaccurate in a rare, contrived edge case; it never
|
|
149
|
+
affects what SQL is actually sent to a database. The SQL that is actually executed always goes through
|
|
150
|
+
engine.execute_sql -> validate_sql and runners.py's bind_parameters, both of which do receive the
|
|
151
|
+
connection's real dialect. See the module docstring above _ANSI_LITERAL for why the dialect matters.
|
|
152
|
+
"""
|
|
153
|
+
return list(dict.fromkeys(named_parameters(sql, dialect) + _BRACE_RE.findall(sql)))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_MARKERS = {'qmark': lambda name: '?', 'format': lambda name: '%s', 'pyformat': lambda name: f'%({name})s'}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def bind_parameters(sql, params, style, dialect=None):
|
|
160
|
+
"""Rewrite ``:name`` markers into a driver's paramstyle and return ``(sql, args)``.
|
|
161
|
+
|
|
162
|
+
``style`` is 'qmark' (``?``, positional), 'format' (``%s``, positional) or 'pyformat'
|
|
163
|
+
(``%(name)s``, mapping). Values never become part of the SQL text. Returns ``(sql, None)``
|
|
164
|
+
when the statement has no parameters, so drivers do not apply ``%`` formatting to it.
|
|
165
|
+
"""
|
|
166
|
+
matches = [m for m in _param_re(dialect).finditer(sql) if m.group('name')]
|
|
167
|
+
if not matches:
|
|
168
|
+
return sql, None
|
|
169
|
+
names = [m.group('name') for m in matches]
|
|
170
|
+
missing = sorted(set(names) - set(params))
|
|
171
|
+
if missing:
|
|
172
|
+
raise ApiError(f"No value provided for parameter(s): {', '.join(missing)}")
|
|
173
|
+
for name in set(names):
|
|
174
|
+
_check_value(name, params[name])
|
|
175
|
+
|
|
176
|
+
escape = (lambda text: text.replace('%', '%%')) if style in ('format', 'pyformat') else (lambda text: text)
|
|
177
|
+
pieces, position = [], 0
|
|
178
|
+
for match in matches:
|
|
179
|
+
pieces.append(escape(sql[position:match.start()]))
|
|
180
|
+
pieces.append(_MARKERS[style](match.group('name')))
|
|
181
|
+
position = match.end()
|
|
182
|
+
pieces.append(escape(sql[position:]))
|
|
183
|
+
args = {name: params[name] for name in names} if style == 'pyformat' else [params[name] for name in names]
|
|
184
|
+
return ''.join(pieces), args
|