Coverage for src/lumen/search/search_param_hooks.py: 76%

84 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-08-31 18:46 +0000

1""" 

2This module contains post-write hooks triggered when SearchParameter resources change. 

3It updates database indices and triggers background reindexing tasks for existing resources. 

4""" 

5 

6from __future__ import annotations 

7 

8from typing import Any 

9from uuid import UUID 

10 

11import asyncpg 

12import structlog 

13 

14from ..fhir.params import SearchParamDef 

15from ..fhir.path import evaluate_path 

16from .registry import SearchParamRegistry 

17 

18logger = structlog.get_logger(__name__) 

19 

20_SUPPORTED_TYPES: frozenset[str] = frozenset( 

21 {"string", "token", "date", "reference", "quantity", "number"} 

22) 

23 

24 

25class SearchParamValidationError(Exception): 

26 """Raised when a SearchParameter definition is invalid.""" 

27 

28 def __init__(self, message: str) -> None: 

29 self.message = message 

30 super().__init__(message) 

31 

32 

33def _validate_search_param( 

34 resource: dict[str, Any], registry: SearchParamRegistry 

35) -> tuple[str, str, str, list[str]]: 

36 """Validate a SearchParameter definition. 

37 

38 Returns a tuple of (code, type, expression, base_types). 

39 Raises SearchParamValidationError if invalid. 

40 """ 

41 code = resource.get("code") 

42 sp_type = resource.get("type") 

43 expression = resource.get("expression") 

44 base_types: list[str] = resource.get("base", []) 

45 

46 if not code or not isinstance(code, str): 

47 raise SearchParamValidationError("SearchParameter.code is required") 

48 

49 if not sp_type or sp_type not in _SUPPORTED_TYPES: 

50 raise SearchParamValidationError( 

51 f"SearchParameter.type must be one of {sorted(_SUPPORTED_TYPES)}, got: {sp_type!r}" 

52 ) 

53 

54 if not expression or not isinstance(expression, str): 

55 raise SearchParamValidationError("SearchParameter.expression is required") 

56 

57 if not base_types: 

58 raise SearchParamValidationError( 

59 "SearchParameter.base must list at least one resource type" 

60 ) 

61 

62 # Check for base-param shadowing. 

63 for rt in base_types: 

64 if registry.is_base_param(rt, code): 

65 raise SearchParamValidationError( 

66 f"Custom SearchParameter '{code}' shadows a base parameter" 

67 ) 

68 

69 # Validate FHIRPath expression by attempting evaluation against an empty 

70 # resource. This catches syntax errors without needing real data. 

71 for rt in base_types: 

72 try: 

73 evaluate_path(expression, {"resourceType": rt}) 

74 except Exception as exc: 

75 raise SearchParamValidationError( 

76 f"Invalid FHIRPath expression '{expression}': {exc}" 

77 ) from exc 

78 

79 return code, sp_type, expression, base_types 

80 

81 

82async def handle_search_param_write( 

83 conn: asyncpg.Connection[asyncpg.Record] | asyncpg.pool.PoolConnectionProxy[asyncpg.Record], 

84 tenant_id: UUID, 

85 resource: dict[str, Any], 

86 registry: SearchParamRegistry, 

87) -> None: 

88 """Process a SearchParameter create or update. 

89 

90 Validates the definition, upserts into ``custom_search_params``, invalidates 

91 the registry cache, and synchronously reindexes existing resources for the 

92 new/changed parameter. 

93 

94 Raises :class:`SearchParamValidationError` on invalid definitions. 

95 """ 

96 code, sp_type, expression, base_types = _validate_search_param(resource, registry) 

97 status = resource.get("status", "active") 

98 description = resource.get("description") 

99 

100 # ── Persist ──────────────────────────────────────────────────────────── 

101 

102 # Only active SearchParameters are registered for search/indexing. 

103 # Draft/retired ones are stored in fhir_resources but not in custom_search_params. 

104 if status != "active": 

105 # Remove from custom_search_params if it was previously active. 

106 for rt in base_types: 

107 await conn.execute( 

108 """ 

109 DELETE FROM custom_search_params 

110 WHERE tenant_id = $1 AND code = $2 AND resource_type = $3 

111 """, 

112 tenant_id, 

113 code, 

114 rt, 

115 ) 

116 registry.invalidate(tenant_id) 

117 await logger.ainfo( 

118 "search_param.skipped_non_active", 

119 code=code, 

120 status=status, 

121 tenant_id=str(tenant_id), 

122 ) 

123 return 

124 

125 for rt in base_types: 

