Feature: Implement advanced POST-based search with recursive logical grouping and unique parameterization (Verified Working).

This commit is contained in:
Scott Idem
2026-01-02 17:09:29 -05:00
parent 7b9ec69e7b
commit 2f24a5588b
4 changed files with 200 additions and 6 deletions

View File

@@ -1,4 +1,5 @@
import datetime, json, pytz, random, redis, secrets import datetime, json, pytz, random, redis, secrets
from typing import Any, Optional
from timeit import default_timer as timer from timeit import default_timer as timer
from app.config import settings from app.config import settings
@@ -614,6 +615,7 @@ def sql_select(
and_like_dict: dict|None = None, and_like_dict: dict|None = None,
or_like_dict: dict|None = None, or_like_dict: dict|None = None,
and_in_dict_li: dict|None = None, and_in_dict_li: dict|None = None,
search_query: Any|None = None, # NEW 2026-01-02 (SearchQuery model)
fulltext_qry_field_li: list|None = None, # ['field_name_1', 'field_name_2'] fulltext_qry_field_li: list|None = None, # ['field_name_1', 'field_name_2']
fulltext_qry_str: str|None = None, # 'search string' fulltext_qry_str: str|None = None, # 'search string'
order_by_li: dict|None = None, # {"the_field_name": "DESC"} order_by_li: dict|None = None, # {"the_field_name": "DESC"}
@@ -715,17 +717,21 @@ def sql_select(
else: else:
sql_hidden = '' sql_hidden = ''
# data['hidden'] = '' # data['hidden'] = ''
# if sql_enabled:
# data['enable'] = sql_enable_part(table_name=table_name, enabled=enabled) # Reasonably safe return str and bool
# if sql_hidden: sql_search_qry = ''
# sql_hidden = sql_hidden_part(table_name=table_name, hidden=hidden) # Reasonably safe return str and bool if search_query:
# sql_hidden, data['hidden'] = sql_hidden_part(table_name=table_name, hidden=hidden) # Reasonably safe return str and bool log.info('Creating partial SQL string for complex SearchQuery.')
sql_search_qry, data_search = sql_search_qry_part(search_query)
data = {**data, **data_search}
sql = text( sql = text(
f""" f"""
SELECT * SELECT *
FROM `{table_name}` FROM `{table_name}`
WHERE 1=1
{sql_search_qry}
{sql_enabled}
{sql_hidden}
{sql_order_by} {sql_order_by}
{sql_limit_offset} {sql_limit_offset}
; ;
@@ -819,6 +825,12 @@ def sql_select(
# NOTE: Merge the data_qry result with the data dict # NOTE: Merge the data_qry result with the data dict
data = {**data, **data_qry} data = {**data, **data_qry}
sql_search_qry = ''
if search_query:
log.info('Creating partial SQL string for complex SearchQuery.')
sql_search_qry, data_search = sql_search_qry_part(search_query)
data = {**data, **data_search}
# # NOTE: Version 3 of the fulltext search # # NOTE: Version 3 of the fulltext search
# sql_fulltext_match_against = '' # sql_fulltext_match_against = ''
# log.debug(fulltext_qry_dict) # log.debug(fulltext_qry_dict)
@@ -883,6 +895,7 @@ def sql_select(
{sql_and_like} {sql_and_like}
{sql_or_like} {sql_or_like}
{sql_and_in_dict_li} {sql_and_in_dict_li}
{sql_search_qry}
{sql_enabled} {sql_enabled}
{sql_hidden} {sql_hidden}
{sql_order_by} {sql_order_by}
@@ -2089,3 +2102,96 @@ def sql_limit_offset_part(limit: int, offset: int = 0) -> bool|str:
else: else:
return False return False
# ### END ### API DB SQL Methods ### sql_limit_offset_part() ### # ### END ### API DB SQL Methods ### sql_limit_offset_part() ###
# ### BEGIN ### API DB SQL Methods ### sql_search_qry_part() ###
# NEW 2026-01-02
# Updated to support complex POST-based searches with recursive logical grouping.
@logger_reset
def sql_search_qry_part(
search_query: Any, # SearchQuery model instance
) -> tuple[str, dict]:
"""
Recursively builds a SQL WHERE clause from a SearchQuery model.
Uses unique parameter names to prevent collisions.
"""
log.setLevel(logging.INFO)
log.debug(locals())
data = {}
param_counter = [0] # List used as a reference for unique parameter names
def get_param_name():
param_counter[0] += 1
return f"sp_{param_counter[0]}"
operator_map = {
"eq": "=",
"ne": "!=",
"gt": ">",
"gte": ">=",
"lt": "<",
"lte": "<=",
"like": "LIKE",
"in": "IN",
"is_null": "IS NULL",
"is_not_null": "IS NOT NULL"
}
def process_node(query_node) -> str:
clauses = []
# Process 'and' filters
if hasattr(query_node, 'and_filters') and query_node.and_filters:
and_clauses = []
for item in query_node.and_filters:
if hasattr(item, 'field'): # SearchFilter
clause, item_data = process_filter(item)
and_clauses.append(clause)
data.update(item_data)
else: # Nested SearchQuery
and_clauses.append(f"({process_node(item)})")
if and_clauses:
clauses.append(f"({' AND '.join(and_clauses)})")
# Process 'or' filters
if hasattr(query_node, 'or_filters') and query_node.or_filters:
or_clauses = []
for item in query_node.or_filters:
if hasattr(item, 'field'): # SearchFilter
clause, item_data = process_filter(item)
or_clauses.append(clause)
data.update(item_data)
else: # Nested SearchQuery
or_clauses.append(f"({process_node(item)})")
if or_clauses:
clauses.append(f"({' OR '.join(or_clauses)})")
return ' AND '.join(clauses)
def process_filter(f) -> tuple[str, dict]:
sql_op = operator_map.get(f.op.lower())
if not sql_op:
raise ValueError(f"Unsupported search operator: {f.op}")
filter_data = {}
if f.op.lower() in ['is_null', 'is_not_null']:
clause = f"`{f.field}` {sql_op}"
elif f.op.lower() == 'in':
p_name = get_param_name()
# IN operator requires a tuple or list
clause = f"`{f.field}` IN (:{p_name})"
filter_data[p_name] = f.value
else:
p_name = get_param_name()
clause = f"`{f.field}` {sql_op} :{p_name}"
filter_data[p_name] = f.value
return clause, filter_data
# Initial processing
sql_where = process_node(search_query)
if sql_where:
return f"AND ({sql_where})", data
return "", {}
# ### END ### API DB SQL Methods ### sql_search_qry_part() ###

View File

@@ -1,6 +1,6 @@
import datetime, pytz import datetime, pytz
from typing import Dict, List, Optional, Set, Union from typing import Any, Dict, List, Optional, Set, Union
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
from app.db_sql import redis_lookup_id_random from app.db_sql import redis_lookup_id_random
@@ -11,6 +11,28 @@ log = logging.getLogger(__name__)
from app.models.common_field_schema import base_fields, default_num_bytes from app.models.common_field_schema import base_fields, default_num_bytes
# ### BEGIN ### API Search Models ###
class SearchFilter(BaseModel):
"""
Represents a single filter condition.
Example: {"field": "price", "op": "gt", "value": 100}
"""
field: str
op: str # eq, ne, gt, gte, lt, lte, like, in, is_null, is_not_null
value: Optional[Any] = None
class SearchQuery(BaseModel):
"""
Represents a complex search query with optional logical grouping.
"""
and_filters: Optional[List[Union[SearchFilter, 'SearchQuery']]] = Field(None, alias="and")
or_filters: Optional[List[Union[SearchFilter, 'SearchQuery']]] = Field(None, alias="or")
# Support recursive models in Pydantic v1
SearchQuery.update_forward_refs()
# ### END ### API Search Models ###
# ### BEGIN ### API CRUD Models ### Fundraising_Cfg_Base() ### # ### BEGIN ### API CRUD Models ### Fundraising_Cfg_Base() ###
class Api_Crud_Base(BaseModel): class Api_Crud_Base(BaseModel):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL

View File

@@ -16,6 +16,7 @@ from app.lib_general_v3 import (
DelayParams, get_delay_params DelayParams, get_delay_params
) )
from app.models.response_models import * from app.models.response_models import *
from app.models.api_crud_models import SearchQuery
from app.ae_obj_types_def import obj_type_kv_li from app.ae_obj_types_def import obj_type_kv_li
from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_update, sql_delete, get_id_random from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_update, sql_delete, get_id_random
@@ -213,6 +214,67 @@ async def get_obj_li(
return mk_resp(data=[], status_code=200, response=response) # Return empty list on no results return mk_resp(data=[], status_code=200, response=response) # Return empty list on no results
@router.post('/{obj_type_l1}/search', response_model=Resp_Body_Base, tags=['CRUD v3 Search (Dev)'])
async def search_obj_li(
response: Response,
obj_type_l1: str,
search_query: SearchQuery,
order_by_li: Optional[str] = Query(None),
account: AccountContext = Depends(get_account_context),
pagination: PaginationParams = Depends(get_pagination_params),
status_filter: StatusFilterParams = Depends(get_status_filter_params),
serialization: SerializationParams = Depends(get_serialization_params),
delay: DelayParams = Depends(get_delay_params),
):
"""
Search top-level objects using a complex SearchQuery in the POST body.
This endpoint supports:
- Recursive AND/OR grouping
- Operators: eq, ne, gt, gte, lt, lte, like, in, is_null, is_not_null
- Large filters that would exceed URL length limits.
"""
if delay.sleep_time_s > 0:
await asyncio.sleep(delay.sleep_time_s)
log.setLevel(logging.WARNING)
log.debug(locals())
if order_by_li:
order_by_li = json.loads(order_by_li)
obj_name = obj_type_l1
if obj_name not in obj_type_kv_li:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.")
obj_cfg = obj_type_kv_li[obj_name]
table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl'))
if not table_name or not base_name:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.")
sql_result = sql_select(
table_name=table_name,
enabled=status_filter.enabled,
hidden=status_filter.hidden,
search_query=search_query,
order_by_li=order_by_li,
limit=pagination.limit,
offset=pagination.offset,
as_list=True,
)
if sql_result:
resp_data_li = []
for record in sql_result:
resp_data = base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
resp_data_li.append(resp_data)
return mk_resp(data=resp_data_li, response=response)
else:
return mk_resp(data=[], status_code=200, response=response)
@router.post('/{obj_type_l1}/', response_model=Resp_Body_Base) @router.post('/{obj_type_l1}/', response_model=Resp_Body_Base)
async def post_obj( async def post_obj(
request: Request, request: Request,

View File

@@ -16,6 +16,10 @@ The V3 CRUD API (`/v3/crud/`) is designed to run in parallel with legacy V1 and
- `DelayParams`: Facilitates optional latency simulation for testing. - `DelayParams`: Facilitates optional latency simulation for testing.
- **Non-blocking Delay**: Uses `await asyncio.sleep()` to simulate network latency without blocking the Gunicorn worker's event loop. - **Non-blocking Delay**: Uses `await asyncio.sleep()` to simulate network latency without blocking the Gunicorn worker's event loop.
- **Data-Driven Configuration**: Uses the modern format in `app/ae_obj_types_def.py` to map objects to tables and models. - **Data-Driven Configuration**: Uses the modern format in `app/ae_obj_types_def.py` to map objects to tables and models.
- **Advanced Search (POST)**: Supports complex, nested filtering via `POST /v3/crud/{obj_type}/search`.
- Recursive AND/OR logic.
- Full operator support: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `in`, `is_null`, `is_not_null`.
- Safe parameterization using unique generated names (e.g., `:sp_1`) to prevent collisions.
## 2. Backward Compatibility Strategy ## 2. Backward Compatibility Strategy