Coverage for fastblocks/actions/query/parser.py: 0%
198 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -0700
1"""Universal Query Parser for FastBlocks.
3Converts HTTP request query parameters into ACB universal database queries.
4Provides automatic filtering, pagination, sorting, and model lookup capabilities.
6Author: lesleslie <les@wedgwoodwebworks.com>
7Created: 2025-01-13
8"""
10import typing as t
11from contextlib import suppress
13from acb.debug import debug
14from acb.depends import depends
15from starlette.requests import Request
16from fastblocks.htmx import HtmxRequest
19class UniversalQueryParser:
20 def __init__(
21 self,
22 request: HtmxRequest | Request,
23 model_class: t.Any = None,
24 pattern: str = "advanced",
25 default_limit: int = 10,
26 max_limit: int = 100,
27 ) -> None:
28 self.request = request
29 self.model_class = model_class
30 self.pattern = pattern
31 self.default_limit = default_limit
32 self.max_limit = max_limit
33 self.query = depends.get("query")
35 def _parse_pagination(self, params: dict[str, str]) -> tuple[int, int, int]:
36 page = max(1, int(params.pop("page", 1)))
37 limit = min(
38 self.max_limit, max(1, int(params.pop("limit", self.default_limit)))
39 )
40 offset = (page - 1) * limit
41 debug(f"Pagination: page={page}, limit={limit}, offset={offset}")
42 return page, limit, offset
44 def _parse_sorting(self, params: dict[str, str]) -> tuple[str | None, str]:
45 order_by = params.pop("order_by", None)
46 order_dir = params.pop("order_dir", "asc").lower()
47 if order_dir not in ("asc", "desc"):
48 order_dir = "asc"
49 debug(f"Sorting: order_by={order_by}, order_dir={order_dir}")
50 return order_by, order_dir
52 def _parse_filters(self, params: dict[str, str]) -> list[tuple[str, str, t.Any]]:
53 filters = []
54 for key, value in params.items():
55 if "__" in key:
56 field, operator = key.rsplit("__", 1)
57 processed_value = self._process_operator_value(operator, value)
58 filters.append((field, operator, processed_value))
59 else:
60 processed_value = self._process_simple_value(value)
61 filters.append((key, "equals", processed_value))
62 debug(f"Filters: {filters}")
63 return filters
65 def _process_operator_value(self, operator: str, value: str) -> t.Any:
66 if operator == "null":
67 return value.lower() in ("true", "1", "yes")
68 elif operator == "in":
69 return [v.strip() for v in value.split(",")]
70 elif operator in ("gt", "gte", "lt", "lte"):
71 return self._convert_to_number(value)
72 return value
74 def _process_simple_value(self, value: str) -> t.Any:
75 if value.lower() in ("true", "false"):
76 return value.lower() == "true"
77 elif value.lower() in ("null", "none"):
78 return None
79 return self._convert_to_number(value)
81 def _convert_to_number(self, value: str) -> t.Any:
82 with suppress(ValueError):
83 if "." in value:
84 return float(value)
85 else:
86 return int(value)
87 return value
89 def _apply_filters( # noqa: C901
90 self, query_builder: t.Any, filters: list[tuple[str, str, t.Any]]
91 ) -> t.Any:
92 for field, operator, value in filters:
93 try:
94 if operator == "equals":
95 query_builder = query_builder.where(field, value)
96 elif operator == "gt":
97 query_builder = query_builder.where_gt(field, value)
98 elif operator == "gte":
99 query_builder = query_builder.where_gte(field, value)
100 elif operator == "lt":
101 query_builder = query_builder.where_lt(field, value)
102 elif operator == "lte":
103 query_builder = query_builder.where_lte(field, value)
104 elif operator == "contains":
105 query_builder = query_builder.where_like(field, f"%{value}%")
106 elif operator == "icontains":
107 query_builder = query_builder.where_ilike(field, f"%{value}%")
108 elif operator == "in":
109 query_builder = query_builder.where_in(field, value)
110 elif operator == "not":
111 query_builder = query_builder.where_not(field, value)
112 elif operator == "null":
113 if value:
114 query_builder = query_builder.where_null(field)
115 else:
116 query_builder = query_builder.where_not_null(field)
117 else:
118 debug(
119 f"Unknown operator '{operator}' for field '{field}', skipping"
120 )
121 except AttributeError as e:
122 debug(
123 f"Query builder method not available for operator '{operator}': {e}"
124 )
126 return query_builder
128 def _apply_sorting(
129 self, query_builder: t.Any, order_by: str | None, order_dir: str
130 ) -> t.Any:
131 if order_by:
132 try:
133 if order_dir == "desc":
134 query_builder = query_builder.order_by_desc(order_by)
135 else:
136 query_builder = query_builder.order_by(order_by)
137 except AttributeError as e:
138 debug(f"Query builder sorting method not available: {e}")
140 return query_builder
142 def _apply_pagination(self, query_builder: t.Any, offset: int, limit: int) -> t.Any:
143 try:
144 return query_builder.offset(offset).limit(limit)
145 except AttributeError as e:
146 debug(f"Query builder pagination method not available: {e}")
147 return query_builder
149 async def parse_and_execute(self) -> list[t.Any]:
150 if not self._validate_query_requirements():
151 return []
152 params = dict(getattr(self.request, "query_params", {}))
153 debug(f"Original query params: {params}")
154 _, limit, offset = self._parse_pagination(params)
155 order_by, order_dir = self._parse_sorting(params)
156 filters = self._parse_filters(params)
157 try:
158 query_builder = self._get_query_builder(filters)
159 if query_builder is None:
160 return []
162 return await self._execute_query(
163 query_builder, filters, order_by, order_dir, offset, limit
164 )
165 except Exception as e:
166 debug(f"Query execution failed: {e}")
167 return []
169 def _validate_query_requirements(self) -> bool:
170 if not self.model_class:
171 debug("No model class provided for query parsing")
172 return False
173 if not self.query:
174 debug("Universal query interface not available")
175 return False
176 return True
178 def _get_query_builder(self, filters: list[tuple[str, str, t.Any]]) -> t.Any:
179 if self.pattern == "simple":
180 return self._handle_simple_pattern(filters)
181 elif self.pattern in ("repository", "specification"):
182 debug(
183 f"{self.pattern.title()} pattern not fully implemented, falling back to advanced"
184 )
185 return self.query.for_model(self.model_class).advanced
186 return self.query.for_model(self.model_class).advanced
188 def _handle_simple_pattern(self, filters: list[tuple[str, str, t.Any]]) -> t.Any:
189 query_builder = self.query.for_model(self.model_class).simple
190 if filters:
191 for field, operator, value in filters:
192 if operator == "equals":
193 try:
194 query_builder = query_builder.where(field, value)
195 except AttributeError:
196 debug("Simple query pattern doesn't support where clause")
197 break
198 return query_builder
200 async def _execute_query(
201 self,
202 query_builder: t.Any,
203 filters: list[tuple[str, str, t.Any]],
204 order_by: str | None,
205 order_dir: str,
206 offset: int,
207 limit: int,
208 ) -> list[t.Any]:
209 if self.pattern == "simple":
210 return await query_builder.all()
212 query_builder = self._apply_filters(query_builder, filters)
213 query_builder = self._apply_sorting(query_builder, order_by, order_dir)
214 query_builder = self._apply_pagination(query_builder, offset, limit)
216 debug(f"Executing query for model {self.model_class.__name__}")
217 results = await query_builder.all()
218 debug(f"Query returned {len(results)} results")
220 return results
222 async def get_count(self) -> int:
223 if not self.model_class or not self.query:
224 return 0
225 params = dict(getattr(self.request, "query_params", {}))
226 params.pop("page", None)
227 params.pop("limit", None)
228 params.pop("order_by", None)
229 params.pop("order_dir", None)
230 filters = self._parse_filters(params)
231 try:
232 query_builder = self.query.for_model(self.model_class).advanced
233 query_builder = self._apply_filters(query_builder, filters)
235 return await query_builder.count()
236 except Exception as e:
237 debug(f"Count query failed: {e}")
238 return 0
240 def get_pagination_info(self) -> dict[str, t.Any]:
241 params = dict(getattr(self.request, "query_params", {}))
242 page, limit, offset = self._parse_pagination(params)
244 return {
245 "page": page,
246 "limit": limit,
247 "offset": offset,
248 "has_prev": page > 1,
249 "prev_page": page - 1 if page > 1 else None,
250 "next_page": page + 1,
251 }
254def get_model_for_query(model_name: str) -> t.Any | None:
255 try:
256 models = depends.get("models")
257 if models and hasattr(models, model_name):
258 return getattr(models, model_name)
259 except Exception as e:
260 debug(f"Failed to get model '{model_name}': {e}")
262 return None
265def create_query_context(
266 request: HtmxRequest | Request,
267 model_name: str | None = None,
268 base_context: dict[str, t.Any] | None = None,
269) -> dict[str, t.Any]:
270 if base_context is None:
271 base_context = {}
273 context = dict(base_context)
275 if not model_name:
276 query_params = getattr(request, "query_params", {})
277 model_name = query_params.get("model")
279 if not model_name:
280 return context
282 model_class = get_model_for_query(model_name)
283 if not model_class:
284 debug(f"Model '{model_name}' not found")
285 return context
287 parser = UniversalQueryParser(request, model_class)
289 context.update(
290 {
291 f"{model_name}_parser": parser,
292 f"{model_name}_pagination": parser.get_pagination_info(),
293 "universal_query": {
294 "model_name": model_name,
295 "model_class": model_class,
296 "parser": parser,
297 },
298 }
299 )
301 return context