126 await conn.execute( 

127 """ 

128 INSERT INTO custom_search_params 

129 (tenant_id, code, resource_type, param_type, expression, description, fhir_resource) 

130 VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) 

131 ON CONFLICT (tenant_id, code, resource_type) 

132 DO UPDATE SET 

133 param_type = EXCLUDED.param_type, 

134 expression = EXCLUDED.expression, 

135 description = EXCLUDED.description, 

136 fhir_resource = EXCLUDED.fhir_resource, 

137 updated_at = now() 

138 """, 

139 tenant_id, 

140 code, 

141 rt, 

142 sp_type, 

143 expression, 

144 description, 

145 resource, 

146 ) 

147 

148 registry.invalidate(tenant_id) 

149 

150 await logger.ainfo( 

151 "search_param.registered", 

152 code=code, 

153 type=sp_type, 

154 base=base_types, 

155 tenant_id=str(tenant_id), 

156 ) 

157 

158 # ── Reindex ──────────────────────────────────────────────────────────── 

159 

160 sp_def = SearchParamDef( 

161 name=code, 

162 type=sp_type, # type: ignore[arg-type] # validated above 

163 paths=[p.strip() for p in expression.split("|") if p.strip()], 

164 ) 

165 for rt in base_types: 

166 await _reindex_for_param(conn, tenant_id, rt, sp_def) 

167 

168 

169async def handle_search_param_delete( 

170 conn: asyncpg.Connection[asyncpg.Record] | asyncpg.pool.PoolConnectionProxy[asyncpg.Record], 

171 tenant_id: UUID, 

172 resource: dict[str, Any], 

173 registry: SearchParamRegistry, 

174) -> None: 

175 """Process a SearchParameter deletion. 

176 

177 Removes the parameter from ``custom_search_params`` and invalidates the 

178 registry cache. Index rows in ``spidx_*`` tables are left as-is (they 

179 become orphans and are cleaned up on next resource write). 

180 """ 

181 code = resource.get("code") 

182 base_types: list[str] = resource.get("base", []) 

183 

184 if not code or not base_types: 

185 return 

186 

187 for rt in base_types: 

188 await conn.execute( 

189 """ 

190 DELETE FROM custom_search_params 

191 WHERE tenant_id = $1 AND code = $2 AND resource_type = $3 

192 """, 

193 tenant_id, 

194 code, 

195 rt, 

196 ) 

197 

198 registry.invalidate(tenant_id) 

199 

200 await logger.ainfo( 

201 "search_param.unregistered", 

202 code=code, 

203 base=base_types, 

204 tenant_id=str(tenant_id), 

205 ) 

206 

207 

208# ── reindex helper ───────────────────────────────────────────────────────────── 

209 

210 

211async def _reindex_for_param( 

212 conn: asyncpg.Connection[asyncpg.Record] | asyncpg.pool.PoolConnectionProxy[asyncpg.Record], 

213 tenant_id: UUID, 

214 resource_type: str, 

215 sp: SearchParamDef, 

216) -> None: 

217 """Index all existing resources of ``resource_type`` for a single new param. 

218 

219 Streams resources in batches to avoid loading everything into memory at once. 

220 Only **adds** new index rows — does not touch existing indexes for other params. 

221 """ 

222 from .indexer import _index_param # type: ignore[reportPrivateUsage] 

223 

224 batch_size = 500 

225 last_id: str | None = None 

226 total = 0 

227 

228 while True: 

229 if last_id is None: 

230 rows = await conn.fetch( 

231 """ 

232 SELECT DISTINCT ON (resource_id) resource_id, resource 

233 FROM fhir_resources 

234 WHERE tenant_id = $1 

235 AND resource_type = $2 

236 AND deleted = FALSE 

237 ORDER BY resource_id, version_id DESC 

238 LIMIT $3 

239 """, 

240 tenant_id, 

241 resource_type, 

242 batch_size, 

243 ) 

244 else: 

245 rows = await conn.fetch( 

246 """ 

247 SELECT DISTINCT ON (resource_id) resource_id, resource 

248 FROM fhir_resources 

249 WHERE tenant_id = $1 

250 AND resource_type = $2 

251 AND deleted = FALSE 

252 AND resource_id > $3 

253 ORDER BY resource_id, version_id DESC 

254 LIMIT $4 

255 """, 

256 tenant_id, 

257 resource_type, 

258 last_id, 

259 batch_size, 

260 ) 

261 

262 if not rows: 

263 break 

264 

265 for row in rows: 

266 resource_id: str = row["resource_id"] 

267 raw = row["resource"] 

268 resource: dict[str, Any] = ( 

269 __import__("json").loads(raw) if isinstance(raw, str) else dict(raw) 

270 ) 

271 await _index_param(conn, tenant_id, resource_type, resource_id, resource, sp) 

272 total += 1 

273 

274 last_id = rows[-1]["resource_id"] 

275 if len(rows) < batch_size: 

276 break 

277 

278 if total > 0: 

279 await logger.ainfo( 

280 "search_param.reindex_complete", 

281 code=sp.name, 

282 resource_type=resource_type, 

283 count=total, 

284 tenant_id=str(tenant_id), 

285 )