Coverage for src / lexigram / admin / data / query.py: 55%

231 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Unified query specification for lexigram-admin. 

2 

3This module provides the QuerySpec and PagedResult types that serve as the 

4canonical query format across all admin layers (controllers, services, data sources). 

5 

6This is the single source of truth for query types. QueryBuilder and the 

7old Query dataclass are deprecated in favor of QuerySpec. 

8""" 

9 

10from __future__ import annotations 

11 

12from dataclasses import dataclass, field 

13from enum import StrEnum 

14from typing import Any, Generic, Literal, Self, TypeVar 

15 

16T = TypeVar("T") 

17 

18_UNSET: Any = object() # sentinel to distinguish "not provided" from None 

19 

20 

21class FilterOperator(StrEnum): 

22 """Supported filter operators for data queries.""" 

23 

24 EQ = "eq" 

25 NEQ = "neq" 

26 GT = "gt" 

27 GTE = "gte" 

28 LT = "lt" 

29 LTE = "lte" 

30 IN = "in" 

31 NOT_IN = "not_in" 

32 CONTAINS = "contains" 

33 ICONTAINS = "icontains" 

34 STARTS_WITH = "starts_with" 

35 ENDS_WITH = "ends_with" 

36 IS_NULL = "is_null" 

37 BETWEEN = "between" 

38 

39 

40@dataclass(frozen=True) 

41class FilterCondition: 

42 """A single filter condition in a query.""" 

43 

44 field: str 

45 operator: FilterOperator 

46 value: Any 

47 

48 

49@dataclass(frozen=True) 

50class QuerySpec: 

51 """Unified query specification used across all layers. 

52 

53 QuerySpec provides an immutable, composable query interface that supports: 

54 - Pagination (page-based and cursor-based) 

55 - Sorting (single field, ascending/descending) 

56 - Full-text search with configurable fields 

57 - Arbitrary filters via dict 

58 - Structured filter conditions with operators (where) 

59 - Field selection and eager loading 

60 - Grouping 

61 

62 All modification methods return a new QuerySpec instance (immutable updates). 

63 

64 Example: 

65 >>> query = QuerySpec().with_page(2).with_filters(status="active") 

66 >>> query = query.with_sort("created_at", "desc") 

67 >>> query = query.with_search("john", fields=["name", "email"]) 

68 >>> query = query.with_where_eq("role", "admin") 

