Coverage for fastblocks/actions/query/parser.py: 0%

198 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -0700

1"""Universal Query Parser for FastBlocks. 

2 

3Converts HTTP request query parameters into ACB universal database queries. 

4Provides automatic filtering, pagination, sorting, and model lookup capabilities. 

5 

6Author: lesleslie <les@wedgwoodwebworks.com> 

7Created: 2025-01-13 

8""" 

9 

10import typing as t 

11from contextlib import suppress 

12 

13from acb.debug import debug 

14from acb.depends import depends 

15from starlette.requests import Request 

16from fastblocks.htmx import HtmxRequest 

17 

18 

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") 

34 

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 

43 

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 

51 

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 

64 

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 

73 

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) 

80 

81 def _convert_to_number(self, value: str) -> t.Any: 

82 with suppress(ValueError): 

83 if "." in value: 

84 return float(value) 

85 

86 return int(value) 

87 return value 

88 

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 ) 

125 

126 return query_builder 

127 

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}") 

139 

140 return query_builder 

141 

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 

148 

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 [] 

161 

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 [] 

168 

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 

177 

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 

187 

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 

199 

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 t.cast(list[t.Any], await query_builder.all()) 

211 

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) 

215 

216 debug(f"Executing query for model {self.model_class.__name__}") 

217 results = t.cast(list[t.Any], await query_builder.all()) 

218 debug(f"Query returned {len(results)} results") 

219 

220 return results 

221 

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) 

234 

235 return t.cast(int, await query_builder.count()) 

236 except Exception as e: 

237 debug(f"Count query failed: {e}") 

238 return 0 

239 

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) 

243 

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 } 

252 

253 

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}") 

261 

262 return None 

263 

264 

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 = {} 

272 

273 context = dict(base_context) 

274 

275 if not model_name: 

276 query_params = getattr(request, "query_params", {}) 

277 model_name = query_params.get("model") 

278 

279 if not model_name: 

280 return context 

281 

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 

286 

287 parser = UniversalQueryParser(request, model_class) 

288 

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 ) 

300 

301 return context