Coverage for src/lumen/search/indexer.py: 85%

227 statements  

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

1""" 

2This module extracts indexable values from FHIR resources based on search parameter definitions. 

3It populates the corresponding search index tables within the resource write transaction. 

4""" 

5 

6from __future__ import annotations 

7 

8import re 

9from datetime import UTC, datetime 

10from typing import Any, cast 

11from uuid import UUID 

12 

13import asyncpg 

14import structlog 

15 

16from ..fhir.params import SearchParamDef 

17from ..fhir.path import evaluate_path 

18 

19logger = structlog.get_logger(__name__) 

20 

21# ── public API ───────────────────────────────────────────────────────────────── 

22 

23 

24async def index_resource( 

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

26 tenant_id: UUID, 

27 resource_type: str, 

28 resource_id: str, 

29 resource: dict[str, Any], 

30 params: list[SearchParamDef], 

31) -> None: 

32 """Delete old spidx_* rows for this resource and insert fresh ones.""" 

33 await _delete_indexes(conn, tenant_id, resource_type, resource_id) 

34 for sp in params: 

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

36 

37 

38async def delete_indexes( 

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

40 tenant_id: UUID, 

41 resource_type: str, 

42 resource_id: str, 

43) -> None: 

44 await _delete_indexes(conn, tenant_id, resource_type, resource_id) 

45 

46 

47# ── private helpers ──────────────────────────────────────────────────────────── 

48 

49 

50async def _delete_indexes( 

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

52 tenant_id: UUID, 

53 resource_type: str, 

54 resource_id: str, 

55) -> None: 

56 for table in ( 

57 "spidx_string", 

58 "spidx_token", 

59 "spidx_date", 

60 "spidx_reference", 

61 "spidx_quantity", 

62 "spidx_number", 

63 "spidx_composite", 

64 "spidx_geo", 

65 ): 

66 await conn.execute( 

67 f"DELETE FROM {table} WHERE tenant_id=$1 AND resource_type=$2 AND resource_id=$3", 

68 tenant_id, 

69 resource_type, 

70 resource_id, 

71 ) 

72 

73 

74async def _index_param( 

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

76 tenant_id: UUID, 

77 resource_type: str, 

78 resource_id: str, 

79 resource: dict[str, Any], 

80 sp: SearchParamDef, 

81) -> None: 

82 values: list[Any] = [] 

83 for path in sp.paths: 

84 values.extend(evaluate_path(path, resource)) 

85 

86 if sp.type == "string": 

87 await _insert_strings(conn, tenant_id, resource_type, resource_id, sp.name, values) 

88 elif sp.type == "token": 

89 await _insert_tokens(conn, tenant_id, resource_type, resource_id, sp.name, values) 

90 elif sp.type == "date": 

91 await _insert_dates(conn, tenant_id, resource_type, resource_id, sp.name, values) 

92 elif sp.type == "reference": 

93 await _insert_references(conn, tenant_id, resource_type, resource_id, sp.name, values) 

94 elif sp.type == "quantity": 

95 await _insert_quantities(conn, tenant_id, resource_type, resource_id, sp.name, values) 

96 elif sp.type == "number": 

97 await _insert_numbers(conn, tenant_id, resource_type, resource_id, sp.name, values) 

98 elif sp.type == "composite": 

99 await _insert_composite(conn, tenant_id, resource_type, resource_id, sp, values) 

100 elif sp.type == "geo": 

101 await _insert_geo(conn, tenant_id, resource_type, resource_id, sp.name, values) 

102 

103 

104# ── string ───────────────────────────────────────────────────────────────────── 

105 

106 

107def _string_values(node: Any) -> list[str]: 

108 """Collect indexable strings from a node. 

109 

110 String search parameters often point at complex types (HumanName, Address); 

111 collect every string leaf so e.g. ``Patient.name`` indexes family/given/text. 

112 """ 

113 out: list[str] = [] 

114 if isinstance(node, str): 

115 if node.strip(): 

116 out.append(node) 

117 elif isinstance(node, dict): 

118 for v in cast("dict[str, Any]", node).values(): 

119 out.extend(_string_values(v)) 

120 elif isinstance(node, list): 

121 for v in cast("list[Any]", node): 

