fix(crud): strip view-only join columns from order_by_li to prevent ambiguous account_id in WHERE

Sorting by join-derived columns (e.g. event_presenter_family_name on v_event_file)
caused MariaDB to expand the view's JOIN inline, making the unqualified account_id
clause from sql_and_qry_part ambiguous — resulting in a 400 SQL error. filter_order_by
now accepts raw_table_name and validates ORDER BY columns against the physical table
only; join-only columns are silently stripped. Also switches filter_order_by off the
global db connection to engine.connect() context managers. Updated all four call sites
in api_crud_v3.py and api_crud_v3_nested.py.

Docs: add order_by_li raw-table limitation and direct download link patterns to
GUIDE__AE_API_V3_for_Frontend.md; record fix in TODO__Agents.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-10 13:33:09 -04:00
parent 22e5a3c3fd
commit 2429a1f731
5 changed files with 90 additions and 19 deletions

View File

@@ -140,30 +140,37 @@ def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountCo
return forced
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]:
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None, raw_table_name: str = None) -> Optional[Dict[str, str]]:
"""
Sanitize Sorting Parameters.
Prevents SQL injection and logic errors by validating that requested sort columns
actually exist in the Pydantic model and/or the database table.
When raw_table_name is provided (the physical table, not the view), columns are
validated against it instead of table_name. This prevents view-only join columns
(e.g. event_presenter_family_name on v_event_file) from reaching ORDER BY —
those columns cause MariaDB to expand the view's JOIN inline, making unqualified
WHERE references like `account_id` ambiguous across joined tables.
"""
if not order_by_li or not isinstance(order_by_li, dict) or not model:
return order_by_li
if not hasattr(model, '__fields__'):
return order_by_li
model_fields = set(model.__fields__.keys())
model_fields.update({f.alias for f in model.__fields__.values() if f.alias})
filtered = {k: v for k, v in order_by_li.items() if k in model_fields}
if table_name and filtered:
from app.db_sql import db
check_table = raw_table_name or table_name
if check_table and filtered:
from app import lib_sql_core
from sqlalchemy import text
final_filtered = {}
for column in filtered:
try:
# Lightweight check to see if column exists in SQL
db.execute(text(f"SELECT `{column}` FROM `{table_name}` LIMIT 0"))
with lib_sql_core.engine.connect() as conn:
conn.execute(text(f"SELECT `{column}` FROM `{check_table}` LIMIT 0"))
final_filtered[column] = filtered[column]
except Exception:
pass