jaaql-middleware-python 5.1.0__py3-none-any.whl → 5.3.1__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.
- jaaql/constants.py +9 -1
- jaaql/db/db_interface.py +3 -3
- jaaql/db/db_pg_interface.py +211 -58
- jaaql/db/db_utils_no_circ.py +2 -2
- jaaql/documentation/documentation_internal.py +34 -1
- jaaql/interpreter/interpret_jaaql.py +65 -20
- jaaql/jaaql.py +13 -0
- jaaql/mvc/base_controller.py +49 -19
- jaaql/mvc/base_model.py +38 -12
- jaaql/mvc/controller.py +39 -2
- jaaql/mvc/model.py +240 -62
- jaaql/scripts/03.install_super_user.handwritten.sql +17 -7
- jaaql/services/migrations_manager_service.py +50 -2
- {jaaql_middleware_python-5.1.0.dist-info → jaaql_middleware_python-5.3.1.dist-info}/METADATA +6 -5
- {jaaql_middleware_python-5.1.0.dist-info → jaaql_middleware_python-5.3.1.dist-info}/RECORD +18 -18
- {jaaql_middleware_python-5.1.0.dist-info → jaaql_middleware_python-5.3.1.dist-info}/WHEEL +0 -0
- {jaaql_middleware_python-5.1.0.dist-info → jaaql_middleware_python-5.3.1.dist-info}/licenses/LICENSE.txt +0 -0
- {jaaql_middleware_python-5.1.0.dist-info → jaaql_middleware_python-5.3.1.dist-info}/top_level.txt +0 -0
jaaql/constants.py
CHANGED
|
@@ -187,6 +187,13 @@ ENDPOINT__internal_applications = "/internal/applications"
|
|
|
187
187
|
ENDPOINT__internal_templates = "/internal/emails/templates"
|
|
188
188
|
ENDPOINT__internal_accounts = "/internal/emails/accounts"
|
|
189
189
|
ENDPOINT__is_alive = "/internal/is-alive"
|
|
190
|
+
ENDPOINT__deep_health = "/internal/deep-health"
|
|
191
|
+
|
|
192
|
+
# Synthetic query the BATON microcompiler injects into every queries.json so
|
|
193
|
+
# deep_health() can resolve a query through the cache. Must match the microcompiler
|
|
194
|
+
# constant HEALTH_QUERY_CACHE_KEY (index is always 0).
|
|
195
|
+
QUERY_CACHE_KEY__health = "__health__"
|
|
196
|
+
QUERY_CACHE_REF__health = QUERY_CACHE_KEY__health + ":0"
|
|
190
197
|
ENDPOINT__report_sentinel_error = "/sentinel/reporting/error"
|
|
191
198
|
ENDPOINT__install = "/internal/install"
|
|
192
199
|
ENDPOINT__set_shared_var = "/set-shared-var"
|
|
@@ -207,8 +214,9 @@ USERNAME__anonymous = "anonymous"
|
|
|
207
214
|
PASSWORD__anonymous = "jaaql_public_password"
|
|
208
215
|
ROLE__jaaql = "jaaql"
|
|
209
216
|
ROLE__postgres = "postgres"
|
|
217
|
+
ROLE__dba = "dba"
|
|
210
218
|
|
|
211
219
|
PROTOCOL__postgres = "postgresql://"
|
|
212
220
|
|
|
213
|
-
VERSION = "5.1
|
|
221
|
+
VERSION = "5.3.1"
|
|
214
222
|
|
jaaql/db/db_interface.py
CHANGED
|
@@ -125,7 +125,7 @@ class DBInterface(ABC):
|
|
|
125
125
|
pass
|
|
126
126
|
|
|
127
127
|
def execute_query_fetching_results(self, conn, query, parameters=None, echo=ECHO__none, as_objects=False, wait_hook: queue.Queue = None,
|
|
128
|
-
requires_dba_check: bool = False):
|
|
128
|
+
requires_dba_check: bool = False, prepare: bool = False):
|
|
129
129
|
if echo not in ECHO__allowed:
|
|
130
130
|
allowed_echoes = ", ".join([str(allowed_echo) for allowed_echo in ECHO__allowed])
|
|
131
131
|
raise HttpStatusException(ERR__unknown_echo % (str(echo), allowed_echoes), HTTPStatus.BAD_REQUEST)
|
|
@@ -149,7 +149,7 @@ class DBInterface(ABC):
|
|
|
149
149
|
if wait_hook:
|
|
150
150
|
wait_hook = None
|
|
151
151
|
|
|
152
|
-
columns, type_codes, rows = self.execute_query(conn, query, new_parameters, wait_hook)
|
|
152
|
+
columns, type_codes, rows = self.execute_query(conn, query, new_parameters, wait_hook, prepare=prepare)
|
|
153
153
|
|
|
154
154
|
ret = {
|
|
155
155
|
RET__columns: columns,
|
|
@@ -225,7 +225,7 @@ class DBInterface(ABC):
|
|
|
225
225
|
pass
|
|
226
226
|
|
|
227
227
|
@abstractmethod
|
|
228
|
-
def execute_query(self, conn, query, parameters: Optional[dict] = None, wait_hook: queue.Queue = None):
|
|
228
|
+
def execute_query(self, conn, query, parameters: Optional[dict] = None, wait_hook: queue.Queue = None, prepare: bool = False):
|
|
229
229
|
pass
|
|
230
230
|
|
|
231
231
|
@abstractmethod
|
jaaql/db/db_pg_interface.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import uuid
|
|
2
2
|
|
|
3
|
+
import psycopg
|
|
3
4
|
from psycopg import OperationalError
|
|
4
5
|
from psycopg_pool import ConnectionPool, PoolClosed
|
|
5
6
|
import queue
|
|
@@ -27,6 +28,99 @@ ERR__must_use_canned_query = "Must use canned query as you are not an admin!"
|
|
|
27
28
|
QUERY__dba_query = "SELECT pg_has_role(datdba::regrole, 'MEMBER') FROM pg_database WHERE datname = %(database)s;"
|
|
28
29
|
QUERY__dba_query_external = "SELECT pg_has_role(datdba::regrole, 'MEMBER') FROM pg_database WHERE datname = current_database();"
|
|
29
30
|
|
|
31
|
+
try:
|
|
32
|
+
# Pipeline mode needs libpq >= 14. create_app refuses to boot when this is False so a server
|
|
33
|
+
# can never silently degrade; the sequential fallback in execute_query exists only for
|
|
34
|
+
# non-server tooling that imports this module directly
|
|
35
|
+
PIPELINE_SUPPORTED = psycopg.Pipeline.is_supported()
|
|
36
|
+
except Exception:
|
|
37
|
+
PIPELINE_SUPPORTED = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class JaaqlPGConnection(psycopg.Connection):
|
|
41
|
+
"""
|
|
42
|
+
Connection whose per-checkout session-authorization statements (jaaql__set_session_authorization
|
|
43
|
+
/ SET ROLE) are deferred at checkout and sent pipelined with the first real query, saving a
|
|
44
|
+
network round trip per checkout.
|
|
45
|
+
|
|
46
|
+
Safety property: authorization must never be skippable. Any cursor obtained through the normal
|
|
47
|
+
cursor() call while statements are still pending executes them eagerly first, so raw connection
|
|
48
|
+
usage cannot run as the pool user. execute_query claims the statements via
|
|
49
|
+
jaaql_take_pending_auth and is then responsible for sending them before (or batched with) the
|
|
50
|
+
query it executes.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_jaaql_pending_auth = None
|
|
54
|
+
_jaaql_flushing = False
|
|
55
|
+
|
|
56
|
+
def jaaql_set_pending_auth(self, statements: list):
|
|
57
|
+
self._jaaql_pending_auth = statements
|
|
58
|
+
|
|
59
|
+
def jaaql_has_pending_auth(self) -> bool:
|
|
60
|
+
return self._jaaql_pending_auth is not None
|
|
61
|
+
|
|
62
|
+
def jaaql_take_pending_auth(self):
|
|
63
|
+
pending, self._jaaql_pending_auth = self._jaaql_pending_auth, None
|
|
64
|
+
return pending
|
|
65
|
+
|
|
66
|
+
def jaaql_raw_cursor(self, *args, **kwargs):
|
|
67
|
+
# Bypasses the pending-authorization flush; the caller has taken responsibility for
|
|
68
|
+
# executing the pending statements itself
|
|
69
|
+
return super().cursor(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
def cursor(self, *args, **kwargs):
|
|
72
|
+
if self._jaaql_pending_auth is not None and not self._jaaql_flushing:
|
|
73
|
+
self._jaaql_flushing = True
|
|
74
|
+
try:
|
|
75
|
+
pending = self.jaaql_take_pending_auth()
|
|
76
|
+
with super().cursor() as flush_cursor:
|
|
77
|
+
for statement in pending:
|
|
78
|
+
flush_cursor.execute(statement)
|
|
79
|
+
finally:
|
|
80
|
+
self._jaaql_flushing = False
|
|
81
|
+
return super().cursor(*args, **kwargs)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _statement_is_preparable(query: str) -> bool:
|
|
85
|
+
# Server-side PREPARE accepts a single statement only. A ';' anywhere except trailing means the
|
|
86
|
+
# text may hold multiple statements (a ';' inside a string literal merely skips the
|
|
87
|
+
# optimisation, which is the safe direction)
|
|
88
|
+
return ";" not in query.rstrip().rstrip(";")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _escape_unescaped_percent(query: str) -> str:
|
|
92
|
+
# psycopg3 scans every '%' in the SQL when parameters are supplied and
|
|
93
|
+
# rejects anything that isn't a placeholder or '%%'. JAAQL uses named
|
|
94
|
+
# parameters only (:parameter -> %(name)s), so positional %s/%b/%t must
|
|
95
|
+
# never survive: any '%s' in author SQL is a literal inside a string
|
|
96
|
+
# (LIKE '%saas%', LIKE '%twynstra%', etc.), not a placeholder. Escape
|
|
97
|
+
# everything except '%%' and '%(name)X'.
|
|
98
|
+
out = []
|
|
99
|
+
i = 0
|
|
100
|
+
n = len(query)
|
|
101
|
+
while i < n:
|
|
102
|
+
if query[i] != '%':
|
|
103
|
+
out.append(query[i])
|
|
104
|
+
i += 1
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
nxt = query[i + 1] if i + 1 < n else ''
|
|
108
|
+
if nxt == '%':
|
|
109
|
+
out.append('%%')
|
|
110
|
+
i += 2
|
|
111
|
+
elif nxt == '(':
|
|
112
|
+
close = query.find(')', i + 2)
|
|
113
|
+
if close == -1 or close + 1 >= n:
|
|
114
|
+
out.append('%%')
|
|
115
|
+
i += 1
|
|
116
|
+
else:
|
|
117
|
+
out.append(query[i:close + 2])
|
|
118
|
+
i = close + 2
|
|
119
|
+
else:
|
|
120
|
+
out.append('%%')
|
|
121
|
+
i += 1
|
|
122
|
+
return ''.join(out)
|
|
123
|
+
|
|
30
124
|
|
|
31
125
|
class DBPGInterface(DBInterface):
|
|
32
126
|
|
|
@@ -41,25 +135,35 @@ class DBPGInterface(DBInterface):
|
|
|
41
135
|
DBPGInterface.HOST_POOLS = {}
|
|
42
136
|
DBPGInterface.HOST_POOLS_QUEUES = {}
|
|
43
137
|
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _process_returned_conn(username: str, db_name: str, conn, do_reset: bool):
|
|
140
|
+
if isinstance(conn, JaaqlPGConnection) and conn.jaaql_has_pending_auth():
|
|
141
|
+
# The deferred authorization statements were never sent, so the session still runs as
|
|
142
|
+
# the pool user and there is nothing to reset. Clearing them here also stops the
|
|
143
|
+
# cursor() below from flushing them
|
|
144
|
+
conn.jaaql_take_pending_auth()
|
|
145
|
+
do_reset = False
|
|
146
|
+
if do_reset:
|
|
147
|
+
with conn.cursor() as cursor:
|
|
148
|
+
cursor.execute("RESET ROLE;")
|
|
149
|
+
did_close = False
|
|
150
|
+
if hasattr(conn, "jaaql_reset_key"):
|
|
151
|
+
try:
|
|
152
|
+
cursor.execute("SELECT jaaql_extension.jaaql__reset_session_authorization('" + str(conn.jaaql_reset_key) + "');")
|
|
153
|
+
except:
|
|
154
|
+
did_close = True
|
|
155
|
+
conn.close()
|
|
156
|
+
if not did_close:
|
|
157
|
+
cursor.execute("RESET ALL;")
|
|
158
|
+
conn.commit()
|
|
159
|
+
DBPGInterface.HOST_POOLS[username][db_name].putconn(conn)
|
|
160
|
+
|
|
44
161
|
@staticmethod
|
|
45
162
|
def put_conn_threaded(username: str, db_name: str, the_queue: queue.Queue):
|
|
46
163
|
while True:
|
|
47
164
|
try:
|
|
48
165
|
conn, do_reset = the_queue.get()
|
|
49
|
-
|
|
50
|
-
with conn.cursor() as cursor:
|
|
51
|
-
cursor.execute("RESET ROLE;")
|
|
52
|
-
did_close = False
|
|
53
|
-
if hasattr(conn, "jaaql_reset_key"):
|
|
54
|
-
try:
|
|
55
|
-
cursor.execute("SELECT jaaql_extension.jaaql__reset_session_authorization('" + str(conn.jaaql_reset_key) + "');")
|
|
56
|
-
except:
|
|
57
|
-
did_close = True
|
|
58
|
-
conn.close()
|
|
59
|
-
if not did_close:
|
|
60
|
-
cursor.execute("RESET ALL;")
|
|
61
|
-
conn.commit()
|
|
62
|
-
DBPGInterface.HOST_POOLS[username][db_name].putconn(conn)
|
|
166
|
+
DBPGInterface._process_returned_conn(username, db_name, conn, do_reset)
|
|
63
167
|
except Exception:
|
|
64
168
|
pass # Ignore, pool has likely been wiped
|
|
65
169
|
|
|
@@ -98,7 +202,8 @@ class DBPGInterface(DBInterface):
|
|
|
98
202
|
self.conn_str = conn_str
|
|
99
203
|
|
|
100
204
|
if self.db_name not in user_pool:
|
|
101
|
-
the_pool = ConnectionPool(conn_str, min_size=PGCONN__min_conns, max_size=PGCONN__max_conns, max_lifetime=60 * 30
|
|
205
|
+
the_pool = ConnectionPool(conn_str, min_size=PGCONN__min_conns, max_size=PGCONN__max_conns, max_lifetime=60 * 30,
|
|
206
|
+
connection_class=JaaqlPGConnection)
|
|
102
207
|
the_pool.getconn(timeout=TIMEOUT)
|
|
103
208
|
user_pool[self.db_name] = the_pool
|
|
104
209
|
the_queue = queue.Queue()
|
|
@@ -113,49 +218,33 @@ class DBPGInterface(DBInterface):
|
|
|
113
218
|
else:
|
|
114
219
|
raise HttpStatusException(str(ex))
|
|
115
220
|
|
|
116
|
-
def _get_conn(self
|
|
221
|
+
def _get_conn(self):
|
|
117
222
|
conn = DBPGInterface.HOST_POOLS[self.username][self.db_name].getconn(timeout=TIMEOUT)
|
|
118
223
|
conn.jaaql_reset_key = str(uuid.uuid4())
|
|
224
|
+
if isinstance(conn, JaaqlPGConnection):
|
|
225
|
+
# Discard any pending authorization left by a previous checkout that was returned
|
|
226
|
+
# without executing (defensive: every return path should already have cleared it).
|
|
227
|
+
# Without this a later checkout could execute a previous user's authorization
|
|
228
|
+
conn.jaaql_take_pending_auth()
|
|
119
229
|
if self.role is not None or self.sub_role is not None:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
except UndefinedFunction:
|
|
132
|
-
raise HttpStatusException("Database '%s' has not been configured for usage with JAAQL. Please ask the dba to run 'configure_database_for_use_with_jaaql' from the jaaql database" % self.db_name)
|
|
133
|
-
if self.sub_role is not None:
|
|
134
|
-
cursor.execute("SET ROLE \"" + self.sub_role + "\"")
|
|
135
|
-
except InvalidParameterValue:
|
|
136
|
-
raise HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
|
|
137
|
-
except OperationalError as ex:
|
|
138
|
-
if allow_operational:
|
|
139
|
-
DBPGInterface.HOST_POOLS[self.username][self.db_name].close()
|
|
140
|
-
DBPGInterface.HOST_POOLS[self.username][self.db_name] = ConnectionPool(self.conn_str, min_size=PGCONN__min_conns,
|
|
141
|
-
max_size=PGCONN__max_conns, max_lifetime=60 * 30)
|
|
142
|
-
try:
|
|
143
|
-
DBPGInterface.HOST_POOLS[self.username][self.db_name].getconn(timeout=TIMEOUT)
|
|
144
|
-
except OperationalError:
|
|
145
|
-
self.close()
|
|
146
|
-
raise HttpStatusException("Database \"" + self.db_name + "\" does not exist",
|
|
147
|
-
CustomHTTPStatus.DATABASE_NO_EXIST)
|
|
148
|
-
|
|
149
|
-
conn = self._get_conn(False)
|
|
150
|
-
else:
|
|
151
|
-
raise ex
|
|
152
|
-
|
|
230
|
+
# Deferred: sent pipelined with the first query by execute_query, or flushed eagerly by
|
|
231
|
+
# JaaqlPGConnection.cursor() if the connection is used rawly. Errors these statements
|
|
232
|
+
# raise are translated by _translate_session_auth_error at execution time; dead pool
|
|
233
|
+
# connections, previously detected here, are recycled by execute_query's
|
|
234
|
+
# OperationalError retry loop
|
|
235
|
+
pending = []
|
|
236
|
+
if self.role is not None:
|
|
237
|
+
pending.append("SELECT jaaql_extension.jaaql__set_session_authorization('" + self.role + "', '" + conn.jaaql_reset_key + "');")
|
|
238
|
+
if self.sub_role is not None:
|
|
239
|
+
pending.append("SET ROLE \"" + self.sub_role + "\"")
|
|
240
|
+
conn.jaaql_set_pending_auth(pending)
|
|
153
241
|
return conn
|
|
154
242
|
|
|
155
243
|
def get_pool(self):
|
|
156
244
|
if self.db_name not in DBPGInterface.HOST_POOLS[self.username]:
|
|
157
245
|
DBPGInterface.HOST_POOLS[self.username][self.db_name] = ConnectionPool(self.conn_str, min_size=PGCONN__min_conns,
|
|
158
|
-
max_size=PGCONN__max_conns, max_lifetime=60 * 30
|
|
246
|
+
max_size=PGCONN__max_conns, max_lifetime=60 * 30,
|
|
247
|
+
connection_class=JaaqlPGConnection)
|
|
159
248
|
return DBPGInterface.HOST_POOLS[self.username][self.db_name]
|
|
160
249
|
|
|
161
250
|
def get_conn(self, retry_closed_pool: bool = True):
|
|
@@ -165,7 +254,8 @@ class DBPGInterface(DBInterface):
|
|
|
165
254
|
# Jaaql has likely been reinstalled, this pool has been forcibly closed
|
|
166
255
|
if retry_closed_pool:
|
|
167
256
|
DBPGInterface.HOST_POOLS[self.username][self.db_name] = ConnectionPool(self.conn_str, min_size=PGCONN__min_conns,
|
|
168
|
-
max_size=PGCONN__max_conns, max_lifetime=60 * 30
|
|
257
|
+
max_size=PGCONN__max_conns, max_lifetime=60 * 30,
|
|
258
|
+
connection_class=JaaqlPGConnection)
|
|
169
259
|
conn = self.get_conn(retry_closed_pool=False)
|
|
170
260
|
if not conn:
|
|
171
261
|
raise ex
|
|
@@ -187,18 +277,36 @@ class DBPGInterface(DBInterface):
|
|
|
187
277
|
DBPGInterface.HOST_POOLS_QUEUES[self.username].pop(self.db_name)
|
|
188
278
|
|
|
189
279
|
def check_dba(self, conn, wait_hook: queue.Queue = None):
|
|
190
|
-
columns, _, rows = self.execute_query(conn, QUERY__dba_query, parameters={KEY__database: self.db_name}, wait_hook=wait_hook)
|
|
280
|
+
columns, _, rows = self.execute_query(conn, QUERY__dba_query, parameters={KEY__database: self.db_name}, wait_hook=wait_hook, prepare=True)
|
|
191
281
|
if not rows[0]:
|
|
192
282
|
raise HttpStatusException(ERR__must_use_canned_query)
|
|
193
283
|
|
|
194
|
-
def
|
|
284
|
+
def _translate_session_auth_error(self, ex):
|
|
285
|
+
"""
|
|
286
|
+
The deferred session-authorization statements execute batched with the first query, so
|
|
287
|
+
their errors surface during execute_query rather than at checkout. This translates them to
|
|
288
|
+
the same exceptions the eager checkout path used to raise. Returns None when the error is
|
|
289
|
+
not recognisably from the authorization statements (the caller re-raises it untouched, so
|
|
290
|
+
a query's own error keeps its normal handling)
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(ex, InternalError):
|
|
293
|
+
if str(ex).startswith("role \"") and str(ex).endswith("\" does not exist"):
|
|
294
|
+
return HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
|
|
295
|
+
if isinstance(ex, UndefinedFunction) and "jaaql__set_session_authorization" in str(ex):
|
|
296
|
+
return HttpStatusException("Database '%s' has not been configured for usage with JAAQL. Please ask the dba to run 'configure_database_for_use_with_jaaql' from the jaaql database" % self.db_name)
|
|
297
|
+
if isinstance(ex, InvalidParameterValue) and "role" in str(ex).lower():
|
|
298
|
+
return HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def execute_query(self, conn, query, parameters=None, wait_hook: queue.Queue = None, prepare: bool = False):
|
|
195
302
|
x = 0
|
|
196
303
|
err = None
|
|
197
304
|
while x < PGCONN__max_conns:
|
|
198
305
|
x += 1
|
|
199
306
|
try:
|
|
200
|
-
|
|
201
|
-
|
|
307
|
+
cursor_factory = conn.jaaql_raw_cursor if isinstance(conn, JaaqlPGConnection) else conn.cursor
|
|
308
|
+
with cursor_factory() as cursor:
|
|
309
|
+
do_prepare = prepare and _statement_is_preparable(query)
|
|
202
310
|
|
|
203
311
|
if wait_hook:
|
|
204
312
|
res, err, code = wait_hook.get()
|
|
@@ -207,16 +315,61 @@ class DBPGInterface(DBInterface):
|
|
|
207
315
|
raise Exception(err)
|
|
208
316
|
raise UserUnauthorized()
|
|
209
317
|
|
|
210
|
-
|
|
211
|
-
|
|
318
|
+
def execute_main_query():
|
|
319
|
+
if parameters is None or len(parameters.keys()) == 0:
|
|
320
|
+
cursor.execute(query, prepare=do_prepare)
|
|
321
|
+
else:
|
|
322
|
+
cursor.execute(_escape_unescaped_percent(query), parameters, prepare=do_prepare)
|
|
323
|
+
|
|
324
|
+
# Claimed only now, after the wait_hook: if verification rejects the request,
|
|
325
|
+
# the statements remain pending and the putback thread discards them unsent
|
|
326
|
+
pending_auth = conn.jaaql_take_pending_auth() if isinstance(conn, JaaqlPGConnection) else None
|
|
327
|
+
|
|
328
|
+
if pending_auth:
|
|
329
|
+
auth_cursor = conn.jaaql_raw_cursor()
|
|
330
|
+
try:
|
|
331
|
+
# Pipeline mode forces the extended protocol on every execute, and the
|
|
332
|
+
# extended protocol rejects a multi-command string ("cannot insert
|
|
333
|
+
# multiple commands into a prepared statement"). Only pipeline
|
|
334
|
+
# single-command queries; a multi-command query (install scripts,
|
|
335
|
+
# interpreted/canned SQL run during a bump) falls back to sequential
|
|
336
|
+
# execution, where the main query runs under the simple protocol that
|
|
337
|
+
# permits several commands.
|
|
338
|
+
if PIPELINE_SUPPORTED and _statement_is_preparable(query):
|
|
339
|
+
with conn.pipeline():
|
|
340
|
+
for statement in pending_auth:
|
|
341
|
+
auth_cursor.execute(statement)
|
|
342
|
+
execute_main_query()
|
|
343
|
+
else:
|
|
344
|
+
for statement in pending_auth:
|
|
345
|
+
auth_cursor.execute(statement)
|
|
346
|
+
execute_main_query()
|
|
347
|
+
except (InternalError, UndefinedFunction, InvalidParameterValue) as ex:
|
|
348
|
+
translated = self._translate_session_auth_error(ex)
|
|
349
|
+
if translated is not None:
|
|
350
|
+
raise translated
|
|
351
|
+
if isinstance(ex, InternalError):
|
|
352
|
+
traceback.print_exc()
|
|
353
|
+
raise ex
|
|
354
|
+
finally:
|
|
355
|
+
try:
|
|
356
|
+
auth_cursor.close()
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
212
359
|
else:
|
|
213
|
-
|
|
360
|
+
execute_main_query()
|
|
361
|
+
|
|
214
362
|
if cursor.description is None:
|
|
215
363
|
return [], [], []
|
|
216
364
|
else:
|
|
217
365
|
return [desc[0] for desc in cursor.description], [desc.type_code for desc in cursor.description], cursor.fetchall()
|
|
218
366
|
except OperationalError as ex:
|
|
219
367
|
if ex.sqlstate is None or ex.sqlstate.startswith("08") or ex.sqlstate.startswith("57"):
|
|
368
|
+
if isinstance(conn, JaaqlPGConnection):
|
|
369
|
+
# This putconn bypasses the reset queue, so make sure no unsent
|
|
370
|
+
# authorization statements ride back into the pool where check() or a
|
|
371
|
+
# later checkout could flush them as the wrong user
|
|
372
|
+
conn.jaaql_take_pending_auth()
|
|
220
373
|
DBPGInterface.HOST_POOLS[self.username][self.db_name].putconn(conn)
|
|
221
374
|
DBPGInterface.HOST_POOLS[self.username][self.db_name].check()
|
|
222
375
|
conn = self.get_conn()
|
jaaql/db/db_utils_no_circ.py
CHANGED
|
@@ -68,7 +68,7 @@ def get_required_db(vault, config, jaaql_connection: DBInterface, inputs: dict,
|
|
|
68
68
|
|
|
69
69
|
def submit(vault, config, db_crypt_key, jaaql_connection: DBInterface, inputs: dict, account_id: str, verification_hook: Queue = None,
|
|
70
70
|
cached_canned_query_service=None, as_objects: bool = False, singleton: bool = False, keep_alive_conn: bool = False,
|
|
71
|
-
conn=None, interface: DBInterface = None, db_cache=None):
|
|
71
|
+
conn=None, interface: DBInterface = None, db_cache=None, prepare_statements: bool = False):
|
|
72
72
|
if not isinstance(inputs, dict):
|
|
73
73
|
raise HttpStatusException("Expected object or string input")
|
|
74
74
|
|
|
@@ -80,7 +80,7 @@ def submit(vault, config, db_crypt_key, jaaql_connection: DBInterface, inputs: d
|
|
|
80
80
|
).transform(inputs, skip_commit=inputs.get(KEY__read_only), wait_hook=verification_hook,
|
|
81
81
|
encryption_key=db_crypt_key, conn=conn,
|
|
82
82
|
canned_query_service=cached_canned_query_service, prevent_unused_parameters=prevent_unused,
|
|
83
|
-
and_return_connection_mid_transaction=keep_alive_conn)
|
|
83
|
+
and_return_connection_mid_transaction=keep_alive_conn, prepare_statements=prepare_statements)
|
|
84
84
|
|
|
85
85
|
if as_objects:
|
|
86
86
|
ret = objectify(ret, singleton=singleton)
|
|
@@ -108,6 +108,17 @@ DOCUMENTATION__is_alive = SwaggerDocumentation(
|
|
|
108
108
|
)
|
|
109
109
|
)
|
|
110
110
|
|
|
111
|
+
DOCUMENTATION__deep_health = SwaggerDocumentation(
|
|
112
|
+
security=False,
|
|
113
|
+
tags="Deep Health",
|
|
114
|
+
methods=SwaggerMethod(
|
|
115
|
+
name="Deep health check",
|
|
116
|
+
description="Resolves a query through the compiled query cache and runs it, so a stale, partial or "
|
|
117
|
+
"unloaded cache fails the check (unlike is-alive, which only pings the database)",
|
|
118
|
+
method=REST__GET
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
111
122
|
DOCUMENTATION__clean = SwaggerDocumentation(
|
|
112
123
|
tags="Installation",
|
|
113
124
|
methods=SwaggerMethod(
|
|
@@ -605,4 +616,26 @@ DOCUMENTATION__security_event = SwaggerDocumentation(
|
|
|
605
616
|
],
|
|
606
617
|
response=RES__allow_all
|
|
607
618
|
)
|
|
608
|
-
)
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
DOCUMENTATION__report_sentinel_error = SwaggerDocumentation(
|
|
622
|
+
tags="Reporting",
|
|
623
|
+
security=False, # PUBLIC: also receives self-posts from the in-core sentinel reporter (SENTINEL_URL=_)
|
|
624
|
+
methods=SwaggerMethod(
|
|
625
|
+
name="Report error",
|
|
626
|
+
description="Logs an application error (user_agent + ip_address encrypted at rest) and triggers Sentinel "
|
|
627
|
+
"alert processing. Never returns 500 (would otherwise make JAAQL report itself recursively).",
|
|
628
|
+
method=REST__POST,
|
|
629
|
+
body=[
|
|
630
|
+
SwaggerArgumentResponse(name="location", description="URL/location where the error occurred", arg_type=str, example=["https://app/index.html"]),
|
|
631
|
+
SwaggerArgumentResponse(name="source_file", description="Source file the error occurred in", arg_type=str, example=["common.js"]),
|
|
632
|
+
SwaggerArgumentResponse(name="error_condensed", description="Condensed error (truncated to 200 chars for grouping)", arg_type=str, example=["Uncaught TypeError"]),
|
|
633
|
+
SwaggerArgumentResponse(name="stacktrace", description="Full stacktrace", arg_type=str, example=["at <anonymous>:1:16"]),
|
|
634
|
+
SwaggerArgumentResponse(name="version", description="Product version", arg_type=str, example=["1.2.3"]),
|
|
635
|
+
SwaggerArgumentResponse(name="source_system", description="Originating system/application", arg_type=str, example=["JAAQL"]),
|
|
636
|
+
SwaggerArgumentResponse(name="file_line_number", description="Line number", arg_type=int, required=False, condition="If available", example=[123]),
|
|
637
|
+
SwaggerArgumentResponse(name="file_col_number", description="Column number", arg_type=int, required=False, condition="If available", example=[21]),
|
|
638
|
+
SwaggerArgumentResponse(name="user_agent", description="Browser user agent (encrypted at rest). Absent on internal self-posts.", arg_type=str, required=False, condition="If present", example=["Mozilla/5.0"]),
|
|
639
|
+
]
|
|
640
|
+
)
|
|
641
|
+
)
|
|
@@ -18,6 +18,7 @@ from jaaql.constants import KEY__position, KEY__file, KEY__application, KEY__err
|
|
|
18
18
|
from jaaql.exceptions.jaaql_interpretable_handled_errors import DatabaseOperationalError, HandledProcedureError, UnhandledQueryError, UnhandledProcedureError, \
|
|
19
19
|
SingletonExpected
|
|
20
20
|
from typing import Union
|
|
21
|
+
from functools import lru_cache
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
ERR_malformed_statement = "Malformed query, expecting string or dictionary"
|
|
@@ -112,6 +113,42 @@ class MarkerPrepare:
|
|
|
112
113
|
self.count = count
|
|
113
114
|
|
|
114
115
|
|
|
116
|
+
# Statements larger than this are tokenized on every call rather than cached, bounding the
|
|
117
|
+
# memory the cache can hold (canned/compiled queries are far below this)
|
|
118
|
+
TOKEN_CACHE__max_statement_len = 16384
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _tokenize_statement_uncached(query: str, match_regex: str):
|
|
122
|
+
literals = []
|
|
123
|
+
names = []
|
|
124
|
+
last_index = 0
|
|
125
|
+
for match in re.finditer(match_regex, query):
|
|
126
|
+
start_pos = match.regs[0][0]
|
|
127
|
+
if start_pos != 0 and query[start_pos - 1] == REGEX_query_argument[0]:
|
|
128
|
+
continue
|
|
129
|
+
end_pos = match.regs[0][1]
|
|
130
|
+
literals.append(query[last_index:start_pos])
|
|
131
|
+
names.append(query[start_pos + 1:end_pos])
|
|
132
|
+
last_index = end_pos
|
|
133
|
+
literals.append(query[last_index:])
|
|
134
|
+
return tuple(literals), tuple(names)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_tokenize_statement_cached = lru_cache(maxsize=1024)(_tokenize_statement_uncached)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def tokenize_statement(query: str, match_regex: str):
|
|
141
|
+
"""
|
|
142
|
+
Splits a statement into literal SQL fragments and the argument names found between them, so the
|
|
143
|
+
(backtracking-heavy) not-in-quotes argument regex runs once per distinct statement text instead
|
|
144
|
+
of on every request. The skip of matches directly preceded by ':' (postgres '::' casts) is part
|
|
145
|
+
of tokenization, mirroring the previous inline check in pre_prepare_statement.
|
|
146
|
+
"""
|
|
147
|
+
if len(query) <= TOKEN_CACHE__max_statement_len:
|
|
148
|
+
return _tokenize_statement_cached(query, match_regex)
|
|
149
|
+
return _tokenize_statement_uncached(query, match_regex)
|
|
150
|
+
|
|
151
|
+
|
|
115
152
|
class InterpretJAAQL:
|
|
116
153
|
|
|
117
154
|
def __init__(self, db_interface: DBInterface, jaaql_db_interface: DBInterface = None):
|
|
@@ -121,7 +158,7 @@ class InterpretJAAQL:
|
|
|
121
158
|
def transform(self, operation: Union[dict, str], conn=None, skip_commit: bool = False, wait_hook: queue.Queue = None,
|
|
122
159
|
encryption_key: bytes = None, autocommit: bool = False, canned_query_service=None, prevent_unused_parameters: bool = True,
|
|
123
160
|
do_prepare_only: str = False, and_return_connection_mid_transaction: bool = False, attempt_fetch_domain_types: bool = False,
|
|
124
|
-
psql: list = None, pre_psql: str = None):
|
|
161
|
+
psql: list = None, pre_psql: str = None, prepare_statements: bool = False):
|
|
125
162
|
if (not isinstance(operation, dict)) and (not isinstance(operation, str)):
|
|
126
163
|
raise HttpStatusException(ERR_malformed_operation_type, HTTPStatus.BAD_REQUEST)
|
|
127
164
|
|
|
@@ -143,6 +180,9 @@ class InterpretJAAQL:
|
|
|
143
180
|
ret = {}
|
|
144
181
|
err = None
|
|
145
182
|
query_key = None
|
|
183
|
+
# Keys whose statements are server-authored (canned) and single: safe and worthwhile to
|
|
184
|
+
# execute as postgres prepared statements so parse/plan is skipped on hot connections
|
|
185
|
+
canned_keys = set()
|
|
146
186
|
|
|
147
187
|
if autocommit:
|
|
148
188
|
conn.autocommit = autocommit
|
|
@@ -174,6 +214,7 @@ class InterpretJAAQL:
|
|
|
174
214
|
query = {"query": {KEY_query: canned_query, KEY_assert: operation.get(KEY_assert), KEY_decrypt: operation.get(KEY_decrypt),
|
|
175
215
|
KEY_parameters: {}}}
|
|
176
216
|
check_required = False
|
|
217
|
+
canned_keys.add("query")
|
|
177
218
|
else:
|
|
178
219
|
is_dict_query = isinstance(query, dict)
|
|
179
220
|
if not is_dict_query:
|
|
@@ -187,15 +228,19 @@ class InterpretJAAQL:
|
|
|
187
228
|
query[key] = {KEY_query: val, KEY_assert: None, KEY_decrypt: None, KEY_parameters: {}}
|
|
188
229
|
else:
|
|
189
230
|
if KEY_store in val:
|
|
231
|
+
store_all_canned = True
|
|
190
232
|
for sub_key, sub_val in val.items():
|
|
191
233
|
if sub_key in [KEY_parameters, KEY_decrypt, KEY_store]:
|
|
192
234
|
continue
|
|
193
235
|
if isinstance(sub_val, str):
|
|
194
236
|
all_replaced = False
|
|
237
|
+
store_all_canned = False
|
|
195
238
|
else:
|
|
196
239
|
canned_query = canned_query_service.get_canned_query(operation[KEY__application],
|
|
197
240
|
sub_val[KEY__file], sub_val[KEY__position])
|
|
198
241
|
val[sub_key] = canned_query
|
|
242
|
+
if store_all_canned:
|
|
243
|
+
canned_keys.add(key)
|
|
199
244
|
if KEY_parameters not in val:
|
|
200
245
|
val[KEY_parameters] = {}
|
|
201
246
|
if KEY_decrypt not in val:
|
|
@@ -208,6 +253,7 @@ class InterpretJAAQL:
|
|
|
208
253
|
else:
|
|
209
254
|
fetched_query = canned_query_service.get_canned_query(operation[KEY__application],
|
|
210
255
|
val[KEY_query][KEY__file], val[KEY_query][KEY__position])
|
|
256
|
+
canned_keys.add(key)
|
|
211
257
|
query[key] = {KEY_query: fetched_query, KEY_assert: val.get(KEY_assert), KEY_decrypt: val.get(KEY_decrypt),
|
|
212
258
|
KEY_parameters: val.get(KEY_parameters, {})}
|
|
213
259
|
check_required = not all_replaced
|
|
@@ -465,7 +511,8 @@ ORDER BY d.column_name;
|
|
|
465
511
|
}
|
|
466
512
|
else:
|
|
467
513
|
res = self.db_interface.execute_query_fetching_results(conn, last_query, found_params, wait_hook=wait_hook,
|
|
468
|
-
requires_dba_check=check_required and canned_query_service is not None
|
|
514
|
+
requires_dba_check=check_required and canned_query_service is not None,
|
|
515
|
+
prepare=prepare_statements or query_key in canned_keys)
|
|
469
516
|
|
|
470
517
|
if do_prepare_only and not attempt_fetch_domain_types and not psql:
|
|
471
518
|
self.db_interface.execute_query_fetching_results(conn, "DEALLOCATE _jaaql_query_check_" + do_prepare_only, found_params,
|
|
@@ -771,6 +818,11 @@ ORDER BY d.column_name;
|
|
|
771
818
|
return [element]
|
|
772
819
|
|
|
773
820
|
def encrypt_literals(self, query, encryption_key: bytes = None, match_regex: str = REGEX_enc_literals):
|
|
821
|
+
# The default pattern can only match if the literal prefix #' is present; the substring
|
|
822
|
+
# check is C-speed and skips the regex scan for the common case of no encrypted literals
|
|
823
|
+
if match_regex is REGEX_enc_literals and "#'" not in query:
|
|
824
|
+
return query
|
|
825
|
+
|
|
774
826
|
new_query = ""
|
|
775
827
|
last_end_match = 0
|
|
776
828
|
|
|
@@ -786,22 +838,15 @@ ORDER BY d.column_name;
|
|
|
786
838
|
def pre_prepare_statement(self, query, parameters, match_regex: str = REGEX_query_argument, encryption_key: bytes = None,
|
|
787
839
|
require_presence: bool = True, for_prepare: bool = False, skipped_singletons: list[str] = None,
|
|
788
840
|
replace_with_nulls: bool = False):
|
|
789
|
-
|
|
790
|
-
|
|
841
|
+
literals, names = tokenize_statement(query, match_regex)
|
|
842
|
+
prepared_parts = []
|
|
791
843
|
found_parameters = []
|
|
792
844
|
found_parameter_dictionary = {}
|
|
793
845
|
|
|
794
|
-
for
|
|
846
|
+
for token_idx, match_str in enumerate(names):
|
|
795
847
|
is_skipped_default = False
|
|
796
|
-
start_pos = match.regs[0][0]
|
|
797
|
-
if start_pos != 0:
|
|
798
|
-
if query[start_pos - 1] == REGEX_query_argument[0]:
|
|
799
|
-
continue
|
|
800
|
-
end_pos = match.regs[0][1]
|
|
801
|
-
match_str = query[start_pos + 1:end_pos]
|
|
802
848
|
|
|
803
|
-
|
|
804
|
-
last_index = end_pos
|
|
849
|
+
prepared_parts.append(literals[token_idx])
|
|
805
850
|
|
|
806
851
|
if match_str not in parameters and for_prepare:
|
|
807
852
|
parameters[match_str] = MarkerPrepare(len(parameters))
|
|
@@ -826,20 +871,20 @@ ORDER BY d.column_name;
|
|
|
826
871
|
found_parameter_dictionary[match_str] = param
|
|
827
872
|
|
|
828
873
|
if parameters[match_str] is None and not isinstance(parameters[match_str], MarkerPrepare):
|
|
829
|
-
|
|
874
|
+
prepared_parts.append(STATEMENT_null)
|
|
830
875
|
else:
|
|
831
876
|
if isinstance(parameters[match_str], MarkerPrepare):
|
|
832
877
|
if replace_with_nulls:
|
|
833
|
-
|
|
878
|
+
prepared_parts.append("NULL")
|
|
834
879
|
else:
|
|
835
|
-
|
|
880
|
+
prepared_parts.append("$" + str(int(parameters[match_str].count) + 1))
|
|
836
881
|
else:
|
|
837
882
|
the_type = self.get_format_type(match_str, parameters[match_str])
|
|
838
883
|
if is_skipped_default:
|
|
839
|
-
|
|
884
|
+
prepared_parts.append(MARKER_DEFAULT)
|
|
840
885
|
else:
|
|
841
|
-
|
|
886
|
+
prepared_parts.append(STATEMENT_argument_begin + STATEMENT_paren_open + match_str + STATEMENT_paren_close + the_type)
|
|
842
887
|
|
|
843
|
-
|
|
888
|
+
prepared_parts.append(literals[-1])
|
|
844
889
|
|
|
845
|
-
return
|
|
890
|
+
return STATEMENT_empty.join(prepared_parts), found_parameter_dictionary
|