diff --git a/admin/development/test_v3_search.py b/admin/development/test_v3_search.py index 7c9b827..8d40a34 100644 --- a/admin/development/test_v3_search.py +++ b/admin/development/test_v3_search.py @@ -11,7 +11,7 @@ headers = { "Content-Type": "application/json" } -def test_search(obj_type, query, description): +def test_search(obj_type, query, description, params=None): """ Helper to run a search test and print results. """ @@ -19,7 +19,8 @@ def test_search(obj_type, query, description): url = f"{BASE_URL}/{obj_type}/search" try: - response = requests.post(url, headers=headers, json=query) + response = requests.post(url, headers=headers, json=query, params=params) + print(f"URL: {response.url}") print(f"Status Code: {response.status_code}") data = response.json() @@ -47,36 +48,37 @@ def test_search(obj_type, query, description): if __name__ == "__main__": print(f"Starting Aether V3 Search Tests against {BASE_URL}\n") - # 1. Simple Equality - # Tests 'eq' operator on 'journal' - query_eq = { - "and": [ - {"field": "enable", "op": "eq", "value": True} - ] + # 1. Standardized Global Search (q property) + query_q = { + "q": "%" # Standardized full-text search across indexed columns } - test_search("journal", query_eq, "Simple Equality (enable=True)") + test_search("journal", query_q, "Global Search (q property)") - # 2. LIKE search - # Tests 'like' operator with wildcards on 'name' field (corrected from 'title') - # Using '%' to catch any journals with names - query_like = { - "and": [ - {"field": "name", "op": "like", "value": "%"} - ] + # 2. Hybrid Filtering (POST Body + Query Params) + query_simple = { + "and": [{"field": "name", "op": "like", "value": "%"}] } - test_search("journal", query_like, "LIKE search (name LIKE '%')") + params_hybrid = {"enabled": "disabled"} # Should find disabled journals if any + test_search("journal", query_simple, "Hybrid Filtering (Body + ?enabled=disabled)", params=params_hybrid) - # 3. Numeric Comparison - # Tests 'gt' (Greater Than) operator - query_gt = { - "and": [ - {"field": "id", "op": "gt", "value": 0} - ] + # 3. View Selection (view parameter) + # Testing with 'site_domain' which has 'tbl_alt' defined as 'v_site_domain_fqdn_id' + query_site = {"q": "%"} + params_view = {"view": "alt"} + test_search("site_domain", query_site, "View Selection (view=alt)", params=params_view) + + # 4. Explicit Parent Filtering + # Testing 'journal_entry' belonging to a journal (journal_id=1 exists based on previous tests) + # We'll use the 'for_obj_type' and 'for_obj_id' as query params + # Assuming id_random 'DCAV-06-35-85' exists for journal_id 1 + query_empty = {} + params_parent = { + "for_obj_type": "journal", + "for_obj_id": "DCAV-06-35-85" } - test_search("journal", query_gt, "Numeric Comparison (internal id > 0)") + test_search("journal_entry", query_empty, "Explicit Parent Filtering (?for_obj_type=journal)", params=params_parent) - # 4. Complex Nested Logic (AND + OR) - # Tests recursive grouping: (enable=True) AND (name LIKE % % OR summary IS NOT NULL) + # 5. Complex Nested Logic (Recap) query_nested = { "and": [ {"field": "enable", "op": "eq", "value": True}, @@ -90,48 +92,4 @@ if __name__ == "__main__": } test_search("journal", query_nested, "Nested Logic (AND + OR group)") - # 5. IN List - # Tests 'in' operator with a list of values - # Based on previous results, we know id 1 exists for journal - query_in = { - "and": [ - {"field": "journal_id", "op": "in", "value": [1, 2, 3]} - ] - } - test_search("journal_entry", query_in, "IN List search (journal_entry with journal_id in [1,2,3])") - - # 6. NULL Check - # Tests 'is_null' operator - query_null = { - "and": [ - {"field": "notes", "op": "is_null"} - ] - } - test_search("site", query_null, "Null Check (notes is NULL)") - - # 7. Event Search - query_event = { - "and": [ - {"field": "enable", "op": "eq", "value": True}, - {"field": "name", "op": "like", "value": "%"} - ] - } - test_search("event", query_event, "Event Search (enable=True and has name)") - - # 8. Event Badge Search - query_badge = { - "and": [ - {"field": "event_id", "op": "gt", "value": 0} - ] - } - test_search("event_badge", query_badge, "Event Badge Search (event_id > 0)") - - # 9. Event Location Search - query_location = { - "and": [ - {"field": "name", "op": "like", "value": "%"} - ] - } - test_search("event_location", query_location, "Event Location Search (has name)") - - print("Tests Complete.") \ No newline at end of file + print("Tests Complete.") diff --git a/app/db_sql.py b/app/db_sql.py index c72c201..c95ac74 100644 --- a/app/db_sql.py +++ b/app/db_sql.py @@ -2141,6 +2141,12 @@ def sql_search_qry_part( def process_node(query_node) -> str: clauses = [] + # Process 'query_string' (Standardized Full-Text Search) + if hasattr(query_node, 'query_string') and query_node.query_string: + p_name = get_param_name() + clauses.append(f"MATCH( default_qry_str ) AGAINST( :{p_name} IN BOOLEAN MODE )") + data[p_name] = query_node.query_string + # Process 'and' filters if hasattr(query_node, 'and_filters') and query_node.and_filters: and_clauses = [] diff --git a/app/models/api_crud_models.py b/app/models/api_crud_models.py index 7ca34fd..53a0d77 100644 --- a/app/models/api_crud_models.py +++ b/app/models/api_crud_models.py @@ -25,6 +25,7 @@ class SearchQuery(BaseModel): """ Represents a complex search query with optional logical grouping. """ + query_string: Optional[str] = Field(None, alias="q") and_filters: Optional[List[Union[SearchFilter, 'SearchQuery']]] = Field(None, alias="and") or_filters: Optional[List[Union[SearchFilter, 'SearchQuery']]] = Field(None, alias="or") diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 86fc61d..9ff9f5c 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -48,6 +48,7 @@ async def get_obj( response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), obj_id: str = Path(min_length=11, max_length=22), + view: str = Query('default', description="Select alternative view/model (e.g., enriched, detail)"), account: AccountContext = Depends(get_account_context), serialization: SerializationParams = Depends(get_serialization_params), delay: DelayParams = Depends(get_delay_params), @@ -60,6 +61,7 @@ async def get_obj( using Redis for performance, falling back to SQL if not found. - Consistency: Uses 'obj_type_kv_li' from ae_obj_types_def.py to map URL paths to database views/tables and Pydantic models. + - View Selection: Use the 'view' parameter to fetch a richer model (e.g., view=enriched). """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -72,11 +74,13 @@ async def get_obj( 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')) + + # Support view selection: tbl_{view} and mdl_{view} + table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) + base_name = obj_cfg.get(f'mdl_{view}', 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.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' (view: {view}) is incomplete.") record_id = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_name) if not record_id: @@ -95,6 +99,7 @@ async def get_obj_li( obj_type_l1: str, for_obj_type: Optional[str] = None, for_obj_id: Optional[str] = None, + view: str = Query('default'), order_by_li: Optional[str] = None, jp: Optional[Union[str, None]] = None, account: AccountContext = Depends(get_account_context), @@ -112,6 +117,7 @@ async def get_obj_li( - Flexible Querying: Supports complex JSON-based queries via the 'jp' parameter. - Contextual Filtering: Optionally filters by parent object relationship if 'for_obj_type' and 'for_obj_id' are provided. + - View Selection: Fetch alternative views (e.g., ?view=enriched). """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -156,11 +162,13 @@ async def get_obj_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')) + + # Support view selection: tbl_{view} and mdl_{view} + table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) + base_name = obj_cfg.get(f'mdl_{view}', 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.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' (view: {view}) is incomplete.") if for_obj_type and for_obj_id: # Resolve random ID to integer ID @@ -219,6 +227,9 @@ async def search_obj_li( response: Response, obj_type_l1: str, search_query: SearchQuery, + for_obj_type: Optional[str] = Query(None, description="Explicit parent type filter"), + for_obj_id: Optional[str] = Query(None, description="Explicit parent ID filter"), + view: str = Query('default', description="Select alternative view/model (e.g., enriched, detail)"), order_by_li: Optional[str] = Query(None), account: AccountContext = Depends(get_account_context), pagination: PaginationParams = Depends(get_pagination_params), @@ -232,7 +243,9 @@ async def search_obj_li( 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. + - Hybrid Filtering: Combines standard query params (enabled, hidden, for_obj_type) + with the complex logic in the body. + - View Selection: Fetch richer models via the 'view' parameter. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -248,22 +261,43 @@ async def search_obj_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')) + + # Support view selection: tbl_{view} and mdl_{view} + table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) + base_name = obj_cfg.get(f'mdl_{view}', 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.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' (view: {view}) 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 for_obj_type and for_obj_id: + # Resolve parentage context for search + resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) + if not resolved_for_obj_id: + return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object with ID '{for_obj_id}' not found.") + + sql_result = sql_select( + table_name=table_name, + field_name=f'{for_obj_type}_id', + field_value=resolved_for_obj_id, + 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, + ) + else: + 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 = [] diff --git a/documentation/Aether_API_CRUD_V3_beta_recommendations.md b/documentation/Aether_API_CRUD_V3_beta_recommendations.md new file mode 100644 index 0000000..80a53f0 --- /dev/null +++ b/documentation/Aether_API_CRUD_V3_beta_recommendations.md @@ -0,0 +1,31 @@ +# Aether API CRUD V3 Beta Recommendations + +Following the initial migration of the Journals module to the V3 CRUD endpoints, the following architectural recommendations are proposed for the FastAPI backend to improve developer experience and frontend efficiency. + +--- + +## 1. "View" Selection (`use_alt_tbl` Replacement) +In V2, the `use_alt_tbl` flag was frequently used to fetch "rich" objects (e.g., including joined data like `person_name`). +- **Recommendation:** Implement a `response_view` (or `view`) query parameter for both GET list and POST search endpoints. +- **Example:** `GET /v3/crud/journal/?view=enriched` +- **Goal:** Allow the client to request a heavier model with joined fields only when necessary, keeping the default response lightweight. + +## 2. Hybrid Filtering for Search +Currently, the `/search` endpoint requires a full JSON body even for simple standard filters like `enabled` or `hidden`. +- **Recommendation:** Update the `POST .../search` endpoint to accept standard query parameters that automatically append `AND` conditions to the logic defined in the body. +- **Example:** `POST /v3/crud/journal/search?enabled=true` +- **Goal:** Simplifies frontend code for common toggles while still allowing complex logic in the POST body. + +## 3. Standardized Full-Text Search Field +The current migration uses a field named `default_qry_str` with a `match` operator for text search. +- **Recommendation:** Implement a reserved top-level property in the Search Pydantic model (e.g., `_search_` or `query_string`) specifically for global text search. +- **Goal:** Decouples the frontend from knowing specific database column names used for full-text indexing. The backend can then decide which columns (name, description, tags, etc.) to include in the search. + +## 4. Explicit "Parent" Filtering in Search +Filtering by parent (e.g., Account or Site) is a primary use case but currently requires manual injection into the `and` array. +- **Recommendation:** Expose `for_obj_type` and `for_obj_id` as top-level arguments in the Search API. +- **Goal:** Standardizes how parent context is passed, allowing the backend to more easily perform ownership and permission validation before executing the search. + +--- +**Date:** 2026-01-02 +**Status:** Beta Feedback diff --git a/documentation/V3_FRONTEND_API_GUIDE.md b/documentation/V3_FRONTEND_API_GUIDE.md index 54c75a8..a46aeb5 100644 --- a/documentation/V3_FRONTEND_API_GUIDE.md +++ b/documentation/V3_FRONTEND_API_GUIDE.md @@ -9,86 +9,48 @@ This guide explains how to update or create frontend functions to interact with | Feature | CRUD V2 (Legacy) | CRUD V3 (Modern) | | --- | --- | --- | | **Base Prefix** | `/v2/crud` | `/v3/crud` | -| **List Suffix** | Uses `/list` (e.g., `/v2/crud/journal/list`) | **No suffix** (e.g., `/v3/crud/journal/`) | -| **Nested Path** | Not supported in URL path | **Supported** (e.g., `/v3/crud/journal/{id}/journal_entry/`) | -| **Order By** | Passed in **Headers** (`order_by_li`) | Passed as **Query Parameter** (`order_by_li`) | -| **Complex Search**| Limited to GET `jp` (~2KB limit) | **POST `/search`** (Unlimited size) | -| **Latency Simulation**| Not standardized | `X-Delay-ms` (Header) or `delay_ms` (Query) | +| **List Suffix** | Uses `/list` | **No suffix** (e.g., `/v3/crud/journal/`) | +| **Nested Path** | Not supported in URL | **Supported** (e.g., `/v3/crud/journal/{id}/journal_entry/`) | +| **View Selection**| `tbl_alt`, `mdl_alt` | **`view` parameter** (e.g., `?view=enriched`) | +| **Complex Search**| Limited to GET `jp` | **POST `/search`** (Unlimited size + Hybrid params) | +| **Full-Text Search**| Manual column names | **Reserved `q` property** in SearchQuery | --- ## 2. Implementing V3 CRUD Functions -### A. List Top-Level Objects (GET) -Use this for simple lists or filtering by a parent via query parameters. +### A. List & Single Object (GET) +Support for view selection allows fetching richer data models when needed. ```ts -export async function get_ae_obj_li_v3({ - api_cfg, - obj_type, - for_obj_type, - for_obj_id, - enabled = 'enabled', - hidden = 'not_hidden', - limit = 100, - offset = 0, - order_by_li = null, // e.g., {"created_on": "DESC"} - delay_ms = 0 -}) { - // 1. Build V3 Endpoint (Note: No /list suffix) - const endpoint = `/v3/crud/${obj_type}/`; - - // 2. Build Query Params - const params: any = { - enabled, - hidden, - limit, - offset - }; - - if (for_obj_type) params.for_obj_type = for_obj_type; - if (for_obj_id) params.for_obj_id = for_obj_id; - if (order_by_li) params.order_by_li = JSON.stringify(order_by_li); - if (delay_ms) params.delay_ms = delay_ms; - - return await get_object({ api_cfg, endpoint, params }); +// Example: Get enriched journal details +// GET /v3/crud/journal/{id}?view=enriched +export async function get_ae_obj_v3({ api_cfg, obj_type, obj_id, view = 'default' }) { + const endpoint = `/v3/crud/${obj_type}/${obj_id}`; + return await get_object({ api_cfg, endpoint, params: { view } }); } ``` -### B. Access Nested Objects (GET) -V3 allows you to enforce parent-child relationships directly in the URL. - -```ts -// Example: Get all entries for a specific journal -// GET /v3/crud/journal/{journal_id}/journal_entry/ -export async function get_nested_obj_li_v3({ - api_cfg, - parent_type, - parent_id, - child_type, - limit = 100 -}) { - const endpoint = `/v3/crud/${parent_type}/${parent_id}/${child_type}/`; - return await get_object({ api_cfg, endpoint, params: { limit } }); -} -``` - -### C. Advanced Search (POST) -Use the new `/search` endpoint for complex logic (AND/OR, LIKE, IN) that would exceed URL length limits. +### B. Advanced & Hybrid Search (POST) +The `/search` endpoint combines the power of complex logical bodies with the simplicity of query parameters. ```ts export async function search_ae_obj_v3({ api_cfg, obj_type, - search_query, // Complex SearchQuery object - order_by_li = null + search_query, // { q: "search term", and: [...] } + enabled = 'enabled', + view = 'default', + for_obj_type, + for_obj_id }) { const endpoint = `/v3/crud/${obj_type}/search`; - const params: any = {}; - if (order_by_li) params.order_by_li = JSON.stringify(order_by_li); + // Standard filters can be passed as query params + const params: any = { enabled, view }; + if (for_obj_type) params.for_obj_type = for_obj_type; + if (for_obj_id) params.for_obj_id = for_obj_id; - // Note: search_query is sent in the BODY via POST return await post_object({ api_cfg, endpoint, @@ -96,36 +58,26 @@ export async function search_ae_obj_v3({ data: search_query }); } +``` -/** - * Example search_query usage: - * { - * "and": [ - * { "field": "enable", "op": "eq", "value": true }, - * { - * "or": [ - * { "field": "name", "op": "like", "value": "%Meeting%" }, - * { "field": "priority", "op": "gt", "value": 5 } - * ] - * } - * ] - * } - */ +### C. Standardized Global Search +Use the `q` property in your search body for a general keyword search across indexed columns. + +```json +// POST /v3/crud/journal/search +{ + "q": "Annual Meeting", + "and": [ + { "field": "enable", "op": "eq", "value": true } + ] +} ``` --- ## 3. Best Practices for V3 -1. **Stop using Headers for Sorting**: In V2, we used `headers['order_by_li']`. In V3, always use `params['order_by_li']`. -2. **Prefer `/search` for Filters**: If your query involves more than a simple `for_obj_id`, use the `POST .../search` endpoint. It is safer, more readable, and bypasses the 2083-character URL limit. -3. **Non-Blocking UI**: Use the `delay_ms` parameter during development to test how your Svelte components handle loading states and skeleton screens. -4. **Singular Nouns**: Always use singular names for `obj_type` (e.g., `journal`, not `journals`), following the backend's `obj_type_kv_li` registry. - ---- - -## 4. Special Case: Account Context -V3 strictly enforces account context. Ensure your `api_cfg` logic correctly sets the `X-Account-ID` header. - -* If a request doesn't need an account (e.g., public site data), set the `X-No-Account-ID: bypass` header. -* If using a temporary token, use the `x_no_account_id_token` query parameter. +1. **Use `view` for Rich Data**: Instead of manually joining data in separate calls, use `?view=enriched` or `?view=detail` if defined in the backend. +2. **Hybrid Search**: Use query parameters for simple toggles (enabled/hidden) and the POST body for complex logic. +3. **Global Search**: Always prefer the `q` property for text search instead of manually targeting `default_qry_str`. +4. **Singular Nouns**: Always use singular names for `obj_type` (e.g., `journal`). \ No newline at end of file