api-foundry-query-engine 0.8.39__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.
- api_foundry_query_engine/.pre-commit-config.yaml +22 -0
- api_foundry_query_engine/__init__.py +1 -0
- api_foundry_query_engine/adapters/adapter.py +73 -0
- api_foundry_query_engine/adapters/case_change_adapter.py +79 -0
- api_foundry_query_engine/adapters/gateway_adapter.py +191 -0
- api_foundry_query_engine/adapters/security_adapter.py +106 -0
- api_foundry_query_engine/connectors/connection.py +32 -0
- api_foundry_query_engine/connectors/connection_factory.py +115 -0
- api_foundry_query_engine/connectors/oracle_connector.py +29 -0
- api_foundry_query_engine/connectors/postgres_connection.py +142 -0
- api_foundry_query_engine/dao/batch_operation_handler.py +295 -0
- api_foundry_query_engine/dao/dao.py +23 -0
- api_foundry_query_engine/dao/operation_dao.py +186 -0
- api_foundry_query_engine/dao/sql_custom_query_handler.py +68 -0
- api_foundry_query_engine/dao/sql_delete_query_handler.py +146 -0
- api_foundry_query_engine/dao/sql_insert_query_handler.py +187 -0
- api_foundry_query_engine/dao/sql_query_handler.py +713 -0
- api_foundry_query_engine/dao/sql_restore_query_handler.py +195 -0
- api_foundry_query_engine/dao/sql_select_query_handler.py +433 -0
- api_foundry_query_engine/dao/sql_subselect_query_handler.py +66 -0
- api_foundry_query_engine/dao/sql_update_query_handler.py +198 -0
- api_foundry_query_engine/lambda_handler.py +63 -0
- api_foundry_query_engine/operation.py +104 -0
- api_foundry_query_engine/services/service.py +75 -0
- api_foundry_query_engine/services/transactional_service.py +52 -0
- api_foundry_query_engine/utils/api_model.py +380 -0
- api_foundry_query_engine/utils/app_exception.py +22 -0
- api_foundry_query_engine/utils/claims_check.py +471 -0
- api_foundry_query_engine/utils/dependency_resolver.py +157 -0
- api_foundry_query_engine/utils/gateway_operation.py +279 -0
- api_foundry_query_engine/utils/logger.py +60 -0
- api_foundry_query_engine/utils/reference_resolver.py +222 -0
- api_foundry_query_engine/utils/token_decoder.py +624 -0
- api_foundry_query_engine-0.8.39.dist-info/METADATA +21 -0
- api_foundry_query_engine-0.8.39.dist-info/RECORD +37 -0
- api_foundry_query_engine-0.8.39.dist-info/WHEEL +4 -0
- api_foundry_query_engine-0.8.39.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Optional, List, Dict
|
|
3
|
+
from datetime import datetime, date
|
|
4
|
+
|
|
5
|
+
from api_foundry_query_engine.utils.app_exception import ApplicationException
|
|
6
|
+
from api_foundry_query_engine.operation import Operation
|
|
7
|
+
from api_foundry_query_engine.utils.api_model import SchemaObject, SchemaObjectProperty
|
|
8
|
+
from api_foundry_query_engine.utils.logger import logger
|
|
9
|
+
|
|
10
|
+
log = logger(__name__)
|
|
11
|
+
|
|
12
|
+
SQL_RESERVED_WORDS = {
|
|
13
|
+
"select",
|
|
14
|
+
"from",
|
|
15
|
+
"where",
|
|
16
|
+
"insert",
|
|
17
|
+
"update",
|
|
18
|
+
"delete",
|
|
19
|
+
"join",
|
|
20
|
+
"on",
|
|
21
|
+
"order",
|
|
22
|
+
"group",
|
|
23
|
+
"having",
|
|
24
|
+
"union",
|
|
25
|
+
"distinct",
|
|
26
|
+
"into",
|
|
27
|
+
"as",
|
|
28
|
+
"and",
|
|
29
|
+
"or",
|
|
30
|
+
"not",
|
|
31
|
+
"in",
|
|
32
|
+
"is",
|
|
33
|
+
"null",
|
|
34
|
+
"like",
|
|
35
|
+
"between",
|
|
36
|
+
"by",
|
|
37
|
+
"case",
|
|
38
|
+
"when",
|
|
39
|
+
"then",
|
|
40
|
+
"else",
|
|
41
|
+
"end",
|
|
42
|
+
"exists",
|
|
43
|
+
"all",
|
|
44
|
+
"any",
|
|
45
|
+
"some",
|
|
46
|
+
"limit",
|
|
47
|
+
"offset",
|
|
48
|
+
"fetch",
|
|
49
|
+
"for",
|
|
50
|
+
"create",
|
|
51
|
+
"alter",
|
|
52
|
+
"drop",
|
|
53
|
+
"table",
|
|
54
|
+
"index",
|
|
55
|
+
"view",
|
|
56
|
+
"trigger",
|
|
57
|
+
"procedure",
|
|
58
|
+
"function",
|
|
59
|
+
"database",
|
|
60
|
+
"schema",
|
|
61
|
+
"grant",
|
|
62
|
+
"revoke",
|
|
63
|
+
"primary",
|
|
64
|
+
"key",
|
|
65
|
+
"foreign",
|
|
66
|
+
"references",
|
|
67
|
+
"check",
|
|
68
|
+
"unique",
|
|
69
|
+
"default",
|
|
70
|
+
"with",
|
|
71
|
+
"values",
|
|
72
|
+
"set",
|
|
73
|
+
"transaction",
|
|
74
|
+
"commit",
|
|
75
|
+
"rollback",
|
|
76
|
+
"savepoint",
|
|
77
|
+
"lock",
|
|
78
|
+
"tablespace",
|
|
79
|
+
"sequence",
|
|
80
|
+
"if",
|
|
81
|
+
"else",
|
|
82
|
+
"elsif",
|
|
83
|
+
"loop",
|
|
84
|
+
"begin",
|
|
85
|
+
"declare",
|
|
86
|
+
"end",
|
|
87
|
+
"open",
|
|
88
|
+
"fetch",
|
|
89
|
+
"close",
|
|
90
|
+
"cursor",
|
|
91
|
+
"next",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
RELATIONAL_TYPES = {
|
|
95
|
+
"lt": "<",
|
|
96
|
+
"le": "<=",
|
|
97
|
+
"eq": "=",
|
|
98
|
+
"ge": ">=",
|
|
99
|
+
"gt": ">",
|
|
100
|
+
"in": "in",
|
|
101
|
+
"not-in": "not-in",
|
|
102
|
+
"between": "between",
|
|
103
|
+
"not-between": "not-between",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SQLQueryHandler:
|
|
108
|
+
operation: Operation
|
|
109
|
+
engine: str
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self, operation: Operation, engine: str
|
|
113
|
+
): # , schema_object: SchemaObject):
|
|
114
|
+
self.operation = operation
|
|
115
|
+
self.engine = engine
|
|
116
|
+
self.__select_list_columns = None
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def sql(self) -> str:
|
|
120
|
+
raise NotImplementedError("Subclasses must implement this method")
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def placeholders(self) -> Dict[str, SchemaObjectProperty]:
|
|
124
|
+
raise NotImplementedError("Subclasses must implement this method")
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def select_list_columns(self) -> List[str]:
|
|
128
|
+
if not self.__select_list_columns:
|
|
129
|
+
# Filter columns based on read permissions
|
|
130
|
+
self.__select_list_columns = list(self.selection_results.keys())
|
|
131
|
+
return self.__select_list_columns
|
|
132
|
+
|
|
133
|
+
def marshal_record(self, record: dict) -> dict:
|
|
134
|
+
"""
|
|
135
|
+
Converts a database record to an API-compatible dictionary,
|
|
136
|
+
removing properties the user is not allowed to read.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
record (dict): A dictionary representing a single database record.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
dict: A dictionary with only the properties the user is allowed to see.
|
|
143
|
+
"""
|
|
144
|
+
result = {}
|
|
145
|
+
for name, value in record.items():
|
|
146
|
+
if name in self.selection_results: # Check if the property is allowed
|
|
147
|
+
property = self.selection_results[name]
|
|
148
|
+
result[property.api_name] = property.convert_to_api_value(value)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def placeholder(self, property: SchemaObjectProperty, param: str = "") -> str:
|
|
152
|
+
if len(param) == 0:
|
|
153
|
+
param = property.api_name if property.api_name is not None else ""
|
|
154
|
+
|
|
155
|
+
if self.engine == "oracle":
|
|
156
|
+
if property.column_type == "date":
|
|
157
|
+
return f"TO_DATE(:{param}, 'YYYY-MM-DD')"
|
|
158
|
+
elif property.column_type == "datetime":
|
|
159
|
+
return f"TO_TIMESTAMP(:{param}, 'YYYY-MM-DD\"T\"HH24:MI:SS.FF')"
|
|
160
|
+
elif property.column_type == "time":
|
|
161
|
+
return f"TO_TIME(:{param}, 'HH24:MI:SS.FF')"
|
|
162
|
+
return f":{param}"
|
|
163
|
+
return f"%({param})s"
|
|
164
|
+
|
|
165
|
+
def check_permissions(
|
|
166
|
+
self,
|
|
167
|
+
permission_type: str,
|
|
168
|
+
permissions: Optional[dict],
|
|
169
|
+
properties: Dict[str, SchemaObjectProperty],
|
|
170
|
+
) -> Dict[str, SchemaObjectProperty]:
|
|
171
|
+
"""
|
|
172
|
+
Checks the user's permissions for the specified permission type.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
permission_type (str): The type of permission to check ("read" or "write").
|
|
176
|
+
properties (List[str], optional): Specific properties to check. If None,
|
|
177
|
+
all schema properties are checked.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
List[str]: A list of properties the user is permitted to access.
|
|
181
|
+
"""
|
|
182
|
+
# if permissions are not defined then no restrictions are applied
|
|
183
|
+
log.info(
|
|
184
|
+
f"checking permissions permission_type: {permission_type}, permissions: {permissions}"
|
|
185
|
+
)
|
|
186
|
+
if not permissions:
|
|
187
|
+
return properties
|
|
188
|
+
|
|
189
|
+
allowed_properties = {}
|
|
190
|
+
log.info(
|
|
191
|
+
"Input properties keys: %s",
|
|
192
|
+
list(properties.keys()) if properties else "None",
|
|
193
|
+
)
|
|
194
|
+
log.info("Operation roles: %s", self.operation.roles)
|
|
195
|
+
|
|
196
|
+
# Normalize permissions to provider-first format if needed
|
|
197
|
+
normalized_permissions = self._normalize_permissions(permissions)
|
|
198
|
+
|
|
199
|
+
# Permissions structure: {provider: {action: {role: rule}}}
|
|
200
|
+
# Use 'default' as the standard provider
|
|
201
|
+
provider_permissions = normalized_permissions.get("default", {})
|
|
202
|
+
action_permissions = provider_permissions.get(permission_type, {})
|
|
203
|
+
|
|
204
|
+
log.info(
|
|
205
|
+
"Extracted action permissions for %s: %s",
|
|
206
|
+
permission_type,
|
|
207
|
+
action_permissions,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
for role in self.operation.roles:
|
|
211
|
+
role_permissions = action_permissions.get(role, {})
|
|
212
|
+
log.info("role: %s, role_permissions: %s", role, role_permissions)
|
|
213
|
+
|
|
214
|
+
# If no permissions found for specific role, check wildcard "*"
|
|
215
|
+
if not role_permissions:
|
|
216
|
+
role_permissions = action_permissions.get("*", {})
|
|
217
|
+
log.info("Fallback to wildcard role '*': %s", role_permissions)
|
|
218
|
+
if not role_permissions:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Extract permission pattern from the rule
|
|
222
|
+
regex_pattern = self._extract_permission_pattern(role_permissions)
|
|
223
|
+
|
|
224
|
+
if regex_pattern:
|
|
225
|
+
# Use efficient regex dictionary filtering
|
|
226
|
+
role_allowed = self._filter_properties_by_regex(
|
|
227
|
+
properties, regex_pattern
|
|
228
|
+
)
|
|
229
|
+
log.info(
|
|
230
|
+
"role: %s, pattern: %s, matched: %s",
|
|
231
|
+
role,
|
|
232
|
+
regex_pattern,
|
|
233
|
+
list(role_allowed.keys()),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Merge with existing allowed properties
|
|
237
|
+
allowed_properties.update(role_allowed)
|
|
238
|
+
|
|
239
|
+
log.info("allowed_properties: %s", allowed_properties)
|
|
240
|
+
return allowed_properties
|
|
241
|
+
|
|
242
|
+
def _normalize_permissions(self, permissions: dict) -> dict:
|
|
243
|
+
"""
|
|
244
|
+
Normalize permissions from legacy role-first format to provider-first format.
|
|
245
|
+
|
|
246
|
+
Legacy format: {role: {action: rule}}
|
|
247
|
+
New format: {provider: {action: {role: rule}}}
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
permissions: Raw permissions dictionary
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Normalized permissions in provider-first format
|
|
254
|
+
"""
|
|
255
|
+
if not permissions or not isinstance(permissions, dict):
|
|
256
|
+
return {}
|
|
257
|
+
|
|
258
|
+
# Check if already in new format (has 'default' or other provider keys)
|
|
259
|
+
if "default" in permissions:
|
|
260
|
+
return permissions
|
|
261
|
+
|
|
262
|
+
# Check if this is legacy format: role -> action -> rule
|
|
263
|
+
is_legacy = False
|
|
264
|
+
for key, value in permissions.items():
|
|
265
|
+
if isinstance(value, dict):
|
|
266
|
+
# Check if value contains action keys
|
|
267
|
+
for action_key in value.keys():
|
|
268
|
+
if action_key in {"read", "write", "delete", "create", "update"}:
|
|
269
|
+
is_legacy = True
|
|
270
|
+
break
|
|
271
|
+
if is_legacy:
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
if not is_legacy:
|
|
275
|
+
# Assume it's already in provider format
|
|
276
|
+
return permissions
|
|
277
|
+
|
|
278
|
+
# Convert legacy format to new format
|
|
279
|
+
normalized = {"default": {"read": {}, "write": {}, "delete": {}}}
|
|
280
|
+
|
|
281
|
+
for role, actions in permissions.items():
|
|
282
|
+
if not isinstance(actions, dict):
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
for action, rule in actions.items():
|
|
286
|
+
# Normalize action names: create/update -> write
|
|
287
|
+
normalized_action = (
|
|
288
|
+
"write" if action in ("create", "update") else action
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
if normalized_action in normalized["default"]:
|
|
292
|
+
normalized["default"][normalized_action][role] = rule
|
|
293
|
+
|
|
294
|
+
log.info("Normalized legacy permissions: %s -> %s", permissions, normalized)
|
|
295
|
+
return normalized
|
|
296
|
+
|
|
297
|
+
def _filter_properties_by_regex(
|
|
298
|
+
self, properties: Dict[str, SchemaObjectProperty], regex_pattern: str
|
|
299
|
+
) -> Dict[str, SchemaObjectProperty]:
|
|
300
|
+
"""Filter dictionary properties using a regex pattern.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
properties: Dictionary of property name -> SchemaObjectProperty
|
|
304
|
+
regex_pattern: Regex pattern to match property names
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Dict[str, SchemaObjectProperty]: Filtered properties dict
|
|
308
|
+
"""
|
|
309
|
+
if not regex_pattern:
|
|
310
|
+
return {}
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
compiled_regex = re.compile(regex_pattern)
|
|
314
|
+
return {k: v for k, v in properties.items() if compiled_regex.match(k)}
|
|
315
|
+
except re.error as e:
|
|
316
|
+
log.warning("Invalid regex pattern '%s': %s", regex_pattern, e)
|
|
317
|
+
return {}
|
|
318
|
+
|
|
319
|
+
def _extract_permission_pattern(self, permission_rule) -> str:
|
|
320
|
+
"""Extract the regex pattern from a permission rule.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
permission_rule: Can be a string (regex) or dict with
|
|
324
|
+
'properties'/'fields' key
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
str: The regex pattern to match property names
|
|
328
|
+
"""
|
|
329
|
+
if isinstance(permission_rule, str):
|
|
330
|
+
return permission_rule
|
|
331
|
+
elif isinstance(permission_rule, dict):
|
|
332
|
+
# Support both 'properties' (preferred) and 'fields' (legacy)
|
|
333
|
+
return permission_rule.get("properties") or permission_rule.get(
|
|
334
|
+
"fields", ""
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
return ""
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def selection_results(self) -> Dict:
|
|
341
|
+
raise NotImplementedError()
|
|
342
|
+
|
|
343
|
+
def generate_sql_condition(
|
|
344
|
+
self, property: SchemaObjectProperty, value, prefix: Optional[str] = None
|
|
345
|
+
) -> str:
|
|
346
|
+
operand = "="
|
|
347
|
+
if isinstance(value, str):
|
|
348
|
+
parts = value.split("::", 1)
|
|
349
|
+
operand = RELATIONAL_TYPES.get(parts[0], "=") if len(parts) > 1 else "="
|
|
350
|
+
value_str = parts[-1]
|
|
351
|
+
elif isinstance(value, (datetime, date)):
|
|
352
|
+
value_str = value.isoformat()
|
|
353
|
+
else:
|
|
354
|
+
value_str = str(value)
|
|
355
|
+
|
|
356
|
+
column = f"{prefix}.{property.column_name}" if prefix else property.column_name
|
|
357
|
+
placeholder_name = (
|
|
358
|
+
f"{prefix}_{property.api_name}" if prefix else property.api_name
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if operand in ["between", "not-between"]:
|
|
362
|
+
value_set = value_str.split(",")
|
|
363
|
+
sql = f"{column} {'NOT ' if operand == 'not-between' else ''}BETWEEN {self.placeholder(property, f'{placeholder_name}_1')} AND {self.placeholder(property, f'{prefix}_{property.api_name}_2')}" # noqa E501
|
|
364
|
+
elif operand in ["in", "not-in"]:
|
|
365
|
+
value_set = value_str.split(",")
|
|
366
|
+
assignments = [
|
|
367
|
+
self.placeholder(property, f"{placeholder_name}_{index}")
|
|
368
|
+
for index, _ in enumerate(value_set)
|
|
369
|
+
]
|
|
370
|
+
sql = f"{column} {'NOT ' if operand == 'not-in' else ''}IN ({', '.join(assignments)})" # noqa E501
|
|
371
|
+
else:
|
|
372
|
+
sql = f"{column} {operand} {self.placeholder(property, str(placeholder_name))}"
|
|
373
|
+
return sql
|
|
374
|
+
|
|
375
|
+
def generate_placeholders(
|
|
376
|
+
self, property: SchemaObjectProperty, value, prefix: Optional[str] = None
|
|
377
|
+
) -> dict:
|
|
378
|
+
operand = "="
|
|
379
|
+
|
|
380
|
+
if isinstance(value, str):
|
|
381
|
+
parts = value.split("::", 1)
|
|
382
|
+
operand = RELATIONAL_TYPES.get(parts[0], "=") if len(parts) > 1 else "="
|
|
383
|
+
value_str = parts[-1]
|
|
384
|
+
elif isinstance(value, (datetime, date)):
|
|
385
|
+
value_str = value.isoformat()
|
|
386
|
+
else:
|
|
387
|
+
value_str = str(value)
|
|
388
|
+
|
|
389
|
+
placeholder_name = (
|
|
390
|
+
f"{prefix}_{property.api_name}" if prefix else property.api_name
|
|
391
|
+
)
|
|
392
|
+
placeholders = {}
|
|
393
|
+
|
|
394
|
+
if operand in ["between", "not-between"]:
|
|
395
|
+
value_set = value_str.split(",")
|
|
396
|
+
placeholders = {
|
|
397
|
+
f"{placeholder_name}_1": property.convert_to_db_value(value_set[0]),
|
|
398
|
+
f"{placeholder_name}_2": property.convert_to_db_value(value_set[1]),
|
|
399
|
+
}
|
|
400
|
+
elif operand in ["in", "not-in"]:
|
|
401
|
+
value_set = value_str.split(",")
|
|
402
|
+
for index, item in enumerate(value_set):
|
|
403
|
+
item_name = f"{placeholder_name}_{index}"
|
|
404
|
+
placeholders[item_name] = property.convert_to_db_value(item)
|
|
405
|
+
else:
|
|
406
|
+
placeholders = {placeholder_name: property.convert_to_db_value(value_str)}
|
|
407
|
+
|
|
408
|
+
return placeholders
|
|
409
|
+
|
|
410
|
+
def search_value_assignment(
|
|
411
|
+
self, property: SchemaObjectProperty, value, prefix: Optional[str] = None
|
|
412
|
+
) -> tuple[str, dict]:
|
|
413
|
+
sql_condition = self.generate_sql_condition(property, value, prefix)
|
|
414
|
+
placeholders = self.generate_placeholders(property, value, prefix)
|
|
415
|
+
return sql_condition, placeholders
|
|
416
|
+
|
|
417
|
+
def extract_injected_value(self, inject_value_source: str):
|
|
418
|
+
"""
|
|
419
|
+
Extract value from various sources for property injection.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
inject_value_source: Source specification (e.g., "claim:sub",
|
|
423
|
+
"timestamp", "uuid", "env:VAR_NAME")
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
The extracted value, or None if source not found
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ApplicationException: If source format is invalid
|
|
430
|
+
"""
|
|
431
|
+
import os
|
|
432
|
+
import uuid
|
|
433
|
+
|
|
434
|
+
if inject_value_source.startswith("claim:"):
|
|
435
|
+
claim_key = inject_value_source[6:]
|
|
436
|
+
return self.operation.claims.get(claim_key)
|
|
437
|
+
elif inject_value_source == "timestamp":
|
|
438
|
+
return datetime.utcnow().isoformat()
|
|
439
|
+
elif inject_value_source == "date":
|
|
440
|
+
return date.today().isoformat()
|
|
441
|
+
elif inject_value_source == "uuid":
|
|
442
|
+
return str(uuid.uuid4())
|
|
443
|
+
elif inject_value_source.startswith("env:"):
|
|
444
|
+
env_key = inject_value_source[4:]
|
|
445
|
+
return os.environ.get(env_key)
|
|
446
|
+
else:
|
|
447
|
+
raise ApplicationException(
|
|
448
|
+
400, f"Unknown inject value source: {inject_value_source}"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
class SQLSchemaQueryHandler(SQLQueryHandler):
|
|
453
|
+
schema_object: SchemaObject
|
|
454
|
+
|
|
455
|
+
def __init__(
|
|
456
|
+
self, operation: Operation, schema_object: SchemaObject, engine: str
|
|
457
|
+
) -> None:
|
|
458
|
+
super().__init__(operation, engine)
|
|
459
|
+
self.schema_object = schema_object
|
|
460
|
+
self.single_table = self.__single_table()
|
|
461
|
+
self.__select_list = None
|
|
462
|
+
self.__selection_result_map = None
|
|
463
|
+
self.search_placeholders = dict()
|
|
464
|
+
self.store_placeholders = dict()
|
|
465
|
+
self.active_prefixes = set()
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def sql(self) -> str:
|
|
469
|
+
raise NotImplementedError("Subclasses should implement this method")
|
|
470
|
+
|
|
471
|
+
@property
|
|
472
|
+
def placeholders(self) -> dict:
|
|
473
|
+
return {**self.search_placeholders, **self.store_placeholders}
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def prefix_map(self) -> Dict[str, str]:
|
|
477
|
+
if not hasattr(self, "_prefix_map"):
|
|
478
|
+
self._prefix_map = {}
|
|
479
|
+
for entity in [
|
|
480
|
+
self.schema_object.api_name,
|
|
481
|
+
*self.schema_object.relations.keys(),
|
|
482
|
+
]:
|
|
483
|
+
entity_lower = entity.lower()
|
|
484
|
+
for i in range(1, len(entity_lower) + 1):
|
|
485
|
+
substring = entity_lower[:i]
|
|
486
|
+
if (
|
|
487
|
+
substring not in self._prefix_map.values()
|
|
488
|
+
and substring not in SQL_RESERVED_WORDS
|
|
489
|
+
):
|
|
490
|
+
self._prefix_map[entity] = substring
|
|
491
|
+
break
|
|
492
|
+
return self._prefix_map
|
|
493
|
+
|
|
494
|
+
def __single_table(self) -> bool:
|
|
495
|
+
if len(self.prefix_map) == 1 or self.operation.action == "create":
|
|
496
|
+
return True
|
|
497
|
+
if ":" in self.operation.metadata_params.get("properties", ""):
|
|
498
|
+
return False
|
|
499
|
+
for param in self.operation.query_params.keys():
|
|
500
|
+
if "." in param:
|
|
501
|
+
return False
|
|
502
|
+
return True
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def select_list(self) -> str:
|
|
506
|
+
if not self.__select_list:
|
|
507
|
+
self.__select_list = ", ".join(self.select_list_columns)
|
|
508
|
+
return self.__select_list
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def table_expression(self) -> str:
|
|
512
|
+
return self.schema_object.qualified_name or ""
|
|
513
|
+
|
|
514
|
+
@property
|
|
515
|
+
def selection_results(self) -> Dict:
|
|
516
|
+
"""
|
|
517
|
+
Filters the schema properties to include only those the user is allowed to read.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
dict: A dictionary of allowed schema properties.
|
|
521
|
+
"""
|
|
522
|
+
log.info("selection_result")
|
|
523
|
+
if not hasattr(self, "__selection_results"):
|
|
524
|
+
log.info("prefix_map: %s", self.prefix_map)
|
|
525
|
+
filters = self.operation.metadata_params.get("_properties", ".*").split()
|
|
526
|
+
allowed_properties = self.check_permissions(
|
|
527
|
+
"read", self.schema_object.permissions, self.schema_object.properties
|
|
528
|
+
)
|
|
529
|
+
self.__selection_results = self.filter_and_prefix_keys(
|
|
530
|
+
filters, allowed_properties
|
|
531
|
+
)
|
|
532
|
+
return self.__selection_results
|
|
533
|
+
|
|
534
|
+
def _has_soft_delete_conflicts(self) -> Dict[str, bool]:
|
|
535
|
+
"""
|
|
536
|
+
Detect conflicts between query parameters and soft delete exclusions.
|
|
537
|
+
|
|
538
|
+
Returns dict mapping property names to whether they have conflicts.
|
|
539
|
+
A conflict occurs when a query explicitly requests values that would
|
|
540
|
+
be filtered out by soft delete rules.
|
|
541
|
+
"""
|
|
542
|
+
conflicts = {}
|
|
543
|
+
soft_delete_props = self.schema_object.get_soft_delete_properties()
|
|
544
|
+
|
|
545
|
+
for prop_name, prop in soft_delete_props.items():
|
|
546
|
+
strategy = prop.get_soft_delete_strategy()
|
|
547
|
+
config = prop.get_soft_delete_config()
|
|
548
|
+
|
|
549
|
+
# Check if this property is queried
|
|
550
|
+
query_value = self.operation.query_params.get(prop_name)
|
|
551
|
+
if not query_value:
|
|
552
|
+
conflicts[prop_name] = False
|
|
553
|
+
continue
|
|
554
|
+
|
|
555
|
+
has_conflict = False
|
|
556
|
+
|
|
557
|
+
if strategy == "exclude_values":
|
|
558
|
+
excluded_values = config.get("values", [])
|
|
559
|
+
# Check if query value matches any excluded value
|
|
560
|
+
if isinstance(query_value, list):
|
|
561
|
+
# Handle IN queries: ?status=archived,deleted
|
|
562
|
+
has_conflict = any(val in excluded_values for val in query_value)
|
|
563
|
+
else:
|
|
564
|
+
# Handle single value: ?status=archived
|
|
565
|
+
has_conflict = query_value in excluded_values
|
|
566
|
+
|
|
567
|
+
elif strategy == "boolean_flag":
|
|
568
|
+
active_value = config.get("active_value", True)
|
|
569
|
+
# Conflict if explicitly querying for inactive value
|
|
570
|
+
if isinstance(query_value, str):
|
|
571
|
+
# Convert string to boolean for comparison
|
|
572
|
+
query_bool = query_value.lower() in ("true", "1", "yes")
|
|
573
|
+
has_conflict = query_bool != active_value
|
|
574
|
+
elif isinstance(query_value, bool):
|
|
575
|
+
has_conflict = query_value != active_value
|
|
576
|
+
|
|
577
|
+
elif strategy == "null_check":
|
|
578
|
+
# Conflict if explicitly querying for null/None values
|
|
579
|
+
# This might be "null", "none", empty string, etc.
|
|
580
|
+
if isinstance(query_value, str):
|
|
581
|
+
has_conflict = query_value.lower() in ("null", "none", "")
|
|
582
|
+
else:
|
|
583
|
+
has_conflict = query_value is None
|
|
584
|
+
|
|
585
|
+
conflicts[prop_name] = has_conflict
|
|
586
|
+
|
|
587
|
+
return conflicts
|
|
588
|
+
|
|
589
|
+
def _soft_delete_where_clause(self) -> str:
|
|
590
|
+
"""
|
|
591
|
+
Generate WHERE clause to filter out soft-deleted records.
|
|
592
|
+
|
|
593
|
+
Uses smart conflict detection - if query explicitly requests
|
|
594
|
+
soft-deleted values, those filters are skipped to allow access.
|
|
595
|
+
"""
|
|
596
|
+
conditions = []
|
|
597
|
+
|
|
598
|
+
soft_delete_props = self.schema_object.get_soft_delete_properties()
|
|
599
|
+
conflicts = self._has_soft_delete_conflicts()
|
|
600
|
+
|
|
601
|
+
for prop_name, prop in soft_delete_props.items():
|
|
602
|
+
# Skip filtering if user explicitly queries for soft-deleted values
|
|
603
|
+
if conflicts.get(prop_name, False):
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
strategy = prop.get_soft_delete_strategy()
|
|
607
|
+
config = prop.get_soft_delete_config()
|
|
608
|
+
column_name = prop.column_name
|
|
609
|
+
|
|
610
|
+
if strategy == "null_check":
|
|
611
|
+
conditions.append(f"{column_name} IS NULL")
|
|
612
|
+
elif strategy == "boolean_flag":
|
|
613
|
+
active_value = config.get("active_value", True)
|
|
614
|
+
conditions.append(f"{column_name} = {active_value}")
|
|
615
|
+
elif strategy == "exclude_values":
|
|
616
|
+
excluded_values = config.get("values", [])
|
|
617
|
+
if excluded_values:
|
|
618
|
+
# Format values for SQL IN clause
|
|
619
|
+
formatted_values = ", ".join(
|
|
620
|
+
f"'{val}'" if isinstance(val, str) else str(val)
|
|
621
|
+
for val in excluded_values
|
|
622
|
+
)
|
|
623
|
+
conditions.append(f"{column_name} NOT IN ({formatted_values})")
|
|
624
|
+
|
|
625
|
+
return " AND ".join(conditions) if conditions else ""
|
|
626
|
+
|
|
627
|
+
@property
|
|
628
|
+
def search_condition(self) -> str:
|
|
629
|
+
self.search_placeholders = {}
|
|
630
|
+
conditions = []
|
|
631
|
+
|
|
632
|
+
# Add soft delete filtering first
|
|
633
|
+
soft_delete_filter = self._soft_delete_where_clause()
|
|
634
|
+
if soft_delete_filter:
|
|
635
|
+
conditions.append(soft_delete_filter)
|
|
636
|
+
|
|
637
|
+
for name, value in self.operation.query_params.items():
|
|
638
|
+
if "." in name:
|
|
639
|
+
raise ApplicationException(
|
|
640
|
+
400, "Selection on relations is not supported"
|
|
641
|
+
)
|
|
642
|
+
property = self.schema_object.properties.get(name)
|
|
643
|
+
if not property:
|
|
644
|
+
raise ApplicationException(
|
|
645
|
+
500, f"Search condition column not found {name}"
|
|
646
|
+
)
|
|
647
|
+
if (
|
|
648
|
+
self.operation.action != "read"
|
|
649
|
+
and isinstance(value, str)
|
|
650
|
+
and re.match(
|
|
651
|
+
r"^(lt|le|eq|ne|gt|ge|in|not-in|between|not-between)::(.+)$", value
|
|
652
|
+
)
|
|
653
|
+
and self.schema_object.concurrency_property
|
|
654
|
+
):
|
|
655
|
+
raise ApplicationException(
|
|
656
|
+
400,
|
|
657
|
+
"Concurrency settings prohibit multi-record updates "
|
|
658
|
+
+ str(self.schema_object.api_name)
|
|
659
|
+
+ ", property: "
|
|
660
|
+
+ str(property.api_name),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
assignment, holders = self.search_value_assignment(property, value)
|
|
664
|
+
conditions.append(assignment)
|
|
665
|
+
self.search_placeholders.update(holders)
|
|
666
|
+
return f" WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
667
|
+
|
|
668
|
+
def filter_and_prefix_keys(
|
|
669
|
+
self, regex_list: List[str], properties: dict, prefix: Optional[str] = None
|
|
670
|
+
) -> dict:
|
|
671
|
+
"""
|
|
672
|
+
Accepts a prefix string, list of regular expressions, and a dictionary.
|
|
673
|
+
Returns a new dictionary containing items whose keys match any of the
|
|
674
|
+
regular expressions, with the prefix string prepended to the key
|
|
675
|
+
values of the dictionary.
|
|
676
|
+
|
|
677
|
+
Parameters:
|
|
678
|
+
- prefix (str): The prefix string to prepend to the key values.
|
|
679
|
+
- regex_list (list of str): The list of regular expression patterns
|
|
680
|
+
to match keys.
|
|
681
|
+
- properties (dict): The input properties.
|
|
682
|
+
|
|
683
|
+
Returns:
|
|
684
|
+
- dict: A new dictionary containing filtered items with modified key values.
|
|
685
|
+
"""
|
|
686
|
+
filtered_dict = {}
|
|
687
|
+
compiled_regexes = [re.compile(regex) for regex in regex_list]
|
|
688
|
+
for key, value in properties.items():
|
|
689
|
+
for pattern in compiled_regexes:
|
|
690
|
+
if pattern.match(key):
|
|
691
|
+
filtered_dict[f"{prefix}.{key}" if prefix else key] = value
|
|
692
|
+
self.active_prefixes.add(prefix)
|
|
693
|
+
break
|
|
694
|
+
return filtered_dict
|
|
695
|
+
|
|
696
|
+
def concurrency_generator(self, property: SchemaObjectProperty) -> str:
|
|
697
|
+
if property.api_type == "date-time":
|
|
698
|
+
return "CURRENT_TIMESTAMP"
|
|
699
|
+
elif property.api_type == "integer":
|
|
700
|
+
return f"{property.column_name} + 1"
|
|
701
|
+
elif property.api_type in ["string", "uuid"]:
|
|
702
|
+
if self.engine == "oracle":
|
|
703
|
+
return "SYS_GUID()"
|
|
704
|
+
if self.engine == "mysql":
|
|
705
|
+
return "UUID()"
|
|
706
|
+
return "gen_random_uuid()"
|
|
707
|
+
raise ApplicationException(
|
|
708
|
+
500,
|
|
709
|
+
(
|
|
710
|
+
"Concurrency control property is unrecognized type"
|
|
711
|
+
+ f"name: {property.api_name}, type: {property.api_type}"
|
|
712
|
+
),
|
|
713
|
+
)
|