69 """ 

70 

71 # Pagination 

72 page: int = 1 

73 per_page: int = 20 

74 cursor: str | None = None 

75 

76 # Sorting 

77 sort_by: str | None = None 

78 sort_order: Literal["asc", "desc"] = "asc" 

79 

80 # Search 

81 search: str | None = None 

82 search_fields: list[str] = field(default_factory=list) 

83 

84 # Simple filters (key=value, from URL params) 

85 filters: dict[str, Any] = field(default_factory=dict) 

86 

87 # Structured filter conditions (with operators) 

88 where: tuple[FilterCondition, ...] = () 

89 

90 # Field selection 

91 select_fields: tuple[str, ...] = () 

92 

93 # Relations to eagerly load 

94 include: list[str] = field(default_factory=list) 

95 

96 # Grouping 

97 group_by: str | None = None 

98 

99 # Soft delete: when True, results include soft-deleted records 

100 include_deleted: bool = False 

101 

102 @property 

103 def offset(self) -> int: 

104 """Calculate SQL offset from page and per_page.""" 

105 return (self.page - 1) * self.per_page 

106 

107 @property 

108 def limit(self) -> int: 

109 """Return per_page as limit (alias for SQL compatibility).""" 

110 return self.per_page 

111 

112 @property 

113 def is_cursor_based(self) -> bool: 

114 """Check if this query uses cursor-based pagination.""" 

115 return self.cursor is not None 

116 

117 @property 

118 def has_search(self) -> bool: 

119 """Check if search is active.""" 

120 return bool(self.search and self.search_fields) 

121 

122 @property 

123 def has_filters(self) -> bool: 

124 """Check if any filters are active.""" 

125 return bool(self.filters) 

126 

127 @property 

128 def has_sort(self) -> bool: 

129 """Check if sorting is specified.""" 

130 return self.sort_by is not None 

131 

132 @property 

133 def resolved_sort(self) -> tuple[str | None, Literal["asc", "desc"]]: 

134 """Return (field, direction) with leading '-' prefix decoded to 'desc'.""" 

135 if self.sort_by and self.sort_by.startswith("-"): 

136 return self.sort_by[1:], "desc" 

137 return self.sort_by, self.sort_order 

138 

139 @staticmethod 

140 def _condition_to_repo_key(condition: FilterCondition) -> str: 

141 """Convert a FilterCondition into a repository-compatible key (e.g. ``age__gt``).""" 

142 eq_types = (FilterOperator.EQ,) 

143 suffix_map: dict[FilterOperator, str] = { 

144 FilterOperator.NEQ: "__neq", 

145 FilterOperator.GT: "__gt", 

146 FilterOperator.GTE: "__gte", 

147 FilterOperator.LT: "__lt", 

148 FilterOperator.LTE: "__lte", 

149 FilterOperator.IN: "__in", 

150 FilterOperator.NOT_IN: "__not_in", 

151 FilterOperator.CONTAINS: "__contains", 

152 FilterOperator.ICONTAINS: "__icontains", 

153 FilterOperator.STARTS_WITH: "__startswith", 

154 FilterOperator.ENDS_WITH: "__endswith", 

155 FilterOperator.IS_NULL: "__isnull", 

156 FilterOperator.BETWEEN: "__between", 

157 } 

158 suffix = suffix_map.get(condition.operator) 

159 if suffix is not None: 

160 return f"{condition.field}{suffix}" 

161 # EQ special case: list values become __in 

162 if isinstance(condition.value, (list, tuple)): 

163 return f"{condition.field}__in" 

164 return condition.field 

165 

166 def to_repository_filters(self) -> dict[str, Any] | None: 

167 """Merge ``filters`` dict and ``filter_conditions`` into repository-style dict. 

168 

169 Returns None when both are empty so callers can pass it directly as the 

170 optional ``filters`` parameter without an extra None-check. 

171 """ 

172 result: dict[str, Any] = {} 

173 

174 # Old-style dict filters 

175 if self.filters: 

176 result.update(self.filters) 

177 

178 # New-style filter conditions — convert operators to __suffix 

179 for condition in self.where: 

180 result[self._condition_to_repo_key(condition)] = condition.value 

181 

182 return result if result else None 

183 

184 @property 

185 def filter_conditions(self) -> list[FilterCondition]: 

186 """Combine ``where`` conditions and ``filters`` dict into a single filter list.""" 

187 result = list(self.where) 

188 for key, value in self.filters.items(): 

189 result.append( 

190 FilterCondition(field=key, operator=FilterOperator.EQ, value=value) 

191 ) 

192 return result 

193 

194 def _copy( 

195 self, 

196 *, 

197 page: int | None = None, 

198 per_page: int | None = None, 

199 cursor: str | None | object = _UNSET, 

200 sort_by: str | None | object = _UNSET, 

201 sort_order: Literal["asc", "desc"] | None = None, 

202 search: str | None | object = _UNSET, 

203 search_fields: list[str] | None = None, 

204 filters: dict[str, Any] | None = None, 

205 where: tuple[FilterCondition, ...] | None = None, 

206 select_fields: tuple[str, ...] | None = None, 

207 include: list[str] | None = None, 

208 group_by: str | None | object = _UNSET, 

209 include_deleted: bool | None = None, 

210 ) -> QuerySpec: 

211 """Internal helper: return a copy with selective overrides. 

212 

213 Fields whose values can legitimately be ``None`` (cursor, sort_by, 

214 search, group_by) default to the sentinel ``_UNSET`` so that 

215 ``None`` is treated as an explicit clear-value rather than 

216 "not provided". 