122 out.extend(_string_values(v)) 

123 return out 

124 

125 

126async def _insert_strings( 

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

128 tenant_id: UUID, 

129 resource_type: str, 

130 resource_id: str, 

131 param_name: str, 

132 values: list[Any], 

133) -> None: 

134 for val in values: 

135 for s in _string_values(val): 

136 await conn.execute( 

137 """ 

138 INSERT INTO spidx_string (tenant_id, resource_type, resource_id, param_name, value) 

139 VALUES ($1,$2,$3,$4,$5) 

140 """, 

141 tenant_id, 

142 resource_type, 

143 resource_id, 

144 param_name, 

145 s.lower(), 

146 ) 

147 

148 

149# ── token ────────────────────────────────────────────────────────────────────── 

150 

151# (system, code, display) — ``display`` is the textual part searched by ``:text``. 

152TokenPair = tuple[str | None, str, str | None] 

153 

154 

155def _extract_codeable_concept_pairs(d: dict[str, Any]) -> list[TokenPair]: 

156 # ``display`` carries the textual part searched by the token ``:text`` modifier. 

157 pairs: list[TokenPair] = [] 

158 text = d.get("text") 

159 text_str = str(text) if text else None 

160 if text_str: 

161 pairs.append((None, text_str, text_str)) 

162 for coding in cast("list[Any]", d.get("coding", [])): 

163 if isinstance(coding, dict): 

164 cd: dict[str, Any] = cast("dict[str, Any]", coding) 

165 code = cd.get("code", "") 

166 if code: 

167 display = cd.get("display") 

168 pairs.append( 

169 ( 

170 cast(str | None, cd.get("system")), 

171 str(code), 

172 str(display) if display else None, 

173 ) 

174 ) 

175 return pairs 

176 

177 

178def _extract_token_pairs_from_dict(d: dict[str, Any]) -> list[TokenPair]: 

179 # CodeableConcept: has coding[] and/or text. 

180 if "coding" in d or "text" in d: 

181 return _extract_codeable_concept_pairs(d) 

182 

183 # Coding: system + code (+ display for :text). 

184 if "code" in d: 

185 code = d.get("code", "") 

186 if code: 

187 display = d.get("display") 

188 return [ 

189 ( 

190 cast(str | None, d.get("system")), 

191 str(code), 

192 str(display) if display else None, 

193 ) 

194 ] 

195 

196 # Identifier: system + value. 

197 if "value" in d: 

198 val = d.get("value", "") 

199 if val: 

200 return [(cast(str | None, d.get("system")), str(val), None)] 

201 

202 return [] 

203 

204 

205def _extract_token_pairs(value: Any) -> list[TokenPair]: 

206 """Return (system, code, display) tuples for an extracted value, inferred from shape. 

207 

208 Handles the token-bearing datatypes: code (str), boolean, Coding, 

209 CodeableConcept, and Identifier — distinguished by the JSON node's keys. 

210 ``display`` is the textual part matched by the ``:text`` modifier and is 

211 ``None`` when the datatype carries no text. 

212 """ 

213 if isinstance(value, bool): 

214 return [(None, "true" if value else "false", None)] 

215 if isinstance(value, str): 

216 return [(None, value, None)] if value else [] 

217 if not isinstance(value, dict): 

218 return [] 

219 

220 return _extract_token_pairs_from_dict(cast("dict[str, Any]", value)) 

221 

222 

223async def _insert_tokens( 

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

225 tenant_id: UUID, 

226 resource_type: str, 

227 resource_id: str, 

228 param_name: str, 

229 values: list[Any], 

230) -> None: 

231 for val in values: 

232 for system, code, display in _extract_token_pairs(val): 

233 await conn.execute( 

234 """ 

235 INSERT INTO spidx_token 

236 (tenant_id, resource_type, resource_id, param_name, system, code, display) 

237 VALUES ($1,$2,$3,$4,$5,$6,$7) 

238 """, 

239 tenant_id, 

240 resource_type, 

241 resource_id, 

242 param_name, 

243 system, 

244 code, 

245 display, 

246 ) 

247 

248 

249# ── date ─────────────────────────────────────────────────────────────────────── 

250 

251_DATE_ONLY_RE = re.compile(r"^\d{4}(-\d{2}(-\d{2})?)?$") 

