Coverage for src/lexigram/admin/integrations/search.py: 87%
62 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Search integration — delegates resource search to a search index."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7if TYPE_CHECKING:
8 from lexigram.contracts.core.di import (
9 ContainerRegistrarProtocol,
10 ContainerResolverProtocol,
11 )
13from lexigram.contracts.search import SearchEngineProtocol
16class _NoOpSearch:
17 async def search(
18 self, index_name: str, query: str, **kwargs: Any
19 ) -> dict[str, Any]:
20 return {"results": [], "total": 0}
23class SearchIntegration:
24 """Adapter that routes list queries through a search index.
26 Gracefully no-ops when ``lexigram-search`` is not installed or the
27 integration is disabled.
28 """
30 def __init__(self, config: Any) -> None:
31 self._config = config
32 self._search: Any = None
33 self._enabled = False
35 def register(self, container: ContainerRegistrarProtocol) -> None:
36 from lexigram.admin.config import SearchIntegrationConfig
37 from lexigram.admin.integrations._optional import is_installed
39 cfg = self._config
40 if not isinstance(cfg, SearchIntegrationConfig):
41 cfg = SearchIntegrationConfig()
42 if not cfg.enabled:
43 self._search = _NoOpSearch()
44 return
45 if not is_installed("lexigram.search"):
46 self._search = _NoOpSearch()
47 return
48 self._enabled = True
50 async def boot(self, container: ContainerResolverProtocol) -> None:
51 if not self._enabled:
52 return
53 self._container = container
55 async def shutdown(self) -> None:
56 pass
58 async def health_check(self) -> dict[str, Any]:
59 return {"status": "healthy" if self.is_available else "noop"}
61 async def query(
62 self,
63 index: str,
64 query_str: str,
65 limit: int = 50,
66 offset: int = 0,
67 filters: dict[str, Any] | None = None,
68 rule: str | None = None,
69 ) -> dict[str, Any]:
70 """Run a query against the search index.
72 Args:
73 index: Index name to search.
74 query_str: Full-text query string.
75 limit: Maximum results to return.
76 offset: Result offset for pagination.
77 filters: Canonical search filter dict (``{field: value}``,
78 ``{field: {"op": value}}``, ``{"$or": [...]}``, ...).
79 rule: Query-builder block JSON string; merged into *filters*
80 with AND semantics by the engine backend.
82 Returns:
83 A ``{"results": [...], "total": n}`` dict.
84 """
85 engine = await self._get_engine()
86 if isinstance(engine, _NoOpSearch):
87 return {"results": [], "total": 0}
88 result = await engine.search(
89 index_name=index,
90 query=query_str,
91 filters=filters,
92 rule=rule,
93 limit=limit,
94 offset=offset,
95 )
96 return self._unwrap(result)
98 async def _get_engine(self) -> Any:
99 if self._search is not None:
100 return self._search
101 try:
102 self._search = await self._container.resolve(SearchEngineProtocol)
103 except Exception:
104 self._search = _NoOpSearch()
105 return self._search
107 @staticmethod
108 def _unwrap(result: Any) -> dict[str, Any]:
109 if hasattr(result, "is_ok"):
110 if not result.is_ok():
111 return {"results": [], "total": 0}
112 result = result.unwrap()
113 if isinstance(result, dict):
114 return {
115 "results": list(result.get("results", [])),
116 "total": result.get("total", 0),
117 }
118 return {
119 "results": list(result.results) if hasattr(result, "results") else [],
120 "total": result.total if hasattr(result, "total") else 0,
121 }
123 @property
124 def is_available(self) -> bool:
125 """True when a real search engine is resolved (not the no-op)."""
126 return self._search is not None and not isinstance(self._search, _NoOpSearch)
128 @property
129 def fallback_to_like(self) -> bool:
130 return getattr(self._config, "fallback_to_like", True)
133__all__ = ["SearchIntegration"]