prismiq 0.1.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.
@@ -0,0 +1,577 @@
1
+ """SQLAlchemy-compatible SQL query builder.
2
+
3
+ This module provides a query builder that generates SQL with SQLAlchemy-style
4
+ named parameters (:param_name) instead of PostgreSQL positional parameters ($1, $2).
5
+
6
+ Use this when working with SQLAlchemy's `text()` function.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Callable
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .sql_utils import (
16
+ ALLOWED_AGGREGATIONS,
17
+ ALLOWED_DATE_TRUNCS,
18
+ ALLOWED_JOIN_TYPES,
19
+ ALLOWED_OPERATORS,
20
+ ALLOWED_ORDER_DIRECTIONS,
21
+ convert_java_date_format_to_postgres,
22
+ validate_identifier,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ pass
27
+
28
+
29
+ def _postprocess_scalar_subqueries(sql: str) -> str:
30
+ """Replace scalar subquery placeholders with actual subqueries.
31
+
32
+ Calculated fields that use "percent of total" patterns (like [Sum:arr] / [Sum:total])
33
+ need the divisor to be computed across ALL rows, not just the grouped rows.
34
+ This is achieved using scalar subqueries.
35
+
36
+ The calculated_fields module generates placeholders like __SCALAR_SUM_columnname__
37
+ which this function replaces with actual scalar subqueries using the FROM and WHERE
38
+ clauses from the main query.
39
+
40
+ Args:
41
+ sql: Generated SQL query that may contain __SCALAR_SUM_*__ placeholders
42
+
43
+ Returns:
44
+ SQL with placeholders replaced by scalar subqueries
45
+
46
+ Example:
47
+ Input: SELECT __SCALAR_SUM_arr__ FROM "public"."accounts" WHERE x = 1
48
+ Output: SELECT (SELECT SUM(("arr")::numeric) FROM "public"."accounts" WHERE x = 1)
49
+ FROM "public"."accounts" WHERE x = 1
50
+ """
51
+ # Pattern: __SCALAR_SUM_columnname__
52
+ scalar_pattern = r"__SCALAR_SUM_(\w+)__"
53
+ matches = re.findall(scalar_pattern, sql)
54
+
55
+ if not matches:
56
+ return sql
57
+
58
+ # Extract FROM clause from SQL to reuse in scalar subqueries
59
+ # Use negated character class to avoid ReDoS from backtracking
60
+ # Match FROM until we hit a SQL keyword boundary
61
+ from_match = re.search(
62
+ r"FROM\s+([^;]+?)(?=\s+WHERE\s|\s+GROUP\s+BY\s|\s+ORDER\s+BY\s|\s+LIMIT\s|$)",
63
+ sql,
64
+ re.IGNORECASE,
65
+ )
66
+ if not from_match:
67
+ # Can't extract FROM clause - return SQL as-is with placeholders
68
+ # This shouldn't happen in practice but handles edge cases gracefully
69
+ return sql
70
+
71
+ from_clause = from_match.group(1).strip()
72
+
73
+ # Extract WHERE clause if present
74
+ # Use negated character class to avoid ReDoS from backtracking
75
+ where_match = re.search(
76
+ r"WHERE\s+([^;]+?)(?=\s+GROUP\s+BY\s|\s+ORDER\s+BY\s|\s+LIMIT\s|$)",
77
+ sql,
78
+ re.IGNORECASE,
79
+ )
80
+ where_clause = where_match.group(1).strip() if where_match else None
81
+
82
+ # Replace each placeholder with a scalar subquery
83
+ for column_name in matches:
84
+ # Build scalar subquery: (SELECT SUM("column") FROM table WHERE filters)
85
+ subquery = f'(SELECT SUM(("{column_name}")::numeric) FROM {from_clause}' # noqa: S608
86
+ if where_clause:
87
+ subquery += f" WHERE {where_clause}"
88
+ subquery += ")"
89
+
90
+ # Replace placeholder with scalar subquery
91
+ sql = sql.replace(f"__SCALAR_SUM_{column_name}__", subquery)
92
+
93
+ return sql
94
+
95
+
96
+ def build_sql_from_dict(
97
+ query: dict[str, Any],
98
+ *,
99
+ table_validator: Callable[[str], None] | None = None,
100
+ preprocess_calculated_fields: bool = True,
101
+ ) -> tuple[str, dict[str, Any]]:
102
+ """Build SQL query from dictionary-based query definition.
103
+
104
+ This function generates parameterized SQL with SQLAlchemy-style named
105
+ parameters (e.g., :param_0, :param_1) for use with SQLAlchemy's text().
106
+
107
+ Args:
108
+ query: Query definition dict with keys:
109
+ - tables: list[dict] with id, name, schema, optional alias
110
+ - joins: list[dict] with join_type, from_table_id, to_table_id, etc.
111
+ - columns: list[dict] with table_id, column, optional aggregation/alias
112
+ - filters: list[dict] with table_id, column, operator, value
113
+ - group_by: list[str] column names
114
+ - order_by: list[dict] with column, direction
115
+ - limit: int | None
116
+ - calculated_fields: list[dict] with name and expression (optional)
117
+
118
+ table_validator: Optional callback to validate table names against
119
+ application-specific whitelist. Should raise ValueError
120
+ if table is not allowed. This enables business-specific
121
+ table access control while keeping core logic generic.
122
+
123
+ preprocess_calculated_fields: If True (default), automatically preprocess
124
+ calculated_fields in the query. Set to False if the caller
125
+ has already preprocessed them.
126
+
127
+ Returns:
128
+ Tuple of (sql, params) where params is a dict for SQLAlchemy text()
129
+
130
+ Raises:
131
+ ValueError: If query contains invalid identifiers, operators, or structure
132
+
133
+ Security:
134
+ - All identifiers validated with strict character checking
135
+ - All operators validated against whitelist
136
+ - All values parameterized (never interpolated)
137
+ - Custom table validator for application-specific access control
138
+
139
+ Example:
140
+ >>> query = {
141
+ ... "tables": [{"id": "t1", "name": "users", "schema": "public"}],
142
+ ... "columns": [{"table_id": "t1", "column": "email"}],
143
+ ... "filters": [{"table_id": "t1", "column": "id", "operator": "eq", "value": 42}],
144
+ ... }
145
+ >>> sql, params = build_sql_from_dict(query)
146
+ >>> # sql: 'SELECT "users"."email" FROM "public"."users" WHERE "users"."id" = :param_0'
147
+ >>> # params: {"param_0": 42}
148
+ """
149
+ # Preprocess calculated fields if present and not already processed
150
+ if preprocess_calculated_fields and query.get("calculated_fields"):
151
+ from .calculated_field_processor import (
152
+ preprocess_calculated_fields as preprocess_calc_fields,
153
+ )
154
+
155
+ query = preprocess_calc_fields(query)
156
+
157
+ # Extract query parts
158
+ tables = query.get("tables", [])
159
+ joins = query.get("joins", [])
160
+ columns = query.get("columns", [])
161
+ filters = query.get("filters", [])
162
+ group_by = query.get("group_by", [])
163
+ order_by = query.get("order_by", [])
164
+ limit = query.get("limit")
165
+
166
+ if not tables or not columns:
167
+ raise ValueError("Query must have at least one table and one column")
168
+
169
+ # Validate all table names and aliases
170
+ for table in tables:
171
+ table_name = table.get("name", "")
172
+ validate_identifier(table_name, "table name")
173
+
174
+ # Optional: Application-specific table whitelist validation
175
+ if table_validator:
176
+ table_validator(table_name)
177
+
178
+ # Validate alias if present
179
+ if table.get("alias"):
180
+ validate_identifier(table["alias"], "table alias")
181
+
182
+ # Validate all column names and aliases
183
+ for col in columns:
184
+ column_name = col.get("column", "")
185
+ # Skip validation if sql_expression is provided (calculated fields use expression directly)
186
+ # Also allow wildcard for SELECT *
187
+ if column_name != "*" and not col.get("sql_expression"):
188
+ validate_identifier(column_name, "column name")
189
+
190
+ # Validate column alias if present for regular columns
191
+ # (aliases are double-quoted in SQL so they can contain special chars,
192
+ # but we validate regular column aliases for consistency)
193
+ if col.get("alias"):
194
+ validate_identifier(col["alias"], "column alias")
195
+ # Note: For calculated fields (with sql_expression), we skip alias validation
196
+ # because calculated field names can contain spaces, %, etc. and are safely
197
+ # quoted as AS "alias" in the generated SQL
198
+
199
+ # Validate JOIN types against whitelist
200
+ for join in joins:
201
+ join_type = join.get("join_type", "INNER").upper()
202
+ if join_type not in ALLOWED_JOIN_TYPES:
203
+ raise ValueError(
204
+ f"Invalid JOIN type: '{join_type}'. Allowed types: {sorted(ALLOWED_JOIN_TYPES)}"
205
+ )
206
+
207
+ # Build table_id -> table reference mapping
208
+ table_refs = {}
209
+ for table in tables:
210
+ table_id = table["id"]
211
+ if table.get("alias"):
212
+ table_refs[table_id] = f'"{table["alias"]}"'
213
+ else:
214
+ table_refs[table_id] = f'"{table["name"]}"'
215
+
216
+ # Build SELECT clause
217
+ select_parts = []
218
+ for col in columns:
219
+ table_id = col["table_id"]
220
+ column_name = col["column"]
221
+ agg = col.get("aggregation", "none")
222
+ alias = col.get("alias")
223
+ date_trunc = col.get("date_trunc")
224
+
225
+ # Check for custom SQL expression (for calculated fields, etc.)
226
+ sql_expression = col.get("sql_expression")
227
+
228
+ # Validate aggregation function
229
+ if agg not in ALLOWED_AGGREGATIONS:
230
+ raise ValueError(
231
+ f"Invalid aggregation function: '{agg}'. "
232
+ f"Allowed functions: {sorted(ALLOWED_AGGREGATIONS)}"
233
+ )
234
+
235
+ # Validate date_trunc period if present
236
+ if date_trunc and date_trunc not in ALLOWED_DATE_TRUNCS:
237
+ raise ValueError(
238
+ f"Invalid date_trunc period: '{date_trunc}'. "
239
+ f"Allowed periods: {sorted(ALLOWED_DATE_TRUNCS)}"
240
+ )
241
+
242
+ # Get table reference for this column
243
+ table_ref = table_refs.get(table_id, f'"{tables[0]["name"]}"')
244
+
245
+ # Build column expression
246
+ if sql_expression:
247
+ # Use custom SQL expression (e.g., for calculated fields)
248
+ # Check if expression already contains aggregation
249
+ has_aggregation = col.get("_has_aggregation", False)
250
+
251
+ # Only apply aggregation if requested and not already present
252
+ if agg and agg != "none" and not has_aggregation:
253
+ if agg == "count_distinct":
254
+ expr = f"COUNT(DISTINCT ({sql_expression}))"
255
+ else:
256
+ expr = f"{agg.upper()}({sql_expression})"
257
+ else:
258
+ expr = sql_expression
259
+ elif date_trunc:
260
+ col_ref = f'{table_ref}."{column_name}"'
261
+ date_trunc_expr = f"DATE_TRUNC('{date_trunc}', {col_ref})"
262
+ # Apply date formatting if date_format is specified
263
+ date_format = col.get("date_format")
264
+ if date_format:
265
+ pg_format = convert_java_date_format_to_postgres(date_format)
266
+ expr = f"TO_CHAR({date_trunc_expr}, '{pg_format}')"
267
+ else:
268
+ expr = date_trunc_expr
269
+ elif agg and agg != "none":
270
+ # Check if column needs type casting (e.g., boolean to int for SUM/AVG)
271
+ cast_type = col.get("cast_type")
272
+
273
+ if agg == "count_distinct":
274
+ if column_name == "*":
275
+ expr = "COUNT(DISTINCT *)"
276
+ else:
277
+ expr = f'COUNT(DISTINCT {table_ref}."{column_name}")'
278
+ else:
279
+ if column_name == "*":
280
+ expr = f"{agg.upper()}(*)"
281
+ else:
282
+ col_ref = f'{table_ref}."{column_name}"'
283
+ # Apply type cast if specified (e.g., ::int for boolean SUM)
284
+ if cast_type:
285
+ col_ref = f"({col_ref})::{cast_type}"
286
+ expr = f"{agg.upper()}({col_ref})"
287
+ else:
288
+ if column_name == "*": # noqa: SIM108
289
+ expr = "*"
290
+ else:
291
+ expr = f'{table_ref}."{column_name}"'
292
+
293
+ if alias:
294
+ expr = f'{expr} AS "{alias}"'
295
+
296
+ select_parts.append(expr)
297
+
298
+ # Build FROM clause with JOINs
299
+ main_table = tables[0]
300
+ from_clause = f'"{main_table["schema"]}"."{main_table["name"]}"'
301
+ if main_table.get("alias"):
302
+ from_clause += f' AS "{main_table["alias"]}"'
303
+
304
+ # Add JOIN clauses
305
+ for join in joins:
306
+ # Find the joined table
307
+ to_table = next((t for t in tables if t["id"] == join["to_table_id"]), None)
308
+ if not to_table:
309
+ continue
310
+
311
+ # Get table references
312
+ from_ref = table_refs[join["from_table_id"]]
313
+ to_ref = table_refs[join["to_table_id"]]
314
+
315
+ # Validate and get JOIN type
316
+ join_type = join.get("join_type", "INNER").upper()
317
+ if join_type not in ALLOWED_JOIN_TYPES:
318
+ raise ValueError(f"Invalid JOIN type: {join_type}")
319
+
320
+ # Validate join column names
321
+ from_column = join["from_column"]
322
+ to_column = join["to_column"]
323
+ validate_identifier(from_column, "join from_column")
324
+ validate_identifier(to_column, "join to_column")
325
+
326
+ table_sql = f'"{to_table["schema"]}"."{to_table["name"]}"'
327
+ if to_table.get("alias"):
328
+ table_sql += f' AS "{to_table["alias"]}"'
329
+
330
+ from_clause += (
331
+ f' {join_type} JOIN {table_sql} ON {from_ref}."{from_column}" = {to_ref}."{to_column}"'
332
+ )
333
+
334
+ # Build WHERE clause
335
+ where_parts = []
336
+ params = {}
337
+ param_counter = 0
338
+
339
+ for filt in filters:
340
+ table_id = filt.get("table_id", "t1")
341
+ column = filt["column"]
342
+ operator = filt["operator"]
343
+ value = filt["value"]
344
+
345
+ # Check for custom SQL expression (for calculated fields in filters)
346
+ sql_expression = filt.get("sql_expression")
347
+
348
+ # Validate filter column name (skip if using sql_expression)
349
+ if not sql_expression:
350
+ validate_identifier(column, "filter column")
351
+
352
+ # Validate operator against whitelist
353
+ if operator not in ALLOWED_OPERATORS:
354
+ raise ValueError(
355
+ f"Invalid filter operator: '{operator}'. "
356
+ f"Allowed operators: {sorted(ALLOWED_OPERATORS)}"
357
+ )
358
+
359
+ # Build column reference - use sql_expression if provided, otherwise build from table.column
360
+ if sql_expression:
361
+ col_ref = f"({sql_expression})"
362
+ else:
363
+ # Get table reference for this filter
364
+ table_ref = table_refs.get(table_id, f'"{tables[0]["name"]}"')
365
+ col_ref = f'{table_ref}."{column}"'
366
+
367
+ if operator == "eq":
368
+ # Handle NULL equality (IS NULL)
369
+ if value is None:
370
+ where_parts.append(f"{col_ref} IS NULL")
371
+ else:
372
+ param_name = f"param_{param_counter}"
373
+ # Handle boolean columns compared with 0 or 1 (cast to int)
374
+ if value in (0, 1):
375
+ where_parts.append(f"({col_ref})::int = :{param_name}")
376
+ else:
377
+ where_parts.append(f"{col_ref} = :{param_name}")
378
+ params[param_name] = value
379
+ param_counter += 1
380
+ elif operator == "ne":
381
+ if value is None:
382
+ where_parts.append(f"{col_ref} IS NOT NULL")
383
+ else:
384
+ param_name = f"param_{param_counter}"
385
+ where_parts.append(f"{col_ref} != :{param_name}")
386
+ params[param_name] = value
387
+ param_counter += 1
388
+ elif operator == "gt":
389
+ param_name = f"param_{param_counter}"
390
+ where_parts.append(f"{col_ref} > :{param_name}")
391
+ params[param_name] = value
392
+ param_counter += 1
393
+ elif operator == "gte":
394
+ param_name = f"param_{param_counter}"
395
+ where_parts.append(f"{col_ref} >= :{param_name}")
396
+ params[param_name] = value
397
+ param_counter += 1
398
+ elif operator == "lt":
399
+ param_name = f"param_{param_counter}"
400
+ where_parts.append(f"{col_ref} < :{param_name}")
401
+ params[param_name] = value
402
+ param_counter += 1
403
+ elif operator == "lte":
404
+ param_name = f"param_{param_counter}"
405
+ where_parts.append(f"{col_ref} <= :{param_name}")
406
+ params[param_name] = value
407
+ param_counter += 1
408
+ elif operator in ("in", "in_"):
409
+ # Guard against empty list (invalid SQL: IN ())
410
+ # Note: "in_" is an alias for "in" (React types use in_)
411
+ if not value:
412
+ # Empty IN list evaluates to FALSE (no rows match)
413
+ where_parts.append("FALSE")
414
+ continue
415
+
416
+ param_names = []
417
+ for val in value:
418
+ param_name = f"param_{param_counter}"
419
+ param_names.append(f":{param_name}")
420
+ params[param_name] = val
421
+ param_counter += 1
422
+ where_parts.append(f"{col_ref} IN ({', '.join(param_names)})")
423
+ elif operator == "in_or_null":
424
+ # Handle mixed selection of concrete values AND NULL
425
+ # Generates: (col IN (...) OR col IS NULL)
426
+ if not value:
427
+ # No concrete values, just NULL filter
428
+ where_parts.append(f"{col_ref} IS NULL")
429
+ continue
430
+
431
+ param_names = []
432
+ for val in value:
433
+ param_name = f"param_{param_counter}"
434
+ param_names.append(f":{param_name}")
435
+ params[param_name] = val
436
+ param_counter += 1
437
+ where_parts.append(f"({col_ref} IN ({', '.join(param_names)}) OR {col_ref} IS NULL)")
438
+ elif operator == "in_subquery":
439
+ # For subquery filters (used in RLS filtering).
440
+ # SECURITY: The SQL in value["sql"] is interpolated directly without
441
+ # parameterization. Callers MUST ensure the SQL is safely generated
442
+ # (e.g., from trusted internal code, not user input). This is by design
443
+ # since subqueries cannot be parameterized.
444
+ if not isinstance(value, dict):
445
+ raise ValueError(
446
+ f"IN_SUBQUERY filter on column '{column}' requires "
447
+ f"value={{'sql': '...'}}, got {type(value).__name__}"
448
+ )
449
+ if "sql" not in value:
450
+ raise ValueError(
451
+ f"IN_SUBQUERY filter on column '{column}' requires "
452
+ f"value={{'sql': '...'}}, missing 'sql' key"
453
+ )
454
+ subquery_sql = value["sql"].strip()
455
+ if not subquery_sql:
456
+ raise ValueError(f"IN_SUBQUERY filter on column '{column}' has empty SQL")
457
+ where_parts.append(f"{col_ref} IN ({subquery_sql})")
458
+ elif operator == "like":
459
+ param_name = f"param_{param_counter}"
460
+ where_parts.append(f"{col_ref} LIKE :{param_name}")
461
+ params[param_name] = f"%{value}%"
462
+ param_counter += 1
463
+ elif operator == "not_like":
464
+ param_name = f"param_{param_counter}"
465
+ where_parts.append(f"{col_ref} NOT LIKE :{param_name}")
466
+ params[param_name] = f"%{value}%"
467
+ param_counter += 1
468
+
469
+ # Build SQL (identifiers are quoted, values use params)
470
+ sql = f"SELECT {', '.join(select_parts)} FROM {from_clause}" # noqa: S608
471
+
472
+ if where_parts:
473
+ sql += f" WHERE {' AND '.join(where_parts)}"
474
+
475
+ if group_by:
476
+ # Build GROUP BY expressions
477
+ group_cols = []
478
+ for col_name in group_by:
479
+ # Find the corresponding column definition first
480
+ col_def = next(
481
+ (c for c in columns if c.get("column") == col_name or c.get("alias") == col_name),
482
+ None,
483
+ )
484
+ if col_def:
485
+ # Skip columns that have aggregation - they shouldn't be in GROUP BY
486
+ # This includes both calculated fields with _has_aggregation flag
487
+ # and regular columns with aggregation set
488
+ if col_def.get("_has_aggregation") or (
489
+ col_def.get("aggregation") and col_def.get("aggregation") != "none"
490
+ ):
491
+ continue
492
+
493
+ # Check if this is a calculated field with sql_expression
494
+ if col_def.get("sql_expression"):
495
+ # Use the sql_expression directly for GROUP BY
496
+ # No validation needed - sql_expression is already validated/safe
497
+ group_cols.append(col_def["sql_expression"])
498
+ else:
499
+ # Regular column - validate the identifier
500
+ column_name = col_def["column"]
501
+ validate_identifier(column_name, "group_by column")
502
+
503
+ # Get table reference and column name
504
+ table_id = col_def.get("table_id", "t1")
505
+ table_ref = table_refs.get(table_id, f'"{tables[0]["name"]}"')
506
+ col_ref = f'{table_ref}."{column_name}"'
507
+
508
+ if col_def.get("date_trunc"):
509
+ # Use the same expression as in SELECT - DATE_TRUNC
510
+ date_trunc = col_def["date_trunc"]
511
+ group_cols.append(f"DATE_TRUNC('{date_trunc}', {col_ref})")
512
+ else:
513
+ # Regular column
514
+ group_cols.append(col_ref)
515
+ else:
516
+ # Fallback: column not found in definitions - validate and quote as-is
517
+ validate_identifier(col_name, "group_by column")
518
+ group_cols.append(f'"{col_name}"')
519
+ # Only add GROUP BY clause if there are non-aggregate columns to group by
520
+ if group_cols:
521
+ sql += f" GROUP BY {', '.join(group_cols)}"
522
+
523
+ if order_by:
524
+ order_parts = []
525
+ for order in order_by:
526
+ col = order["column"]
527
+ direction = order.get("direction", "asc").upper()
528
+
529
+ # Validate direction
530
+ if direction not in ALLOWED_ORDER_DIRECTIONS:
531
+ # Default to ASC for invalid directions
532
+ direction = "ASC"
533
+
534
+ # Find the column definition first
535
+ col_def = next(
536
+ (c for c in columns if c["column"] == col or c.get("alias") == col),
537
+ None,
538
+ )
539
+
540
+ if col_def:
541
+ # Check if this is a calculated field with sql_expression
542
+ if col_def.get("sql_expression"):
543
+ # Use the sql_expression directly for ORDER BY
544
+ # No validation needed - sql_expression is already validated/safe
545
+ order_parts.append(f"{col_def['sql_expression']} {direction}")
546
+ else:
547
+ # Regular column - validate the identifier
548
+ column_name = col_def["column"]
549
+ validate_identifier(column_name, "order_by column")
550
+
551
+ # Get table reference and column name
552
+ table_id = col_def.get("table_id", "t1")
553
+ table_ref = table_refs.get(table_id, f'"{tables[0]["name"]}"')
554
+ col_ref = f'{table_ref}."{column_name}"'
555
+
556
+ if col_def.get("date_trunc"):
557
+ # Use the same expression as in SELECT/GROUP BY - DATE_TRUNC
558
+ date_trunc = col_def["date_trunc"]
559
+ expr = f"DATE_TRUNC('{date_trunc}', {col_ref})"
560
+ order_parts.append(f"{expr} {direction}")
561
+ else:
562
+ # Regular column
563
+ order_parts.append(f"{col_ref} {direction}")
564
+ else:
565
+ # Fallback: column not found in definitions - validate and quote as-is
566
+ validate_identifier(col, "order_by column")
567
+ order_parts.append(f'"{col}" {direction}')
568
+ sql += f" ORDER BY {', '.join(order_parts)}"
569
+
570
+ # Handle LIMIT clause (use is not None to allow limit=0)
571
+ if limit is not None:
572
+ sql += f" LIMIT {int(limit)}"
573
+
574
+ # Post-process scalar subquery placeholders (for percent-of-total patterns)
575
+ sql = _postprocess_scalar_subqueries(sql)
576
+
577
+ return sql, params