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.
- prismiq/__init__.py +543 -0
- prismiq/api.py +1889 -0
- prismiq/auth.py +108 -0
- prismiq/cache.py +527 -0
- prismiq/calculated_field_processor.py +231 -0
- prismiq/calculated_fields.py +819 -0
- prismiq/dashboard_store.py +1219 -0
- prismiq/dashboards.py +374 -0
- prismiq/dates.py +247 -0
- prismiq/engine.py +1315 -0
- prismiq/executor.py +345 -0
- prismiq/filter_merge.py +397 -0
- prismiq/formatting.py +298 -0
- prismiq/logging.py +489 -0
- prismiq/metrics.py +536 -0
- prismiq/middleware.py +346 -0
- prismiq/permissions.py +87 -0
- prismiq/persistence/__init__.py +45 -0
- prismiq/persistence/models.py +208 -0
- prismiq/persistence/postgres_store.py +1119 -0
- prismiq/persistence/saved_query_store.py +336 -0
- prismiq/persistence/schema.sql +95 -0
- prismiq/persistence/setup.py +222 -0
- prismiq/persistence/tables.py +76 -0
- prismiq/pins.py +72 -0
- prismiq/py.typed +0 -0
- prismiq/query.py +1233 -0
- prismiq/schema.py +333 -0
- prismiq/schema_config.py +354 -0
- prismiq/sql_utils.py +147 -0
- prismiq/sql_validator.py +219 -0
- prismiq/sqlalchemy_builder.py +577 -0
- prismiq/timeseries.py +410 -0
- prismiq/transforms.py +471 -0
- prismiq/trends.py +573 -0
- prismiq/types.py +688 -0
- prismiq-0.1.0.dist-info/METADATA +109 -0
- prismiq-0.1.0.dist-info/RECORD +39 -0
- prismiq-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|