252 

253 

254def _parse_fhir_date(value: str) -> tuple[datetime, datetime]: 

255 """Parse a FHIR date/dateTime string into (low, high) inclusive bounds.""" 

256 if _DATE_ONLY_RE.match(value): 

257 parts = value.split("-") 

258 if len(parts) == 1: # year only 

259 lo = datetime(int(parts[0]), 1, 1, tzinfo=UTC) 

260 hi = datetime(int(parts[0]), 12, 31, 23, 59, 59, tzinfo=UTC) 

261 elif len(parts) == 2: # year-month 

262 import calendar 

263 

264 y, m = int(parts[0]), int(parts[1]) 

265 last_day = calendar.monthrange(y, m)[1] 

266 lo = datetime(y, m, 1, tzinfo=UTC) 

267 hi = datetime(y, m, last_day, 23, 59, 59, tzinfo=UTC) 

268 else: # full date 

269 y, m, d = int(parts[0]), int(parts[1]), int(parts[2]) 

270 lo = datetime(y, m, d, tzinfo=UTC) 

271 hi = datetime(y, m, d, 23, 59, 59, tzinfo=UTC) 

272 return lo, hi 

273 

274 # dateTime / instant — strip timezone info and parse 

275 clean = value.replace("Z", "+00:00") 

276 # Remove sub-second precision beyond microseconds 

277 clean = re.sub(r"(\.\d{6})\d+", r"\1", clean) 

278 dt = datetime.fromisoformat(clean) 

279 if dt.tzinfo is None: 

280 dt = dt.replace(tzinfo=UTC) 

281 return dt, dt 

282 

283 

284def _parse_extracted_date( 

285 val: Any, 

286 resource_type: str, 

287 resource_id: str, 

288 param_name: str, 

289) -> tuple[datetime | None, datetime | None]: 

290 lo: datetime | None = None 

291 hi: datetime | None = None 

292 try: 

293 # Period / Range: {start,end}; otherwise a date/dateTime/instant string. 

294 if isinstance(val, dict) and ("start" in val or "end" in val): 

295 period: dict[str, Any] = cast("dict[str, Any]", val) 

296 start_str = period.get("start") 

297 end_str = period.get("end") 

298 if start_str: 

299 lo, _ = _parse_fhir_date(str(start_str)) 

300 if end_str: 

301 _, hi = _parse_fhir_date(str(end_str)) 

302 elif isinstance(val, str) and val: 

303 lo, hi = _parse_fhir_date(val) 

304 except (ValueError, KeyError) as e: 

305 logger.warning( 

306 "indexer.parse_error", 

307 resource_type=resource_type, 

308 resource_id=resource_id, 

309 param_name=param_name, 

310 value=val, 

311 error=str(e), 

312 ) 

313 return lo, hi 

314 

315 

316async def _insert_dates( 

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

318 tenant_id: UUID, 

319 resource_type: str, 

320 resource_id: str, 

321 param_name: str, 

322 values: list[Any], 

323) -> None: 

324 for val in values: 

325 lo, hi = _parse_extracted_date(val, resource_type, resource_id, param_name) 

326 if lo is not None or hi is not None: 

327 await conn.execute( 

328 """ 

329 INSERT INTO spidx_date 

330 (tenant_id, resource_type, resource_id, param_name, value_low, value_high) 

331 VALUES ($1,$2,$3,$4,$5,$6) 

332 """, 

333 tenant_id, 

334 resource_type, 

335 resource_id, 

336 param_name, 

337 lo, 

338 hi, 

339 ) 

340 

341 

342# ── reference ────────────────────────────────────────────────────────────────── 

343 

344 

345def _parse_reference(value: Any) -> tuple[str | None, str] | None: 

346 """Extract (resource_type, resource_id) from a FHIR Reference dict or string.""" 

347 if isinstance(value, dict): 

348 ref: str = cast("str", cast("dict[str, Any]", value).get("reference", "")) 

349 elif isinstance(value, str): 

350 ref = value 

351 else: 

352 return None 

353 

354 if not ref: 

355 return None 

356 

357 # Absolute URL: https://example.com/fhir/Patient/123 

358 # Relative: Patient/123 or just 123 