217 """ 

218 return QuerySpec( 

219 page=self.page if page is None else page, 

220 per_page=self.per_page if per_page is None else per_page, 

221 cursor=self.cursor if cursor is _UNSET else cursor, # type: ignore[arg-type] 

222 sort_by=self.sort_by if sort_by is _UNSET else sort_by, # type: ignore[arg-type] 

223 sort_order=self.sort_order if sort_order is None else sort_order, 

224 search=self.search if search is _UNSET else search, # type: ignore[arg-type] 

225 search_fields=list(self.search_fields) 

226 if search_fields is None 

227 else search_fields, 

228 filters=dict(self.filters) if filters is None else filters, 

229 where=self.where if where is None else where, 

230 select_fields=self.select_fields 

231 if select_fields is None 

232 else select_fields, 

233 include=list(self.include) if include is None else include, 

234 group_by=self.group_by if group_by is _UNSET else group_by, # type: ignore[arg-type] 

235 include_deleted=self.include_deleted 

236 if include_deleted is None 

237 else include_deleted, 

238 ) 

239 

240 def with_page(self, page: int) -> QuerySpec: 

241 """Return new QuerySpec with updated page. 

242 

243 Args: 

244 page: Page number (1-indexed) 

245 

246 Returns: 

247 New QuerySpec instance with updated page 

248 """ 

249 return self._copy(page=max(1, page), cursor=None) 

250 

251 def with_per_page(self, per_page: int) -> QuerySpec: 

252 """Return new QuerySpec with updated per_page. 

253 

254 Args: 

255 per_page: Number of items per page (clamped to 1-1000) 

256 

257 Returns: 

258 New QuerySpec instance with updated per_page 

259 """ 

260 return self._copy(per_page=max(1, min(per_page, 1000))) 

261 

262 def with_cursor(self, cursor: str | None) -> QuerySpec: 

263 """Return new QuerySpec with cursor-based pagination. 

264 

265 Args: 

266 cursor: Cursor string for pagination 

267 

268 Returns: 

269 New QuerySpec instance with cursor pagination 

270 """ 

271 return self._copy(page=1, cursor=cursor) 

272 

273 def with_sort( 

274 self, 

275 field: str | None, 

276 order: Literal["asc", "desc"] = "asc", 

277 ) -> QuerySpec: 

278 """Return new QuerySpec with updated sort. 

279 

280 Args: 

281 field: Field name to sort by (None to clear sort) 

282 order: Sort direction ("asc" or "desc") 

283 

284 Returns: 

285 New QuerySpec instance with updated sort 

286 """ 

287 return self._copy(sort_by=field, sort_order=order) 

288 

289 def with_search( 

290 self, 

291 term: str | None, 

292 fields: list[str] | None = None, 

293 ) -> QuerySpec: 

294 """Return new QuerySpec with search parameters. 

295 

296 Args: 

297 term: Search term (None to clear search) 

298 fields: Fields to search in (uses existing if not provided) 

299 

300 Returns: 

301 New QuerySpec instance with search 

302 """ 

303 return self._copy( 

304 page=1, 

305 cursor=None, 

306 search=term, 

307 search_fields=fields if fields is not None else list(self.search_fields), 

308 ) 

309 

310 def with_filters(self, **filters: Any) -> QuerySpec: 

311 """Return new QuerySpec with additional/updated filters. 

312 

313 Args: 

314 **filters: Key-value filter parameters to add/update 

315 

316 Returns: 

317 New QuerySpec instance with merged filters 

318 """ 

319 merged = {**self.filters, **filters} 

320 return self._copy(page=1, cursor=None, filters=merged) 

321 

322 def with_filter(self, key: str, value: Any) -> QuerySpec: 

323 """Return new QuerySpec with a single filter added/updated. 

324 

325 Args: 

326 key: Filter key 

327 value: Filter value 

328 

329 Returns: 

330 New QuerySpec instance with filter 

331 """ 

332 return self.with_filters(**{key: value}) 

333 

334 def without_filter(self, key: str) -> QuerySpec: 

335 """Return new QuerySpec with a filter removed. 

336 

337 Args: 

338 key: Filter key to remove 

339 

340 Returns: 

341 New QuerySpec instance without the filter 

