Coverage for src/time_agnostic_library/agnostic_query.py: 99%

842 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-03 21:17 +0000

1# SPDX-FileCopyrightText: 2021-2026 Arcangelo Massari <arcangelo.massari@unibo.it> 

2# 

3# SPDX-License-Identifier: ISC 

4 

5 

6import atexit 

7import json 

8import os 

9import re 

10from collections import Counter 

11from collections.abc import Callable, Iterable 

12from concurrent.futures import ThreadPoolExecutor, as_completed 

13from itertools import pairwise, product 

14from pathlib import Path 

15from typing import NoReturn, cast 

16 

17from rdflib import URIRef 

18from rdflib.paths import InvPath 

19from rdflib.paths import Path as PropertyPath 

20from rdflib.plugins.sparql.parserutils import CompValue 

21from rdflib.plugins.sparql.processor import prepareQuery 

22 

23from time_agnostic_library.agnostic_entity import ( 

24 AgnosticEntity, 

25 _fast_parse_update, 

26 _iter_working_states, 

27 _materialize_versions, 

28 _parse_datetime, 

29 _select_interval_snapshots, 

30) 

31from time_agnostic_library.prov_entity import ProvEntity 

32from time_agnostic_library.sparql import Sparql, _binding_to_n3, _n3_to_binding 

33from time_agnostic_library.support import convert_to_datetime 

34 

35CONFIG_PATH = "./config.json" 

36 

37_OBJECT_POS = 2 

38 

39_PARALLEL_THRESHOLD = os.cpu_count() or 1 

40 

41# jena-text answers a text:query with no explicit limit with at most 10000 hits 

42# and drops the rest silently, leaving entities out of the reconstruction. 

43_FUSEKI_TEXT_SEARCH_LIMIT = 10_000_000 

44 

45# Algebra nodes the pattern collection below knows how to walk. Anything else 

46# would be flattened into a conjunction of its operands, silently returning 

47# wrong results, so it is rejected instead. 

48_SUPPORTED_ALGEBRA_NODES = frozenset( 

49 {"SelectQuery", "Project", "Distinct", "Join", "LeftJoin", "BGP"} 

50) 

51 

52# rdflib maps BIND, the expressions of a SELECT clause and every form of 

53# grouping onto Extend, which always wraps the aggregation nodes below it, so 

54# the label has to hold for all of them. 

55_SPARQL_CONSTRUCT_NAMES = { 

56 "Union": "UNION", 

57 "Filter": "FILTER", 

58 "Minus": "MINUS", 

59 "Graph": "GRAPH", 

60 "OrderBy": "ORDER BY", 

61 "Slice": "LIMIT or OFFSET", 

62 "Extend": "BIND or an aggregate function", 

63 "ToMultiSet": "VALUES or a subquery", 

64} 

65 

66_COVERAGE_NOTE = ( 

67 "Time agnostic queries cover SELECT queries made of basic graph patterns " 

68 "and OPTIONAL clauses." 

69) 

70 

71 

72def _reject(construct: str) -> NoReturn: 

73 msg = f"The query uses {construct}, which is not supported. {_COVERAGE_NOTE}" 

74 raise ValueError(msg) 

75 

76 

77def _reject_unsupported(node_name: str) -> None: 

78 if node_name in _SUPPORTED_ALGEBRA_NODES: 

79 return 

80 _reject(_SPARQL_CONSTRUCT_NAMES.get(node_name, node_name)) 

81 

82 

83def _contains_algebra_node(node: CompValue, name: str) -> bool: 

84 if node.name == name: 

85 return True 

86 return any( 

87 _contains_algebra_node(value, name) 

88 for value in node.values() 

89 if isinstance(value, CompValue) 

90 ) 

91 

92 

93def _is_unsupported_path(term: object) -> bool: 

94 # An inverse path over a plain predicate is the one path form the library 

95 # resolves, by rewriting it in _n3_triples. Every other form would reach the 

96 # matcher as an opaque predicate string and match nothing. 

97 if isinstance(term, InvPath): 

98 return not isinstance(term.arg, URIRef) 

99 return isinstance(term, PropertyPath) 

100 

101 

102def _n3_triples(node: CompValue) -> list[tuple[str, ...]]: 

103 triples = [] 

104 for triple in node["triples"]: 

105 if any(_is_unsupported_path(el) for el in triple): 

106 _reject("a property path") 

107 subject, predicate, obj = triple 

108 if isinstance(predicate, InvPath): 

109 # Read the pattern in its direct orientation, so that every step 

110 # downstream works on a plain triple. 

111 subject, predicate, obj = obj, predicate.arg, subject 

112 triples.append((subject.n3(), predicate.n3(), obj.n3())) 

113 return triples 

114 

115 

116def _reject_filter_in_optional(node: CompValue) -> None: 

117 # rdflib keeps the condition of a FILTER inside an OPTIONAL in the expr slot 

118 # of the left join, where TrueFilter stands for no condition at all. 

119 if node["expr"].name != "TrueFilter": 

120 _reject("a FILTER inside an OPTIONAL") 

121 

122 

123_IO_EXECUTOR = ThreadPoolExecutor(max_workers=2) 

124atexit.register(_IO_EXECUTOR.shutdown, wait=False) 

125 

126 

127def _run_in_parallel(worker_fn, args_list): 

128 if len(args_list) < _PARALLEL_THRESHOLD: 

129 for args in args_list: 

130 yield worker_fn(*args) 

131 return 

132 with ThreadPoolExecutor() as executor: 

133 futures = { 

134 executor.submit(worker_fn, *args): i for i, args in enumerate(args_list) 

135 } 

136 for future in as_completed(futures): 

137 yield future.result() 

138 

139 

140def _reconstruct_entity_worker(entity, config, on_time): 

141 agnostic_entity = AgnosticEntity( 

142 entity, 

143 config=config, 

144 include_related_objects=False, 

145 include_merged_entities=False, 

146 include_reverse_relations=False, 

147 ) 

148 if on_time: 

149 entity_graphs, _, _ = agnostic_entity.get_state_at_time( 

150 time=on_time, 

151 include_prov_metadata=False, 

152 ) 

153 return entity, entity_graphs 

154 entity_history = agnostic_entity.get_history(include_prov_metadata=False) 

155 return entity, entity_history[0] 

156 

157 

158_LITERAL_N3_RE = re.compile( 

159 r""" 

160 ^ " (?P<lexical>.*) " # Greedy matching retains escaped quotes. 

161 (?: @(?P<language>[\w-]+) | \^\^<[^>]*> )? 

162 \Z 

163 """, 

164 re.VERBOSE, 

165) 

166 

167 

168def _normalize_constant(term_n3: str) -> str | None: 

169 if term_n3.startswith("<") and term_n3.endswith(">"): 

170 return term_n3 

171 match = _LITERAL_N3_RE.match(term_n3) 

172 if match is None: 

173 return None 

174 language = match["language"] 

175 if language is None: 

176 return term_n3 

177 return f'"{match["lexical"]}"@{language.lower()}' 

178 

179 

180def _pattern_constants(triple: tuple) -> set[str]: 

181 constants = (_normalize_constant(el) for el in triple[:3]) 

182 return {constant for constant in constants if constant is not None} 

183 

184 

185def _pattern_search_terms(triple: tuple) -> set[str]: 

186 return {el[1:-1] for el in triple[:3] if el.startswith("<") and el.endswith(">")} 

187 

188 

189def _fuseki_search_phrases(triple: tuple) -> list[str]: 

190 predicate = _normalize_constant(triple[1]) 

191 obj = _normalize_constant(triple[2]) 

192 if obj is not None and obj.startswith("<"): 

193 if predicate is not None: 

194 return [f"{predicate} {obj} ."] 

195 return [f"{obj} ."] 

196 return [f"<{term}>" for term in sorted(_pattern_search_terms(triple))] 

197 

198 

199def _escape_search_term(text: str, *quotes: str) -> str: 

200 escaped = text.replace("\\", "\\\\") 

201 for quote in quotes: 

202 escaped = escaped.replace(quote, "\\" + quote) 

203 return escaped 

204 

205 

206def _expected_quad_slots(triple: tuple) -> tuple[str | None, str | None, str | None]: 

207 subject, predicate, obj = triple[:3] 

208 return ( 

209 _normalize_constant(subject), 

210 _normalize_constant(predicate), 

211 _normalize_constant(obj), 

212 ) 