359 parts: list[str] = ref.split("/") 

360 if len(parts) >= 2: 

361 return parts[-2], parts[-1] 

362 return None, parts[-1] 

363 

364 

365async def _insert_references( 

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

367 tenant_id: UUID, 

368 resource_type: str, 

369 resource_id: str, 

370 param_name: str, 

371 values: list[Any], 

372) -> None: 

373 for val in values: 

374 parsed = _parse_reference(val) 

375 if parsed is None: 

376 continue 

377 target_type, target_id = parsed 

378 if target_id: 

379 await conn.execute( 

380 """ 

381 INSERT INTO spidx_reference 

382 (tenant_id, resource_type, resource_id, param_name, 

383 target_resource_type, target_resource_id) 

384 VALUES ($1,$2,$3,$4,$5,$6) 

385 """, 

386 tenant_id, 

387 resource_type, 

388 resource_id, 

389 param_name, 

390 target_type, 

391 target_id, 

392 ) 

393 

394 

395# ── quantity ─────────────────────────────────────────────────────────────────── 

396 

397 

398async def _insert_quantities( 

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

400 tenant_id: UUID, 

401 resource_type: str, 

402 resource_id: str, 

403 param_name: str, 

404 values: list[Any], 

405) -> None: 

406 for val in values: 

407 if not isinstance(val, dict): 

408 continue 

409 qty: dict[str, Any] = cast("dict[str, Any]", val) 

410 raw_value = qty.get("value") 

411 if raw_value is None: 

412 continue 

413 try: 

414 numeric = float(raw_value) # type: ignore[arg-type] # Any from FHIR JSON 

415 except (TypeError, ValueError): 

416 continue 

417 await conn.execute( 

418 """ 

419 INSERT INTO spidx_quantity 

420 (tenant_id, resource_type, resource_id, param_name, system, code, value) 

421 VALUES ($1,$2,$3,$4,$5,$6,$7) 

422 """, 

423 tenant_id, 

424 resource_type, 

425 resource_id, 

426 param_name, 

427 cast(str | None, qty.get("system")), 

428 cast(str | None, qty.get("code") or qty.get("unit")), 

429 numeric, 

430 ) 

431 

432 

433# ── number ───────────────────────────────────────────────────────────────────── 

434 

435 

436async def _insert_numbers( 

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

438 tenant_id: UUID, 

439 resource_type: str, 

440 resource_id: str, 

441 param_name: str, 

442 values: list[Any], 

443) -> None: 

444 for val in values: 

445 try: 

446 numeric = float(val) 

447 except (TypeError, ValueError) as e: 

448 logger.warning( 

449 "indexer.parse_error", 

450 resource_type=resource_type, 

451 resource_id=resource_id, 

452 param_name=param_name, 

453 value=val, 

454 error=str(e), 

455 ) 

456 continue 

457 await conn.execute( 

458 """ 

459 INSERT INTO spidx_number 

460 (tenant_id, resource_type, resource_id, param_name, value) 

461 VALUES ($1,$2,$3,$4,$5) 

462 """, 

463 tenant_id, 

464 resource_type, 

465 resource_id, 

466 param_name, 

467 numeric, 

468 ) 

469 

470 

471# ── composite ────────────────────────────────────────────────────────────────── 

472 

473 

474async def _insert_composite( 

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

476 tenant_id: UUID, 

477 resource_type: str, 

478 resource_id: str, 

479 sp: SearchParamDef, 

480 occurrences: list[Any], 

481) -> None: 

482 """Index each occurrence of a composite param's base path (e.g. one 

483 ``Observation.component`` entry, or the whole resource for the 

484 ``Observation | Observation.component`` "either" branch) as a row pairing 

485 both components — the correlation a plain per-type spidx table can't 

486 express (see migration 004 for why). 

487 """ 

488 if sp.components is None or len(sp.components) != 2: 

489 return 

490 c1, c2 = sp.components 

491 for occurrence in occurrences: 

492 if not isinstance(occurrence, dict): 

493 continue 

494 occ: dict[str, Any] = cast("dict[str, Any]", occurrence) 

495 c1_pairs = [ 

496 (system, code) 

497 for val in evaluate_path(c1.expression, occ) 

498 for system, code, _ in _extract_token_pairs(val) 

499 ] 

