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 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.0"
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
@@ -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
- if do_reset:
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, allow_operational: bool = True):
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
- try:
121
- with conn.cursor() as cursor:
122
- if self.role is not None:
123
- try:
124
- cursor.execute("SELECT jaaql_extension.jaaql__set_session_authorization('" + self.role + "', '" + conn.jaaql_reset_key + "');")
125
- except InternalError as ex:
126
- if str(ex).startswith("role \"") and str(ex).endswith("\" does not exist"):
127
- raise HttpStatusException(ERR__invalid_token, HTTPStatus.UNAUTHORIZED)
128
- else:
129
- traceback.print_exc()
130
- raise ex
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 execute_query(self, conn, query, parameters=None, wait_hook: queue.Queue = None):
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
- with conn.cursor() as cursor:
201
- do_prepare = False
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
- if parameters is None or len(parameters.keys()) == 0:
211
- cursor.execute(query, prepare=do_prepare)
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
- cursor.execute(query, parameters, prepare=do_prepare)
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()
@@ -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
- prepared = ""
790
- last_index = 0
841
+ literals, names = tokenize_statement(query, match_regex)
842
+ prepared_parts = []
791
843
  found_parameters = []
792
844
  found_parameter_dictionary = {}
793
845
 
794
- for match in re.finditer(match_regex, query):
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
- prepared += query[last_index:start_pos]
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
- prepared += STATEMENT_null
874
+ prepared_parts.append(STATEMENT_null)
830
875
  else:
831
876
  if isinstance(parameters[match_str], MarkerPrepare):
832
877
  if replace_with_nulls:
833
- prepared += "NULL"
878
+ prepared_parts.append("NULL")
834
879
  else:
835
- prepared += "$" + str(int(parameters[match_str].count) + 1)
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
- prepared += MARKER_DEFAULT
884
+ prepared_parts.append(MARKER_DEFAULT)
840
885
  else:
841
- prepared += STATEMENT_argument_begin + STATEMENT_paren_open + match_str + STATEMENT_paren_close + the_type
886
+ prepared_parts.append(STATEMENT_argument_begin + STATEMENT_paren_open + match_str + STATEMENT_paren_close + the_type)
842
887
 
843
- prepared += query[last_index:]
888
+ prepared_parts.append(literals[-1])
844
889
 
845
- return prepared, found_parameter_dictionary
890
+ return STATEMENT_empty.join(prepared_parts), found_parameter_dictionary