213 

214 

215def _matching_update_quads( 

216 update_query: str, triple: tuple 

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

218 quad_matches = _quad_filter_for_pattern(triple) 

219 return [ 

220 quad 

221 for _, quads in _fast_parse_update(update_query) 

222 for quad in quads 

223 if quad_matches(quad) 

224 ] 

225 

226 

227def _quad_filter_for_pattern( 

228 triple: tuple, 

229) -> Callable[[tuple[str, ...]], bool]: 

230 expected = tuple( 

231 (index, slot, slot.startswith("<")) 

232 for index, slot in enumerate(_expected_quad_slots(triple)) 

233 if slot is not None 

234 ) 

235 

236 def matches(quad: tuple[str, ...]) -> bool: 

237 return all( 

238 quad[index] == slot if is_iri else _normalize_constant(quad[index]) == slot 

239 for index, slot, is_iri in expected 

240 ) 

241 

242 return matches 

243 

244 

245def _sparql_values(uris: set[str]) -> str: 

246 return " ".join(f"<{uri}>" for uri in uris) 

247 

248 

249def _wrap_in_graph(body: str, *, is_quadstore: bool) -> str: 

250 if is_quadstore: 

251 return f"GRAPH ?g {{ {body} }}" 

252 return body 

253 

254 

255def _batch_query_provenance_snapshots( 

256 entity_uris: set[str], config: dict 

257) -> dict[str, list[dict]]: 

258 values = _sparql_values(entity_uris) 

259 body = f""" 

260 ?snapshot <{ProvEntity.iri_specialization_of}> ?entity; 

261 <{ProvEntity.iri_generated_at_time}> ?time. 

262 OPTIONAL {{ ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery. }} 

263 VALUES ?entity {{ {values} }} 

264 """ 

265 wrapped = _wrap_in_graph(body, is_quadstore=config["provenance"]["is_quadstore"]) 

266 query = f"SELECT ?entity ?time ?updateQuery WHERE {{ {wrapped} }}" 

267 results = Sparql(query, config).run_select_query() 

268 output: dict[str, list[dict]] = {uri: [] for uri in entity_uris} 

269 for binding in results["results"]["bindings"]: 

270 entity_uri = binding["entity"]["value"] 

271 entry = { 

272 "time": binding["time"]["value"], 

273 "updateQuery": binding["updateQuery"]["value"] 

274 if "updateQuery" in binding 

275 else None, 

276 } 

277 output[entity_uri].append(entry) 

278 return output 

279 

280 

281def _sparql_filter_in(var: str, uris: set[str]) -> str: 

282 return f"FILTER({var} IN ({', '.join(f'<{uri}>' for uri in uris)}))" 

283 

284 

285def _batch_query_dataset_triples( 

286 entity_uris: set[str], 

287 config: dict, 

288 *, 

289 is_virtuoso: bool, 

290 triple: tuple | None = None, 

291) -> dict[str, set[tuple]]: 

292 is_quadstore = config["dataset"]["is_quadstore"] 

293 predicate = "?p" 

294 obj = "?o" 

295 if triple is not None: 

296 predicate = triple[1] if _normalize_constant(triple[1]) is not None else "?p" 

297 obj = triple[2] if _normalize_constant(triple[2]) is not None else "?o" 

298 # Virtuoso resolves VALUES inexplicably slowly, while Jena needs it: under 

299 # FILTER ... IN no index prefix applies and it scans the whole store. 

300 if is_virtuoso: 

301 body = f"?s {predicate} {obj}. {_sparql_filter_in('?s', entity_uris)}" 

302 else: 

303 body = f"VALUES ?s {{ {_sparql_values(entity_uris)} }} ?s {predicate} {obj}." 

304 wrapped = _wrap_in_graph(body, is_quadstore=is_quadstore) 

305 select_vars = " ".join( 

306 variable 

307 for variable in ("?s", predicate, obj, "?g" if is_quadstore else None) 

308 if variable is not None and variable.startswith("?") 

309 ) 

310 query = f"SELECT {select_vars} WHERE {{ {wrapped} }}" 

311 results = Sparql(query, config).run_select_query() 

312 output: dict[str, set[tuple]] = {uri: set() for uri in entity_uris} 

313 for binding in results["results"]["bindings"]: 

314 s_val = binding["s"]["value"] 

315 s = _binding_to_n3(binding["s"]) 

316 p = _binding_to_n3(binding["p"]) if predicate == "?p" else predicate 

317 o = _binding_to_n3(binding["o"]) if obj == "?o" else obj 

318 if is_quadstore and "g" in binding: 

319 output[s_val].add((s, p, o, _binding_to_n3(binding["g"]))) 

320 else: 

321 output[s_val].add((s, p, o)) 

322 return output 

323 

324 

325def _reconstruct_at_time_as_sets( 

326 prov_snapshots: list[dict], 

327 dataset_quads: set[tuple], 

328 on_time: tuple[str | None, str | None], 

329 triple: tuple | None = None, 

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

331 if not prov_snapshots: 

332 return [] 

333 sorted_snaps = sorted( 

334 prov_snapshots, key=lambda x: _parse_datetime(x["time"]), reverse=True 

335 ) 

336 snapshot_bindings = [ 

337 {"time": {"value": snapshot["time"]}} for snapshot in sorted_snaps 

338 ] 

339 relevant, start_timestamp_alias = _select_interval_snapshots( 

340 on_time, snapshot_bindings, time_index="time" 

341 ) 

342 if not relevant: 

343 return [] 

344 relevant_times = {r["time"]["value"] for r in relevant} 

345 sorted_versions = [ 

346 (snapshot["time"], snapshot["updateQuery"]) for snapshot in sorted_snaps 

347 ] 

348 quad_filter = _quad_filter_for_pattern(triple) if triple is not None else None 

349 materialized = _materialize_versions( 

350 sorted_versions, dataset_quads, relevant_times, quad_filter 

351 ) 

352 return [ 

353 ( 

354 start_timestamp_alias[1] 

355 if start_timestamp_alias is not None 

356 and timestamp == start_timestamp_alias[0] 

357 else timestamp, 

358 quads, 

359 ) 

360 for timestamp, quads in materialized 

361 ] 

362 

363 

364def _match_single_pattern( 

365 triple_pattern: tuple, quads: Iterable[tuple[str, ...]] 

366) -> list[dict]: 

367 s_pat, p_pat, o_pat = triple_pattern[0], triple_pattern[1], triple_pattern[2] 

368 s_is_var = s_pat.startswith("?") 

369 p_is_var = p_pat.startswith("?") 

370 o_is_var = o_pat.startswith("?") 

371 bindings = [] 

372 for quad in quads: 

373 s, p, o = quad[0], quad[1], quad[2] 

374 if not p_is_var and p != p_pat: 

375 continue 

376 if not o_is_var and o != o_pat: 

377 continue 

378 if not s_is_var and s != s_pat: 

379 continue 

380 binding = {} 

381 if s_is_var: 

382 binding[s_pat[1:]] = _n3_to_binding(s) 

383 if p_is_var: 

384 binding[p_pat[1:]] = _n3_to_binding(p) 

385 if o_is_var: 

386 binding[o_pat[1:]] = _n3_to_binding(o) 

387 bindings.append(binding) 

388 return bindings 

389 

390 

391def _index_quads_by_subject( 

392 quads: set[tuple[str, ...]], 

393) -> dict[str, set[tuple[str, ...]]]: 

394 by_subject: dict[str, set[tuple[str, ...]]] = {} 

395 for quad in quads: 

396 subject = quad[0] 

397 if subject in by_subject: 

398 by_subject[subject].add(quad) 

399 else: 

400 by_subject[subject] = {quad} 

401 return by_subject 

402 

403 

404def _merge_entity_bindings( 

405 entity_bindings: dict[str, dict[str, list[dict]]], 

406) -> dict[str, list[dict]]: 

407 all_timestamps: set[str] = set() 

408 for per_ts in entity_bindings.values(): 

409 all_timestamps.update(per_ts.keys()) 

410 sorted_timestamps = sorted(all_timestamps, key=_parse_datetime) 

411 result: dict[str, list[dict]] = {} 

412 last_known: dict[str, list[dict]] = {} 

413 for ts in sorted_timestamps: 

414 merged: list[dict] = [] 

415 for entity_str, per_ts in entity_bindings.items(): 

416 if ts in per_ts: 

417 last_known[entity_str] = per_ts[ts] 

418 if entity_str in last_known: 

419 merged.extend(last_known[entity_str]) 

420 result[ts] = merged 

421 return result 

422 

423 

424def _binding_key( 

425 binding: dict[str, dict[str, str]], 

426) -> frozenset[tuple[str, frozenset[tuple[str, str]]]]: 

427 return frozenset( 

428 (variable, frozenset(value.items())) for variable, value in binding.items() 

429 ) 

430 

431 

432def _bag_difference(left: list[dict], right: list[dict]) -> list[dict]: 

433 remaining = Counter(_binding_key(binding) for binding in right) 

434 difference = [] 

435 for binding in left: 

436 key = _binding_key(binding) 

437 if remaining[key] > 0: 

438 remaining[key] -= 1 

439 else: 

440 difference.append(binding) 

441 return difference 

442 

443 

444def _states_at( 

445 history: list[tuple[str, list[dict]]], timeline: list[str] 

446) -> list[tuple[str, list[dict]]]: 

447 history_position = 0 

448 state: list[dict] = [] 

449 states = [] 

450 for timestamp in timeline: 

451 requested = _parse_datetime(timestamp) 

452 while ( 

453 history_position < len(history) 

454 and _parse_datetime(history[history_position][0]) <= requested 

455 ): 

456 state = history[history_position][1] 

457 history_position += 1 

458 states.append((timestamp, state)) 

459 return states 

460 

461 

462def _build_solution_delta( 

463 results: dict[str, list[dict]], 

464 on_time: tuple[str | None, str | None] | None, 

465) -> dict[str, list | None]: 

466 history = sorted(results.items(), key=lambda item: _parse_datetime(item[0])) 

467 if not history: 

468 return {"additions": [], "deletions": [], "changes": []} 

469 start = on_time[0] if on_time and on_time[0] else history[0][0] 

470 end = on_time[1] if on_time and on_time[1] else history[-1][0] 

471 if _parse_datetime(start) > _parse_datetime(end): 

472 message = "The start of the interval must not follow its end" 

473 raise ValueError(message) 

474 timeline = [start] 

475 timeline.extend( 

476 timestamp 

477 for timestamp, _ in history 

478 if _parse_datetime(start) < _parse_datetime(timestamp) < _parse_datetime(end) 

479 ) 

480 if end != start: 

481 timeline.append(end) 

482 states = _states_at(history, timeline) 

483 start_state = states[0][1] 

484 end_state = states[-1][1] 

485 changes = [ 

486 { 

487 "start": previous_time, 

488 "end": current_time, 

489 "additions": _bag_difference(current_state, previous_state), 

490 "deletions": _bag_difference(previous_state, current_state), 

491 } 

492 for (previous_time, previous_state), (current_time, current_state) in pairwise( 

493 states 

494 ) 

495 ] 

496 return { 

497 "additions": _bag_difference(end_state, start_state), 

498 "deletions": _bag_difference(start_state, end_state), 

499 "changes": changes, 

500 } 

501 

502 

503class AgnosticQuery: 

504 blazegraph_full_text_search: bool 

505 fuseki_full_text_search: bool 

506 virtuoso_full_text_search: bool 

507 graphdb_connector_name: str 

508 

509 def __init__( 

510 self, 

511 query: str, 

512 on_time: tuple[str | None, str | None] | None = (None, None), 

513 *, 

514 merge_aware: bool = False, 

515 include_prov_metadata: bool = False, 

516 config_path: str = CONFIG_PATH, 

517 config_dict: dict | None = None, 

518 ): 

519 self.query = query 

520 self.merge_aware = merge_aware 

521 self.include_prov_metadata = include_prov_metadata 

522 self.config_path = config_path 

523 self._merge_adjacency: dict[str, set[str]] = {} 

524 self._merge_events: dict[str, dict] = {} 

525 self._merge_scanned_entities: set[str] = set() 

526 if config_dict is not None: 

527 self.config = config_dict 

528 else: 

529 with Path(config_path).open(encoding="utf8") as json_file: 

530 self.config = json.load(json_file) 

531 self.__init_text_index(self.config) 

532 if on_time: 

533 after_time = convert_to_datetime(on_time[0], stringify=True) 

534 before_time = convert_to_datetime(on_time[1], stringify=True) 

535 self.on_time: tuple[str | None, str | None] | None = ( 

536 after_time, 

537 before_time, 

538 ) # type: ignore[assignment] 

539 else: 

540 self.on_time = None 

541 self.reconstructed_entities: set[str] = set() 

542 self.vars_to_explicit_by_time: dict = {} 

543 self.relevant_entities_graphs: dict[str, dict[str, set]] = {} 

544 self.relevant_graphs: dict[str, set[tuple[str, ...]]] = {} 

545 self._rebuild_relevant_graphs() 

546 

547 def _query_adjacent_merge_events(self, entity_uris: set[str]) -> list[dict]: 

548 values = _sparql_values(entity_uris) 

549 merge_pattern = f""" 

550 ?snapshot <{ProvEntity.iri_specialization_of}> ?survivor; 

551 <{ProvEntity.iri_generated_at_time}> ?time; 

552 <{ProvEntity.iri_was_derived_from}> ?sourceSnapshot. 

553 """ 

554 source_pattern = ( 

555 f"?sourceSnapshot <{ProvEntity.iri_specialization_of}> ?absorbed." 

556 ) 

557 if self.config["provenance"]["is_quadstore"]: 

558 merge_pattern = f"GRAPH ?mergeGraph {{ {merge_pattern} }}" 

559 source_pattern = f"GRAPH ?sourceGraph {{ {source_pattern} }}" 

560 query = f""" 

561 SELECT DISTINCT ?snapshot ?time ?survivor ?absorbed 

562 WHERE {{ 

563 VALUES ?participant {{ {values} }} 

564 {merge_pattern} 

565 {source_pattern} 

566 FILTER (?survivor != ?absorbed) 

567 FILTER (?survivor = ?participant || ?absorbed = ?participant) 

568 }} 

569 """ 

570 results = Sparql(query, self.config).run_select_query() 

571 return results["results"]["bindings"] 

572 

573 def _expand_entities_with_merges(self, entity_uris: set[str]) -> set[str]: 

574 if not self.merge_aware or not entity_uris: 

575 return set(entity_uris) 

576 component = set(entity_uris) 

577 frontier = component.difference(self._merge_scanned_entities) 

578 while frontier: 

579 self._merge_scanned_entities.update(frontier) 

580 discovered = set() 

581 for binding in self._query_adjacent_merge_events(frontier): 

582 survivor = binding["survivor"]["value"] 

583 absorbed = binding["absorbed"]["value"] 

584 snapshot = binding["snapshot"]["value"] 

585 event = self._merge_events.setdefault( 

586 snapshot, 

587 { 

588 "time": str( 

589 convert_to_datetime( 

590 binding["time"]["value"], stringify=True 

591 ) 

592 ), 

593 "snapshot": snapshot, 

594 "survivor": survivor, 

595 "absorbed": set(), 

596 }, 

597 ) 

598 event["absorbed"].add(absorbed) 

599 self._merge_adjacency.setdefault(survivor, set()).add(absorbed) 

600 self._merge_adjacency.setdefault(absorbed, set()).add(survivor) 

601 discovered.update((survivor, absorbed)) 

602 component.update(discovered) 

603 frontier = discovered.difference(self._merge_scanned_entities) 

604 queue = list(component) 

605 while queue: 

606 entity_uri = queue.pop() 

607 adjacent_entities = ( 

608 self._merge_adjacency[entity_uri] 

609 if entity_uri in self._merge_adjacency 

610 else set() 

611 ) 

612 for adjacent in adjacent_entities: 

613 if adjacent not in component: 

614 component.add(adjacent) 

615 queue.append(adjacent) 

616 return component 

617 

618 def _entity_aliases(self, entity_uri: str) -> set[str]: 

619 return self._expand_entities_with_merges({entity_uri}) 

620 

621 def _expand_triple_aliases(self, triple: tuple[str, ...]) -> list[tuple[str, ...]]: 

622 term_options = [] 

623 for index, term in enumerate(triple): 

624 if ( 

625 index in (0, _OBJECT_POS) 

626 and term.startswith("<") 

627 and term.endswith(">") 

628 ): 

629 term_options.append( 

630 [f"<{uri}>" for uri in sorted(self._entity_aliases(term[1:-1]))] 

631 ) 

632 else: 

633 term_options.append([term]) 

634 return [tuple(terms) for terms in product(*term_options)] 

635 

636 def _load_provenance( 

637 self, entity_uris: set[str] 

638 ) -> tuple[dict | None, dict | None]: 

639 if not self.include_prov_metadata: 

640 return None, None 

641 provenance = {} 

642 other_provenance = {} 

643 for entity_uri in sorted(entity_uris): 

644 agnostic_entity = AgnosticEntity( 

645 entity_uri, 

646 config=self.config, 

647 include_related_objects=False, 

648 include_merged_entities=False, 

649 include_reverse_relations=False, 

650 ) 

651 if self.on_time is None: 

652 _, entity_metadata = agnostic_entity.get_history( 

653 include_prov_metadata=True 

654 ) 

655 snapshots = ( 

656 entity_metadata[entity_uri] if entity_uri in entity_metadata else {} 

657 ) 

658 if snapshots: 

659 provenance[entity_uri] = snapshots 

660 continue 

661 _, relevant_snapshots, other_snapshots = agnostic_entity.get_state_at_time( 

662 self.on_time, 

663 include_prov_metadata=True, 

664 ) 

665 if relevant_snapshots: 

666 provenance[entity_uri] = relevant_snapshots 

667 if other_snapshots: 

668 other_provenance[entity_uri] = other_snapshots 

669 return provenance, other_provenance 

670 

671 def __init_text_index(self, config: dict): 

672 for full_text_search in ( 

673 "blazegraph_full_text_search", 

674 "fuseki_full_text_search", 

675 "virtuoso_full_text_search", 

676 ): 

677 ts_full_text_search: str = config[full_text_search] 

678 if ts_full_text_search.lower() in {"true", "1", 1, "t", "y", "yes", "ok"}: 

679 setattr(self, full_text_search, True) 

680 elif ( 

681 ts_full_text_search.lower() in {"false", "0", 0, "n", "f", "no"} 

682 or not ts_full_text_search 

683 ): 

684 setattr(self, full_text_search, False) 

685 else: 

686 msg = ( 

687 f"Enter a valid value for '{full_text_search}' in the " 

688 "configuration file, for example 'yes' or 'no'." 

689 ) 

690 raise ValueError(msg) 

691 self.graphdb_connector_name = config["graphdb_connector_name"] 

692 if ( 

693 len( 

694 [ 

695 index 

696 for index in [ 

697 self.blazegraph_full_text_search, 

698 self.fuseki_full_text_search, 

699 self.virtuoso_full_text_search, 

700 self.graphdb_connector_name, 

701 ] 

702 if index 

703 ] 

704 ) 

705 > 1 

706 ): 

707 msg = ( 

708 "The use of multiple indexing systems simultaneously " 

709 "is currently not supported." 

710 ) 

711 raise ValueError(msg) 

712 

713 def _process_query(self) -> list[tuple[str, ...]]: 

714 # Parse the SPARQL string into an algebra tree via rdflib, then walk 

715 # the tree to extract triple patterns as N3 strings, separating 

716 # mandatory patterns from OPTIONAL groups. 

717 algebra = prepareQuery(self.query).algebra 

718 if algebra.name != "SelectQuery": 

719 msg = "Only SELECT queries are allowed." 

720 raise ValueError(msg) 

721 mandatory: list[tuple[str, ...]] = [] 

722 self._optional_groups: list[list[tuple[str, ...]]] = [] 

723 self._collect_patterns(algebra, mandatory) 

724 all_triples = list(mandatory) 

725 for group in self._optional_groups: 

726 all_triples.extend(group) 

727 # Reject triples made of only variables (e.g. ?s ?p ?o): they would 

728 # match every entity in the dataset, making the query too expensive. 

729 triples_without_hook = [ 

730 t for t in all_triples if all(el.startswith("?") for el in t) 

731 ] 

732 if triples_without_hook: 

733 msg = ( 

734 "Could not perform a generic time agnostic query. " 

735 "Please, specify at least one URI or Literal within the query." 

736 ) 

737 raise ValueError(msg) 

738 self._select_vars = [str(v) for v in algebra["PV"]] 

739 self._distinct = _contains_algebra_node(algebra, "Distinct") 

740 self._mandatory_triples = mandatory 

741 return all_triples 

742 

743 def _collect_patterns( 

744 self, node: CompValue, mandatory: list[tuple[str, ...]] 

745 ) -> None: 

746 name = node.name 

747 if name == "LeftJoin": 

748 # OPTIONAL = left join: p1 (left, mandatory) must match, p2 (right, 

749 # optional) extends the binding if possible, otherwise it's ignored 

750 _reject_filter_in_optional(node) 

751 self._collect_patterns(node["p1"], mandatory) 

752 opt_group: list[tuple[str, ...]] = [] 

753 self._collect_triples_flat(node["p2"], opt_group) 

754 if opt_group: 

755 self._optional_groups.append(opt_group) 

756 elif name == "Join": 

757 # Both sides are mandatory (rdflib splits BGPs into Join nodes) 

758 self._collect_patterns(node["p1"], mandatory) 

759 self._collect_patterns(node["p2"], mandatory) 

760 elif "triples" in node: 

761 # BGP leaf node: convert rdflib terms to N3 strings 

762 mandatory.extend(_n3_triples(node)) 

763 else: 

764 _reject_unsupported(name) 

765 for v in node.values(): 

766 if isinstance(v, CompValue): 

767 self._collect_patterns(v, mandatory) 

768 

769 def _collect_triples_flat( 

770 self, node: CompValue, triples: list[tuple[str, ...]] 

771 ) -> None: 

772 name = node.name 

773 if name == "LeftJoin": 

774 # A nested OPTIONAL: its patterns join the group being flattened. 

775 _reject_filter_in_optional(node) 

776 self._collect_triples_flat(node["p1"], triples) 

777 self._collect_triples_flat(node["p2"], triples) 

778 return 

779 if "triples" in node: 

780 triples.extend(_n3_triples(node)) 

781 return 

782 _reject_unsupported(name) 

783 for v in node.values(): 

784 if isinstance(v, CompValue): 

785 self._collect_triples_flat(v, triples) 

786 

787 def _rebuild_relevant_graphs(self) -> None: 

788 triples_checked = set() 

789 all_isolated = True 

790 self.triples = self._process_query() 

791 for triple in self.triples: 

792 if self._is_isolated(triple) and self._is_a_new_triple( 

793 triple, triples_checked 

794 ): 

795 present_entities = self._get_present_entities(triple) 

796 self._rebuild_relevant_entity(triple[0]) 

797 self._find_entities_in_update_queries(triple, present_entities) 

798 else: 

799 all_isolated = False 

800 self._rebuild_relevant_entity(triple[0]) 

801 triples_checked.add(triple) 

802 self._align_snapshots() 

803 if not all_isolated: 

804 self._solve_variables() 

805 

806 def _is_isolated(self, triple: tuple) -> bool: 

807 if triple[0].startswith("<") and triple[0].endswith(">"): 

808 return False 

809 variables = [el for el in triple if el.startswith("?")] 

810 for variable in variables: 

811 other_triples = {t for t in self.triples if t != triple} 

812 if self._there_is_transitive_closure(variable, other_triples): 

813 return False 

814 return True 

815 

816 def _there_is_transitive_closure(self, variable: str, triples: set[tuple]) -> bool: 

817 there_is_transitive_closure = False 

818 for triple in triples: 

819 if variable in triple and triple.index(variable) == _OBJECT_POS: 

820 if triple[0].startswith("<") and triple[0].endswith(">"): 

821 return True 

822 if triple[0].startswith("?"): 

823 other_triples = {t for t in triples if t != triple} 

824 there_is_transitive_closure = self._there_is_transitive_closure( 

825 triple[0], other_triples 

826 ) 

827 return there_is_transitive_closure 

828 

829 def _rebuild_relevant_entity(self, entity_n3: str) -> None: 

830 if entity_n3.startswith("<") and entity_n3.endswith(">"): 

831 entity_uris = self._expand_entities_with_merges({entity_n3[1:-1]}) 

832 for entity_uri in entity_uris: 

833 if entity_uri not in self.reconstructed_entities: 

834 self.reconstructed_entities.add(entity_uri) 

835 result = self._reconstruct_entity_state(entity_uri) 

836 if result is not None: 

837 self._merge_entity_result(entity_uri, result) 

838 

839 def _reconstruct_entity_state(self, entity_uri: str) -> dict | None: 

840 agnostic_entity = AgnosticEntity( 

841 entity_uri, 

842 config=self.config, 

843 include_related_objects=False, 

844 include_merged_entities=False, 

845 include_reverse_relations=False, 

846 ) 

847 if self.on_time: 

848 entity_graphs, _, _ = agnostic_entity.get_state_at_time( 

849 time=self.on_time, include_prov_metadata=False 

850 ) 

851 return entity_graphs 

852 entity_history = agnostic_entity.get_history(include_prov_metadata=False) 

853 return entity_history[0] 

854 

855 def _merge_entity_result(self, entity_uri: str, entity_graphs: dict) -> None: 

856 if self.on_time: 

857 if entity_graphs: 

858 for relevant_timestamp, quad_set in entity_graphs.items(): 

859 self.relevant_entities_graphs.setdefault(entity_uri, {})[ 

860 relevant_timestamp 

861 ] = quad_set 

862 elif entity_graphs.get(entity_uri): 

863 self.relevant_entities_graphs.update(entity_graphs) 

864 

865 def _get_present_entities(self, triple: tuple) -> set[str]: 

866 entities = set() 

867 for expanded_triple in self._expand_triple_aliases(triple): 

868 entities.update(self._get_present_entities_exact(expanded_triple)) 

869 return entities 

870 

871 def _get_present_entities_exact(self, triple: tuple) -> set[str]: 

872 variables = [el for el in triple if el.startswith("?")] 

873 if self.config["dataset"]["is_quadstore"]: 

874 query = ( 

875 f"SELECT {' '.join(variables)} WHERE " 

876 f"{{GRAPH ?_g {{{triple[0]} {triple[1]} {triple[2]}}} " 

877 "FILTER(!CONTAINS(STR(?_g), '/prov/'))}" 

878 ) 

879 else: 

880 query = ( 

881 f"SELECT {' '.join(variables)} WHERE " 

882 f"{{{triple[0]} {triple[1]} {triple[2]}}}" 

883 ) 

884 results = Sparql(query, self.config).run_select_query() 

885 bindings = results["results"]["bindings"] 

886 var_name = triple[0][1:] 

887 return { 

888 b[var_name]["value"] 

889 for b in bindings 

890 if var_name in b and b[var_name]["type"] == "uri" 

891 } 

892 

893 def _is_a_new_triple(self, triple: tuple, triples_checked: set) -> bool: 

894 constants = _pattern_constants(triple) 

895 for triple_checked in triples_checked: 

896 if not constants.difference(_pattern_constants(triple_checked)): 

897 return False 

898 return True 

899 

900 def _get_query_to_update_queries(self, triple: tuple) -> str: 

901 if self.fuseki_full_text_search: 

902 phrases = _fuseki_search_phrases(triple) 

903 query_obj = '\\" AND \\"'.join( 

904 _escape_search_term(phrase, '"') for phrase in phrases 

905 ) 

906 return f""" 

907 PREFIX text: <http://jena.apache.org/text#> 

908 SELECT ?updateQuery WHERE {{ 

909 ?se text:query ("\\"{query_obj}\\"" {_FUSEKI_TEXT_SEARCH_LIMIT}); 

910 <{ProvEntity.iri_has_update_query}> ?updateQuery. 

911 }} 

912 """ 

913 return self.get_full_text_search(_pattern_search_terms(triple)) 

914 

915 def get_full_text_search(self, terms: set) -> str: 

916 if not terms: 

917 query_to_identify = f""" 

918 SELECT ?updateQuery 

919 WHERE {{ 

920 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery. 

921 }} 

922 """ 

923 elif self.blazegraph_full_text_search: 

924 query_obj = " ".join(_escape_search_term(term, '"') for term in terms) 

925 query_to_identify = f""" 

926 PREFIX bds: <http://www.bigdata.com/rdf/search#> 

927 SELECT ?updateQuery 

928 WHERE {{ 

929 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery. 

930 ?updateQuery bds:search "{query_obj}"; 

931 bds:matchAllTerms 'true'. 

932 }} 

933 """ 

934 elif self.fuseki_full_text_search: 

935 # The angle brackets keep the IRI a single token under the 

936 # whitespace tokenizer the index requires, so a namespace root such 

937 # as <http://www.w3.org/> no longer matches every IRI below it. 

938 query_obj = '\\" AND \\"'.join( 

939 _escape_search_term(f"<{term}>", '"') for term in terms 

940 ) 

941 query_to_identify = f""" 

942 PREFIX text: <http://jena.apache.org/text#> 

943 SELECT ?updateQuery WHERE {{ 

944 ?se text:query ("\\"{query_obj}\\"" {_FUSEKI_TEXT_SEARCH_LIMIT}); 

945 <{ProvEntity.iri_has_update_query}> ?updateQuery. 

946 }} 

947 """ 

948 elif self.virtuoso_full_text_search: 

949 query_obj = "' AND '".join( 

950 _escape_search_term(term, '"', "'") for term in terms 

951 ) 

952 query_to_identify = f""" 

953 PREFIX bif: <bif:> 

954 SELECT ?updateQuery 

955 WHERE {{ 

956 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery. 

957 ?updateQuery bif:contains "'{query_obj}'". 

958 }} 

959 """ 

960 elif self.graphdb_connector_name: 

961 quote = '"' 

962 con_queries = ( 

963 f"con:query '{quote}" 

964 + f"{quote}'; con:query '{quote}".join( 

965 _escape_search_term(term, "'", '"') for term in terms 

966 ) 

967 + f"{quote}'" 

968 ) 

969 query_to_identify = f""" 

970 PREFIX con: <http://www.ontotext.com/connectors/lucene#> 

971 PREFIX con-inst: <http://www.ontotext.com/connectors/lucene/instance#> 

972 SELECT ?updateQuery 

973 WHERE {{ 

974 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery. 

975 [] a con-inst:{self.graphdb_connector_name}; 

976 {con_queries}; 

977 con:entities ?snapshot. 

978 }} 

979 """ 

980 else: 

981 escaped = [_escape_search_term(term, "'") for term in terms] 

982 filters = ").".join( 

983 f"FILTER CONTAINS (?updateQuery, '{term}'" for term in escaped 

984 ) 

985 query_to_identify = f""" 

986 SELECT ?updateQuery 

987 WHERE {{ 

988 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery. 

989 {filters}). 

990 }} 

991 """ 

992 return query_to_identify 

993 

994 def _find_entity_uris_in_update_queries(self, triple: tuple, entities: set) -> None: 

995 for expanded_triple in self._expand_triple_aliases(triple): 

996 self._find_entity_uris_in_update_queries_exact(expanded_triple, entities) 

997 

998 def _find_entity_uris_in_update_queries_exact( 

999 self, triple: tuple, entities: set 

1000 ) -> None: 

1001 if not any( 

1002 [ 

1003 self.blazegraph_full_text_search, 

1004 self.fuseki_full_text_search, 

1005 self.virtuoso_full_text_search, 

1006 self.graphdb_connector_name, 

1007 ] 

1008 ): 

1009 terms = _pattern_search_terms(triple) 

1010 escaped = [_escape_search_term(term, "'") for term in terms] 

1011 filter_clauses = "\n".join( 

1012 f"FILTER CONTAINS (?updateQuery, '{term}')." for term in escaped 

1013 ) 

1014 query = f""" 

1015 SELECT ?entity ?updateQuery WHERE {{ 

1016 ?snapshot <{ProvEntity.iri_specialization_of}> ?entity; 

1017 <{ProvEntity.iri_has_update_query}> ?updateQuery. 

1018 {filter_clauses} 

1019 }} 

1020 """ 

1021 results = Sparql(query, self.config).run_select_query() 

1022 for binding in results["results"]["bindings"]: 

1023 matching_quads = _matching_update_quads( 

1024 binding["updateQuery"]["value"], triple 

1025 ) 

1026 if matching_quads: 

1027 entities.add(binding["entity"]["value"]) 

1028 return 

1029 query_to_identify = self._get_query_to_update_queries(triple) 

1030 results = Sparql(query_to_identify, self.config).run_select_query() 

1031 bindings = results["results"]["bindings"] 

1032 if self.fuseki_full_text_search and len(bindings) >= _FUSEKI_TEXT_SEARCH_LIMIT: 

1033 terms = ", ".join(sorted(_pattern_search_terms(triple))) 

1034 msg = ( 

1035 f"The full-text search for {terms} returned " 

1036 f"{_FUSEKI_TEXT_SEARCH_LIMIT} update queries, which is the " 

1037 "limit, so the rest were dropped and the entities behind them " 

1038 "would be missing from the answer." 

1039 ) 

1040 raise ValueError(msg) 

1041 for binding in bindings: 

1042 for quad in _matching_update_quads(binding["updateQuery"]["value"], triple): 

1043 subject = quad[0].removeprefix("<").removesuffix(">") 

1044 if subject.startswith("_:"): 

1045 continue 

1046 entities.add(subject) 

1047 

1048 def _find_entities_in_update_queries( 

1049 self, triple: tuple, present_entities: set | None = None 

1050 ): 

1051 if present_entities is None: 

1052 present_entities = set() 

1053 relevant_entities_found = present_entities 

1054 self._find_entity_uris_in_update_queries(triple, relevant_entities_found) 

1055 if relevant_entities_found: 

1056 relevant_entities_found = self._expand_entities_with_merges( 

1057 relevant_entities_found 

1058 ) 

1059 args_list = [ 

1060 (entity_uri, self.config, self.on_time) 

1061 for entity_uri in relevant_entities_found 

1062 ] 

1063 for result in _run_in_parallel(_reconstruct_entity_worker, args_list): 

1064 if result is not None: 

1065 entity, entity_graphs = result 

1066 self.reconstructed_entities.add(entity) 

1067 self._merge_entity_result(entity, entity_graphs) 

1068 

1069 def _term_matches(self, expected: str, actual: str, index: int) -> bool: 

1070 if expected == actual: 

1071 return True 

1072 if ( 

1073 not self.merge_aware 

1074 or index not in (0, _OBJECT_POS) 

1075 or not expected.startswith("<") 

1076 or not expected.endswith(">") 

1077 or not actual.startswith("<") 

1078 or not actual.endswith(">") 

1079 ): 

1080 return False 

1081 return actual[1:-1] in self._entity_aliases(expected[1:-1]) 

1082 

1083 def _solve_variables(self) -> None: 

1084 self.vars_to_explicit_by_time = {} 

1085 self._get_vars_to_explicit_by_time() 

1086 while self._there_are_variables(): 

1087 solved_variables = self._explicit_solvable_variables() 

1088 self._align_snapshots() 

1089 if not solved_variables: 

1090 return 

1091 self._update_vars_to_explicit(solved_variables) 

1092 self._get_vars_to_explicit_by_time() 

1093 

1094 def _there_are_variables(self) -> bool: 

1095 for triples in self.vars_to_explicit_by_time.values(): 

1096 for triple in triples: 

1097 if any(el.startswith("?") for el in triple): 

1098 return True 

1099 return False 

1100 

1101 def _explicit_solvable_variables(self) -> dict: 

1102 explicit_triples: dict[str, dict[str, set]] = {} 

1103 for se, triples in self.vars_to_explicit_by_time.items(): 

1104 for triple in triples: 

1105 variables = [el for el in triple if el.startswith("?")] 

1106 if len(variables) == 1: 

1107 variable = variables[0] 

1108 variable_index = triple.index(variable) 

1109 if variable_index == _OBJECT_POS: 

1110 subject_terms = {triple[0]} 

1111 if triple[0].startswith("<") and triple[0].endswith(">"): 

1112 subject_terms = { 

1113 f"<{uri}>" 

1114 for uri in self._entity_aliases(triple[0][1:-1]) 

1115 } 

1116 quads_by_subject = self._relevant_graphs_by_subject[se] 

1117 query_results = [ 

1118 (triple[0], triple[1], q[2]) 

1119 for subject in subject_terms 

1120 if subject in quads_by_subject 

1121 for q in quads_by_subject[subject] 

1122 if q[1] == triple[1] 

1123 ] 

1124 for row in query_results: 

1125 explicit_triples.setdefault(se, {}) 

1126 explicit_triples[se].setdefault(variable, set()) 

1127 explicit_triples[se][variable].add(row) 

1128 discovered_entities = { 

1129 row[2][1:-1] 

1130 for row in query_results 

1131 if row[2].startswith("<") and row[2].endswith(">") 

1132 } 

1133 discovered_entities = self._expand_entities_with_merges( 

1134 discovered_entities 

1135 ).difference(self.reconstructed_entities) 

1136 args_list = [ 

1137 (entity_uri, self.config, self.on_time) 

1138 for entity_uri in discovered_entities 

1139 ] 

1140 for result_data in _run_in_parallel( 

1141 _reconstruct_entity_worker, args_list 

1142 ): 

1143 if result_data is not None: 

1144 entity, entity_graphs = result_data 

1145 self.reconstructed_entities.add(entity) 

1146 self._merge_entity_result(entity, entity_graphs) 

1147 return explicit_triples 

1148 

1149 def _align_snapshots(self) -> None: 

1150 for snapshots in self.relevant_entities_graphs.values(): 

1151 for snapshot, quad_set in snapshots.items(): 

1152 if snapshot in self.relevant_graphs: 

1153 self.relevant_graphs[snapshot].update(quad_set) 

1154 else: 

1155 self.relevant_graphs[snapshot] = set(quad_set) 

1156 self._relevant_graphs_by_subject = { 

1157 timestamp: _index_quads_by_subject(quad_set) 

1158 for timestamp, quad_set in self.relevant_graphs.items() 

1159 } 

1160 if len(self.relevant_graphs) <= 1: 

1161 return 

1162 ordered_data = sorted( 

1163 self.relevant_graphs.items(), 

1164 key=lambda x: _parse_datetime(x[0]), 

1165 ) 

1166 for index in range(1, len(ordered_data)): 

1167 previous_se = ordered_data[index - 1][0] 

1168 se = ordered_data[index][0] 

1169 previous_by_subject = self._relevant_graphs_by_subject[previous_se] 

1170 current_by_subject = self._relevant_graphs_by_subject[se] 

1171 for subject_n3, subject_quads in previous_by_subject.items(): 

1172 subject_uri = ( 

1173 subject_n3[1:-1] if subject_n3.startswith("<") else subject_n3 

1174 ) 

1175 if ( 

1176 subject_n3 not in current_by_subject 

1177 and subject_uri in self.relevant_entities_graphs 

1178 and se not in self.relevant_entities_graphs[subject_uri] 

1179 ): 

1180 self.relevant_graphs[se].update(subject_quads) 

1181 current_by_subject[subject_n3] = set(subject_quads) 

1182 

1183 def _update_vars_to_explicit(self, solved_variables: dict): 

1184 vars_to_explicit_by_time: dict = {} 

1185 for se, triples in self.vars_to_explicit_by_time.items(): 

1186 vars_to_explicit_by_time.setdefault(se, set()) 

1187 new_triples = set() 

1188 for triple in triples: 

1189 if se in solved_variables: 

1190 for solved_var, solved_triples in solved_variables[se].items(): 

1191 if solved_var in triple: 

1192 for solved_triple in solved_triples: 

1193 new_triple = None 

1194 if ( 

1195 solved_triple[0] != triple[0] 

1196 and solved_triple[1] == triple[1] 

1197 ): 

1198 continue 

1199 if ( 

1200 solved_triple[0] == triple[0] 

1201 and solved_triple[1] == triple[1] 

1202 ): 

1203 new_triple = solved_triple 

1204 else: 

1205 new_triple = ( 

1206 solved_triple[2], 

1207 triple[1], 

1208 triple[2], 

1209 ) 

1210 new_triples.add(new_triple) 

1211 elif not any(el.startswith("?") for el in triple) or not any( 

1212 var for var in solved_variables[se] if var in triple 

1213 ): 

1214 new_triples.add(triple) 

1215 vars_to_explicit_by_time[se] = new_triples 

1216 self.vars_to_explicit_by_time = vars_to_explicit_by_time 

1217 

1218 def _get_vars_to_explicit_by_time(self) -> None: 

1219 relevant_triples = None 

1220 for se in self.relevant_graphs: 

1221 if se not in self.vars_to_explicit_by_time: 

1222 if relevant_triples is None: 

1223 relevant_triples = set() 

1224 for triple in self.triples: 

1225 if any( 

1226 el 

1227 for el in triple 

1228 if el.startswith("?") 

1229 and not self._is_a_dead_end(el, triple) 

1230 ) and not self._is_isolated(triple): 

1231 relevant_triples.add(triple) 

1232 self.vars_to_explicit_by_time[se] = set(relevant_triples) 

1233 

1234 def _is_a_dead_end(self, el: str, triple: tuple) -> bool: 

1235 return ( 

1236 el.startswith("?") 

1237 and triple.index(el) == _OBJECT_POS 

1238 and not any(t for t in self.triples if el in t if t.index(el) == 0) 

1239 ) 

1240 

1241 

1242class VersionQuery(AgnosticQuery): 

1243 """Time-travel queries, both on a single version and all versions of the dataset. 

1244 

1245 :param query: The SPARQL query string. 

1246 :type query: str 

1247 :param on_time: If you want to query a specific version, specify the time 

1248 interval here. The format is (START, END). If one of the two values is None, 

1249 only the other is considered. Dates must be in ISO 8601 format. 

1250 :type on_time: Tuple[Union[str, None]], optional 

1251 :param merge_aware: Follow entity histories connected by merges. 

1252 :type merge_aware: bool, optional 

1253 :param include_prov_metadata: Return snapshot metadata with the query results. 

1254 :type include_prov_metadata: bool, optional 

1255 :param config_path: The path to the configuration file. 

1256 :type config_path: str, optional 

1257 """ 

1258 

1259 def __init__( 

1260 self, 

1261 query: str, 

1262 on_time: tuple[str | None, str | None] | None = None, 

1263 *, 

1264 merge_aware: bool = False, 

1265 include_prov_metadata: bool = False, 

1266 config_path: str = CONFIG_PATH, 

1267 config_dict: dict | None = None, 

1268 ): 

1269 self._streaming_results: dict[str, list[dict]] = {} 

1270 super().__init__( 

1271 query, 

1272 on_time, 

1273 merge_aware=merge_aware, 

1274 include_prov_metadata=include_prov_metadata, 

1275 config_path=config_path, 

1276 config_dict=config_dict, 

1277 ) 

1278 

1279 def _rebuild_relevant_graphs(self) -> None: 

1280 self.triples = self._process_query() 

1281 if self.on_time is not None: 

1282 if ( 

1283 len(self.triples) == 1 

1284 and self._is_isolated(self.triples[0]) 

1285 and not self.merge_aware 

1286 ): 

1287 self._rebuild_vm_batch(self.on_time) 

1288 return 

1289 super()._rebuild_relevant_graphs() 

1290 return 

1291 if not all(self._is_isolated(t) for t in self.triples): 

1292 super()._rebuild_relevant_graphs() 

1293 self._streaming_results = { 

1294 str(convert_to_datetime(ts, stringify=True)): self._extract_bindings(g) 

1295 for ts, g in self.relevant_graphs.items() 

1296 } 

1297 return 

1298 self._rebuild_streaming() 

1299 

1300 def _discover_entities_parallel(self, triple: tuple) -> set[str]: 

1301 fut_present = _IO_EXECUTOR.submit(self._get_present_entities, triple) 

1302 entities_set: set = set() 

1303 fut_prov = _IO_EXECUTOR.submit( 

1304 self._find_entity_uris_in_update_queries, triple, entities_set 

1305 ) 

1306 present_entities = fut_present.result() 

1307 fut_prov.result() 

1308 all_entities = set(present_entities) 

1309 all_entities.update(entities_set) 

1310 return all_entities 

1311 

1312 def _rebuild_vm_batch(self, on_time: tuple[str | None, str | None]) -> None: 

1313 triple = self.triples[0] 

1314 all_entity_strs = self._discover_entities_parallel(triple) 

1315 all_entity_strs = self._expand_entities_with_merges(all_entity_strs) 

1316 if not all_entity_strs: 

1317 return 

1318 self.reconstructed_entities.update(all_entity_strs) 

1319 fut_prov = _IO_EXECUTOR.submit( 

1320 _batch_query_provenance_snapshots, all_entity_strs, self.config 

1321 ) 

1322 fut_data = _IO_EXECUTOR.submit( 

1323 _batch_query_dataset_triples, 

1324 all_entity_strs, 

1325 self.config, 

1326 is_virtuoso=self.virtuoso_full_text_search, 

1327 triple=triple, 

1328 ) 

1329 prov_data = fut_prov.result() 

1330 dataset_data = fut_data.result() 

1331 point_time = ( 

1332 str(convert_to_datetime(on_time[0], stringify=True)) 

1333 if on_time[0] is not None and on_time[0] == on_time[1] 

1334 else None 

1335 ) 

1336 entity_bindings: dict[str, dict[str, list[dict]]] = {} 

1337 for entity_str in all_entity_strs: 

1338 per_ts: dict[str, list[dict]] = {} 

1339 for ts, quad_set in _reconstruct_at_time_as_sets( 

1340 prov_data[entity_str], 

1341 dataset_data[entity_str], 

1342 on_time, 

1343 triple, 

1344 ): 

1345 result_time = point_time if point_time is not None else ts 

1346 per_ts[result_time] = _match_single_pattern(triple, quad_set) 

1347 entity_bindings[entity_str] = per_ts 

1348 self._streaming_results = _merge_entity_bindings(entity_bindings) 

1349 

1350 def _extract_bindings(self, quads: set[tuple[str, ...]]) -> list[dict]: 

1351 # Match the SPARQL query patterns against the quad set. 

1352 # Phase 1: mandatory triples. Start with an empty binding and for each 

1353 # pattern keep only the quads that are compatible with what was already 

1354 # matched. 

1355 bindings: list[dict[str, str]] = [{}] 

1356 for pattern in self._mandatory_triples: 

1357 new_bindings: list[dict[str, str]] = [] 

1358 for binding in bindings: 

1359 for quad in quads: 

1360 new_binding = self._try_match(pattern, quad, binding) 

1361 if new_binding is not None: 

1362 new_bindings.append(new_binding) 

1363 bindings = new_bindings 

1364 # Phase 2: OPTIONAL groups. Try to extend each binding, but if nothing 

1365 # matches, keep the binding as-is (no data is lost). 

1366 for opt_group in self._optional_groups: 

1367 bindings = self._left_join(bindings, opt_group, quads) 

1368 # Phase 3: project to SELECT variables and deduplicate. 

1369 seen: set[frozenset] = set() 

1370 result: list[dict] = [] 

1371 for b in bindings: 

1372 projected_n3: dict[str, str] = {} 

1373 for var in self._select_vars: 

1374 key = "?" + var 

1375 if key in b: 

1376 projected_n3[var] = b[key] 

1377 frozen = frozenset(projected_n3.items()) 

1378 if not self._distinct or frozen not in seen: 

1379 seen.add(frozen) 

1380 result.append( 

1381 {var: _n3_to_binding(val) for var, val in projected_n3.items()} 

1382 ) 

1383 return result 

1384 

1385 def _left_join( 

1386 self, 

1387 bindings: list[dict], 

1388 opt_triples: list[tuple], 

1389 quads: set[tuple[str, ...]], 

1390 ) -> list[dict]: 

1391 # For each binding, try to add values from the optional patterns. 

1392 # If a quad matches, the binding grows with new variables. 

1393 # If nothing matches, the binding is kept unchanged. 

1394 result: list[dict] = [] 

1395 for binding in bindings: 

1396 extended: list[dict] = [dict(binding)] 

1397 for pattern in opt_triples: 

1398 new_extended: list[dict] = [] 

1399 for b in extended: 

1400 matched = False 

1401 for quad in quads: 

1402 new_b = self._try_match(pattern, quad, b) 

1403 if new_b is not None: 

1404 new_extended.append(new_b) 

1405 matched = True 

1406 if not matched: 

1407 new_extended.append(b) 

1408 extended = new_extended 

1409 result.extend(extended) 

1410 return result 

1411 

1412 def _try_match(self, pattern: tuple, quad: tuple, binding: dict) -> dict | None: 

1413 # Check if a triple pattern (s, p, o) matches a quad. 

1414 new_binding = dict(binding) 

1415 for index, (expected, actual) in enumerate( 

1416 zip(pattern[:3], quad[:3], strict=True) 

1417 ): 

1418 is_variable = expected.startswith("?") 

1419 if is_variable and expected in new_binding: 

1420 # Variable already bound: check consistency 

1421 if new_binding[expected] != actual: 

1422 return None 

1423 elif is_variable: 

1424 # New variable: bind it 

1425 new_binding[expected] = actual 

1426 elif not self._term_matches(expected, actual, index): 

1427 return None 

1428 return new_binding 

1429 

1430 def _rebuild_streaming(self) -> None: 

1431 triples_checked = set() 

1432 all_entity_strs: set[str] = set() 

1433 use_fast_path = ( 

1434 len(self.triples) == 1 

1435 and self._is_isolated(self.triples[0]) 

1436 and not self.merge_aware 

1437 ) 

1438 for triple in self.triples: 

1439 if self._is_a_new_triple(triple, triples_checked): 

1440 present_entities = self._get_present_entities(triple) 

1441 prov_entities: set = set() 

1442 self._find_entity_uris_in_update_queries(triple, prov_entities) 

1443 all_entity_strs.update(present_entities) 

1444 all_entity_strs.update(prov_entities) 

1445 triples_checked.add(triple) 

1446 if not all_entity_strs: 

1447 self._streaming_results = {} 

1448 return 

1449 all_entity_strs = self._expand_entities_with_merges(all_entity_strs) 

1450 self.reconstructed_entities.update(all_entity_strs) 

1451 if use_fast_path: 

1452 fut_prov = _IO_EXECUTOR.submit( 

1453 _batch_query_provenance_snapshots, all_entity_strs, self.config 

1454 ) 

1455 fut_data = _IO_EXECUTOR.submit( 

1456 _batch_query_dataset_triples, 

1457 all_entity_strs, 

1458 self.config, 

1459 is_virtuoso=self.virtuoso_full_text_search, 

1460 triple=self.triples[0], 

1461 ) 

1462 prov_data = fut_prov.result() 

1463 dataset_data = fut_data.result() 

1464 triple = self.triples[0] 

1465 quad_filter = _quad_filter_for_pattern(triple) 

1466 entity_bindings: dict[str, dict[str, list[dict]]] = {} 

1467 for entity_str in all_entity_strs: 

1468 per_ts: dict[str, list[dict]] = {} 

1469 sorted_versions = sorted( 

1470 ( 

1471 (snapshot["time"], snapshot["updateQuery"]) 

1472 for snapshot in prov_data[entity_str] 

1473 ), 

1474 key=lambda version: _parse_datetime(version[0]), 

1475 reverse=True, 

1476 ) 

1477 for ts, quad_set in _iter_working_states( 

1478 sorted_versions, 

1479 dataset_data[entity_str], 

1480 quad_filter=quad_filter, 

1481 ): 

1482 per_ts[ts] = _match_single_pattern(triple, quad_set) 

1483 entity_bindings[entity_str] = per_ts 

1484 else: 

1485 entity_bindings = {} 

1486 for entity_str in all_entity_strs: 

1487 ae = AgnosticEntity( 

1488 entity_str, 

1489 config=self.config, 

1490 include_related_objects=False, 

1491 include_merged_entities=False, 

1492 include_reverse_relations=False, 

1493 ) 

1494 per_ts = {} 

1495 for ts, quad_set in ae.iter_versions(): 

1496 per_ts[ts] = self._extract_bindings(quad_set) 

1497 entity_bindings[entity_str] = per_ts 

1498 self._streaming_results = _merge_entity_bindings(entity_bindings) 

1499 

1500 def run_agnostic_query( 

1501 self, 

1502 ) -> tuple[dict[str, list[dict]], dict | None, dict | None]: 

1503 is_point_query = ( 

1504 self.on_time is not None 

1505 and self.on_time[0] is not None 

1506 and self.on_time[0] == self.on_time[1] 

1507 ) 

1508 if self._streaming_results: 

1509 agnostic_result = self._streaming_results 

1510 elif is_point_query: 

1511 point_graph: set[tuple[str, ...]] = set() 

1512 for graph in self.relevant_graphs.values(): 

1513 point_graph.update(graph) 

1514 point_interval = cast("tuple[str | None, str | None]", self.on_time) 

1515 point_time = cast("str", point_interval[0]) 

1516 agnostic_result = {point_time: self._extract_bindings(point_graph)} 

1517 elif self.on_time is None: 

1518 agnostic_result = self._streaming_results 

1519 else: 

1520 agnostic_result = {} 

1521 for timestamp, graph in self.relevant_graphs.items(): 

1522 normalized = str(convert_to_datetime(timestamp, stringify=True)) 

1523 agnostic_result[normalized] = self._extract_bindings(graph) 

1524 provenance, other_provenance = self._load_provenance( 

1525 self.reconstructed_entities 

1526 ) 

1527 return agnostic_result, provenance, other_provenance 

1528 

1529 

1530class DeltaQuery(AgnosticQuery): 

1531 """Delta structured query over a temporal interval. 

1532 

1533 The result contains the multiset difference between the solution mappings 

1534 at the interval endpoints and the difference for each consecutive state. 

1535 If ``on_time`` is ``None``, the interval spans the entire dataset history. 

1536 

1537 :param query: A SPARQL query string. 

1538 :type query: str 

1539 :param on_time: The time interval in the format (START, END). If one of the 

1540 two values is None, only the other is considered. If the interval is 

1541 None, the entire dataset history is considered. Dates must be in ISO 

1542 8601 format. 

1543 :type on_time: Tuple[Union[str, None]], optional 

1544 :param merge_aware: Follow entity histories connected by merges. 

1545 :type merge_aware: bool, optional 

1546 :param include_prov_metadata: Return snapshot metadata with the query results. 

1547 :type include_prov_metadata: bool, optional 

1548 :param config_path: The path to the configuration file. 

1549 :type config_path: str, optional 

1550 """ 

1551 

1552 def __init__( 

1553 self, 

1554 query: str, 

1555 on_time: tuple[str | None, str | None] | None = None, 

1556 *, 

1557 merge_aware: bool = False, 

1558 include_prov_metadata: bool = False, 

1559 config_path: str = CONFIG_PATH, 

1560 config_dict: dict | None = None, 

1561 ): 

1562 super().__init__( 

1563 query=query, 

1564 on_time=on_time, 

1565 merge_aware=merge_aware, 

1566 include_prov_metadata=include_prov_metadata, 

1567 config_path=config_path, 

1568 config_dict=config_dict, 

1569 ) 

1570 

1571 def _rebuild_relevant_graphs(self) -> None: 

1572 version_query = VersionQuery( 

1573 self.query, 

1574 on_time=self.on_time, 

1575 merge_aware=self.merge_aware, 

1576 include_prov_metadata=False, 

1577 config_dict=self.config, 

1578 ) 

1579 self._version_results, _, _ = version_query.run_agnostic_query() 

1580 self.reconstructed_entities = version_query.reconstructed_entities 

1581 self._merge_events = version_query._merge_events # noqa: SLF001 

1582 self.triples = version_query.triples 

1583 

1584 def _reported_merge_events(self) -> list[dict] | None: 

1585 if not self.merge_aware: 

1586 return None 

1587 return [ 

1588 { 

1589 "time": event["time"], 

1590 "snapshot": event["snapshot"], 

1591 "survivor": event["survivor"], 

1592 "absorbed": list(event["absorbed"]), 

1593 } 

1594 for event in self._merge_events.values() 

1595 ] 

1596 

1597 def run_agnostic_query(self) -> tuple[dict, dict | None, dict | None]: 

1598 result = _build_solution_delta(self._version_results, self.on_time) 

1599 result["merges"] = self._reported_merge_events() 

1600 provenance, other_provenance = self._load_provenance( 

1601 self.reconstructed_entities 

1602 ) 

1603 return result, provenance, other_provenance 

1604 

1605 

1606def get_insert_query(graph_iri: str, data: set[tuple[str, ...]]) -> tuple[str, int]: 

1607 if not data: 

1608 return "", 0 

1609 statements = "\n".join(f"{q[0]} {q[1]} {q[2]} ." for q in data) 

1610 return f"INSERT DATA {{ GRAPH <{graph_iri}> {{ {statements} }} }}", len(data)