500 if not c1_pairs: 

501 continue 

502 if c2.type == "token": 

503 await _insert_composite_token_pairs( 

504 conn, tenant_id, resource_type, resource_id, sp.name, c1_pairs, c2.expression, occ 

505 ) 

506 else: 

507 await _insert_composite_quantity_pairs( 

508 conn, tenant_id, resource_type, resource_id, sp.name, c1_pairs, c2.expression, occ 

509 ) 

510 

511 

512async def _insert_composite_token_pairs( 

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

514 tenant_id: UUID, 

515 resource_type: str, 

516 resource_id: str, 

517 param_name: str, 

518 c1_pairs: list[tuple[str | None, str]], 

519 c2_expression: str, 

520 occurrence: dict[str, Any], 

521) -> None: 

522 c2_pairs = [ 

523 (system, code) 

524 for val in evaluate_path(c2_expression, occurrence) 

525 for system, code, _ in _extract_token_pairs(val) 

526 ] 

527 for c1_system, c1_code in c1_pairs: 

528 for c2_system, c2_code in c2_pairs: 

529 await conn.execute( 

530 """ 

531 INSERT INTO spidx_composite 

532 (tenant_id, resource_type, resource_id, param_name, 

533 c1_token_system, c1_token_code, c2_token_system, c2_token_code) 

534 VALUES ($1,$2,$3,$4,$5,$6,$7,$8) 

535 """, 

536 tenant_id, 

537 resource_type, 

538 resource_id, 

539 param_name, 

540 c1_system, 

541 c1_code, 

542 c2_system, 

543 c2_code, 

544 ) 

545 

546 

547async def _insert_composite_quantity_pairs( 

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

549 tenant_id: UUID, 

550 resource_type: str, 

551 resource_id: str, 

552 param_name: str, 

553 c1_pairs: list[tuple[str | None, str]], 

554 c2_expression: str, 

555 occurrence: dict[str, Any], 

556) -> None: 

557 for c2_val in evaluate_path(c2_expression, occurrence): 

558 if not isinstance(c2_val, dict): 

559 continue 

560 qty: dict[str, Any] = cast("dict[str, Any]", c2_val) 

561 raw_value = qty.get("value") 

562 if raw_value is None: 

563 continue 

564 try: 

565 numeric = float(raw_value) # type: ignore[arg-type] # Any from FHIR JSON 

566 except (TypeError, ValueError): 

567 continue 

568 for c1_system, c1_code in c1_pairs: 

569 await conn.execute( 

570 """ 

571 INSERT INTO spidx_composite 

572 (tenant_id, resource_type, resource_id, param_name, 

573 c1_token_system, c1_token_code, c2_qty_system, c2_qty_code, c2_qty_value) 

574 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) 

575 """, 

576 tenant_id, 

577 resource_type, 

578 resource_id, 

579 param_name, 

580 c1_system, 

581 c1_code, 

582 cast(str | None, qty.get("system")), 

583 cast(str | None, qty.get("code") or qty.get("unit")), 

584 numeric, 

585 ) 

586 

587 

588# ── geo ──────────────────────────────────────────────────────────────────────── 

589 

590 

591async def _insert_geo( 

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

593 tenant_id: UUID, 

594 resource_type: str, 

595 resource_id: str, 

596 param_name: str, 

597 values: list[Any], 

598) -> None: 

599 for val in values: 

600 if not isinstance(val, dict): 

601 continue 

602 position: dict[str, Any] = cast("dict[str, Any]", val) 

603 lat, lon = position.get("latitude"), position.get("longitude") 

604 if lat is None or lon is None: 

605 continue 

606 try: 

607 lat_f, lon_f = float(lat), float(lon) # type: ignore[arg-type] # Any from FHIR JSON 

608 except (TypeError, ValueError): 

609 continue 

610 await conn.execute( 

611 """ 

612 INSERT INTO spidx_geo 

613 (tenant_id, resource_type, resource_id, param_name, latitude, longitude) 

614 VALUES ($1,$2,$3,$4,$5,$6) 

615 """, 

616 tenant_id, 

617 resource_type, 

618 resource_id, 

619 param_name, 

620 lat_f, 

621 lon_f, 

622 )