342 """ 

343 return self._copy( 

344 page=1, 

345 cursor=None, 

346 filters={k: v for k, v in self.filters.items() if k != key}, 

347 ) 

348 

349 def clear_filters(self) -> QuerySpec: 

350 """Return new QuerySpec with all filters cleared. 

351 

352 Returns: 

353 New QuerySpec instance with no filters 

354 """ 

355 return self._copy(page=1, cursor=None, filters={}) 

356 

357 def with_deleted(self, include: bool = True) -> QuerySpec: 

358 """Return new QuerySpec that includes (or excludes) soft-deleted records. 

359 

360 Args: 

361 include: When True, soft-deleted records are returned alongside active ones. 

362 

363 Returns: 

364 New QuerySpec instance with updated include_deleted flag. 

365 """ 

366 return self._copy(include_deleted=include) 

367 

368 def with_include(self, *relations: str) -> QuerySpec: 

369 """Return new QuerySpec with relations to eagerly load. 

370 

371 Args: 

372 *relations: Relation names to include 

373 

374 Returns: 

375 New QuerySpec instance with includes 

376 """ 

377 combined = list(set(self.include) | set(relations)) 

378 return self._copy(include=combined) 

379 

380 # ========== Structured filter condition methods (replaces QueryBuilder) ========== 

381 

382 def with_where( 

383 self, 

384 field: str, 

385 operator: str | FilterOperator, 

386 value: Any, 

387 ) -> QuerySpec: 

388 """Add a structured filter condition. 

389 

390 Args: 

391 field: The field name to filter on. 

392 operator: The operator (e.g., "eq", FilterOperator.GT). 

393 value: The value to compare against. 

394 

395 Returns: 

396 New QuerySpec instance with the condition appended. 

397 """ 

398 if isinstance(operator, str): 

399 operator = FilterOperator(operator) 

400 return self._copy( 

401 page=1, 

402 cursor=None, 

403 where=(*self.where, FilterCondition(field, operator, value)), 

404 ) 

405 

406 def with_where_eq(self, field: str, value: Any) -> QuerySpec: 

407 """Add an equality filter condition.""" 

408 return self.with_where(field, FilterOperator.EQ, value) 

409 

410 def with_where_in(self, field: str, values: list[Any]) -> QuerySpec: 

411 """Add an IN filter condition.""" 

412 return self.with_where(field, FilterOperator.IN, values) 

413 

414 def with_where_contains(self, field: str, value: str) -> QuerySpec: 

415 """Add a CONTAINS filter condition.""" 

416 return self.with_where(field, FilterOperator.CONTAINS, value) 

417 

418 def with_where_between(self, field: str, min_val: Any, max_val: Any) -> QuerySpec: 

419 """Add a BETWEEN filter condition.""" 

420 return self.with_where(field, FilterOperator.BETWEEN, (min_val, max_val)) 

421 

422 def with_order_by(self, field: str, direction: str = "asc") -> QuerySpec: 

423 """Set the sort field and direction (alias for with_sort).""" 

424 return self._copy(sort_by=field, sort_order=direction) # type: ignore[arg-type] 

425 

426 def with_order_by_desc(self, field: str) -> QuerySpec: 

427 """Set the sort field with descending direction.""" 

428 return self._copy(sort_by=field, sort_order="desc") 

429 

430 def with_select(self, *fields: str) -> QuerySpec: 

431 """Specify which fields to include in the result. 

432 

433 Args: 

434 *fields: Field names to include. 

435 

436 Returns: 

437 New QuerySpec instance with select_fields set. 

438 """ 

439 return self._copy(select_fields=fields) 

440 

441 def with_group_by(self, field: str) -> QuerySpec: 

442 """Set grouping field. 

443 

444 Args: 

445 field: The field name to group by. 

446 

447 Returns: 

448 New QuerySpec instance with group_by set. 

449 """ 

450 return self._copy(group_by=field) 

451 

452 @classmethod 

453 def from_dict(cls, data: dict[str, Any]) -> Self: 

454 """Create QuerySpec from dictionary (e.g., query parameters). 

455 

456 Args: 

457 data: Dictionary with query parameters 

458 

459 Returns: 

460 New QuerySpec instance 

461 """ 

462 return cls( 

463 page=int(data.get("page", 1)), 

464 per_page=int(data.get("per_page", 20)), 

465 cursor=data.get("cursor"), 

466 sort_by=data.get("sort_by") or data.get("sort"), 

467 sort_order=data.get("sort_order", data.get("order", "asc")), 

468 search=data.get("search") or data.get("q"), 

469 search_fields=data.get("search_fields", []), 

470 select_fields=tuple(data.get("select_fields", [])), 

471 group_by=data.get("group_by"), 

472 filters={ 

473 k: v 

474 for k, v in data.items() 

475 if k 

476 not in ( 

477 "page", 

478 "per_page", 

479 "cursor", 

480 "sort_by", 

481 "sort", 

482 "sort_order", 

483 "order", 

484 "search", 

485 "q", 

486 "search_fields", 

487 "select_fields", 

488 "group_by", 

489 "include", 

490 ) 

491 }, 

492 include=data.get("include", []) 

493 if isinstance(data.get("include"), list) 

494 else data.get("include", "").split(",") 

495 if data.get("include") 

496 else [], 

497 include_deleted=bool(data.get("include_deleted", False)), 

498 ) 

499 

500 def to_dict(self) -> dict[str, Any]: 

501 """Convert QuerySpec to dictionary. 

502 

503 Returns: 

504 Dictionary representation (excludes None/empty values) 

505 """ 

506 result: dict[str, Any] = { 

507 "page": self.page, 

508 "per_page": self.per_page, 

509 } 

510 

511 if self.cursor: 

512 result["cursor"] = self.cursor 

513 if self.sort_by: 

514 result["sort_by"] = self.sort_by 

515 result["sort_order"] = self.sort_order 

516 if self.search: 

517 result["search"] = self.search 

518 if self.search_fields: 

519 result["search_fields"] = self.search_fields 

520 if self.select_fields: 

521 result["select_fields"] = list(self.select_fields) 

522 if self.group_by: 

523 result["group_by"] = self.group_by 

524 if self.filters: 

525 result["filters"] = self.filters 

526 if self.include: 

527 result["include"] = self.include 

528 if self.include_deleted: 

529 result["include_deleted"] = True 

530 

531 return result 

532 

533 

534@dataclass 

535class PagedResult(Generic[T]): 

536 """Paginated query result. 

537 

538 Contains the items for the current page along with pagination metadata. 

539 Supports both page-based and cursor-based pagination. 

540 

541 Example: 

542 >>> result = PagedResult( 

543 ... items=[user1, user2], 

544 ... total=100, 

545 ... page=1, 

546 ... per_page=20, 

547 ... ) 

548 >>> result.has_next # True 

549 >>> result.total_pages # 5 

550 """ 

551 

552 items: list[T] 

553 total: int 

554 page: int 

555 per_page: int 

556 cursor: str | None = None 

557 next_cursor: str | None = None 

558 

559 @property 

560 def has_next(self) -> bool: 

561 """Check if there are more pages after current.""" 

562 if self.next_cursor is not None: 

563 return True 

564 return self.page * self.per_page < self.total 

565 

566 @property 

567 def has_prev(self) -> bool: 

568 """Check if there are pages before current.""" 

569 return self.page > 1 

570 

571 @property 

572 def total_pages(self) -> int: 

573 """Calculate total number of pages.""" 

574 if self.per_page <= 0: 

575 return 0 

576 return (self.total + self.per_page - 1) // self.per_page 

577 

578 @property 

579 def is_empty(self) -> bool: 

580 """Check if result has no items.""" 

581 return len(self.items) == 0 

582 

583 @property 

584 def count(self) -> int: 

585 """Number of items in current page.""" 

586 return len(self.items) 

587 

588 @property 

589 def start_index(self) -> int: 

590 """1-indexed start position of first item.""" 

591 if self.is_empty: 

592 return 0 

593 return (self.page - 1) * self.per_page + 1 

594 

595 @property 

596 def end_index(self) -> int: 

597 """1-indexed position of last item.""" 

598 if self.is_empty: 

599 return 0 

600 return self.start_index + self.count - 1 

601 

602 def map(self, fn: Any) -> PagedResult[Any]: 

603 """Transform items using a function. 

604 

605 Args: 

606 fn: Function to apply to each item 

607 

608 Returns: 

609 New PagedResult with transformed items 

610 """ 

611 return PagedResult( 

612 items=list(map(fn, self.items)), 

613 total=self.total, 

614 page=self.page, 

615 per_page=self.per_page, 

616 cursor=self.cursor, 

617 next_cursor=self.next_cursor, 

618 ) 

619 

620 @classmethod 

621 def empty(cls, per_page: int = 20) -> PagedResult[T]: 

622 """Create an empty PagedResult. 

623 

624 Args: 

625 per_page: Items per page for pagination metadata 

626 

627 Returns: 

628 Empty PagedResult 

629 """ 

630 return cls( 

631 items=[], 

632 total=0, 

633 page=1, 

634 per_page=per_page, 

635 ) 

636 

637 

638__all__ = [ 

639 "CombinedSpec", 

640 "EqualSpec", 

641 "FilterCondition", 

642 "FilterOperator", 

643 "FilterSpec", 

644 "GreaterThanOrEqualSpec", 

645 "InSpec", 

646 "LessThanOrEqualSpec", 

647 "PagedResult", 

648 "QuerySpec", 

649] 

650 

651 

652# ============================================================================ 

653# Filter SpecificationProtocol Types 

654# ============================================================================ 

655 

656 

657class EqualSpec: 

658 """Filter specification for exact equality: column == value.""" 

659 

660 def __init__(self, field: str, value: object) -> None: 

661 """Initialize EqualSpec with field name and value to match.""" 

662 self.field = field 

663 self.value = value 

664 

665 def __and__(self, other: FilterSpec) -> CombinedSpec: 

666 """Combine with another spec using AND.""" 

667 return CombinedSpec(specs=[self, other]) 

668 

669 

670class InSpec: 

671 """Filter specification for IN query: column IN values.""" 

672 

673 def __init__(self, field: str, values: list[object]) -> None: 

674 """Initialize InSpec with field name and list of values.""" 

675 self.field = field 

676 self.values = values 

677 

678 def __and__(self, other: FilterSpec) -> CombinedSpec: 

679 """Combine with another spec using AND.""" 

680 return CombinedSpec(specs=[self, other]) 

681 

682 

683class GreaterThanOrEqualSpec: 

684 """Filter specification for >= comparison: column >= value.""" 

685 

686 def __init__(self, field: str, value: object) -> None: 

687 """Initialize GreaterThanOrEqualSpec with field name and value.""" 

688 self.field = field 

689 self.value = value 

690 

691 def __and__(self, other: FilterSpec) -> CombinedSpec: 

692 """Combine with another spec using AND.""" 

693 return CombinedSpec(specs=[self, other]) 

694 

695 

696class LessThanOrEqualSpec: 

697 """Filter specification for <= comparison: column <= value.""" 

698 

699 def __init__(self, field: str, value: object) -> None: 

700 """Initialize LessThanOrEqualSpec with field name and value.""" 

701 self.field = field 

702 self.value = value 

703 

704 def __and__(self, other: FilterSpec) -> CombinedSpec: 

705 """Combine with another spec using AND.""" 

706 return CombinedSpec(specs=[self, other]) 

707 

708 

709FilterSpec = EqualSpec | InSpec | GreaterThanOrEqualSpec | LessThanOrEqualSpec 

710 

711 

712class CombinedSpec: 

713 """Combined filter specification (AND of multiple specs).""" 

714 

715 def __init__(self, specs: list[FilterSpec]) -> None: 

716 """Initialize CombinedSpec with list of filter specs.""" 

717 self.specs = specs 

718 

719 def __and__(self, other: FilterSpec) -> CombinedSpec: 

720 """Combine with another spec using AND.""" 

721 return CombinedSpec(specs=[*self.specs, other])