Coverage for heritrace/utils/sparql_utils.py: 95%

543 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 08:34 +0000

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

2# 

3# SPDX-License-Identifier: ISC 

4 

5import atexit 

6import logging 

7import os 

8import re 

9import time 

10from collections import defaultdict, deque 

11from collections.abc import Generator 

12from concurrent.futures import ProcessPoolExecutor 

13from dataclasses import dataclass 

14 

15from flask import current_app 

16from rdflib import RDF, Dataset, Graph, Literal, URIRef 

17from rdflib.plugins.sparql.algebra import translateUpdate 

18from rdflib.plugins.sparql.parser import parseUpdate 

19from rdflib.term import Node 

20from rdflib.util import from_n3 

21from SPARQLWrapper import JSON 

22from SPARQLWrapper.SPARQLExceptions import SPARQLWrapperException 

23 

24from heritrace.editor import Editor 

25from heritrace.extensions import ( 

26 get_classes_with_multiple_shapes, 

27 get_custom_filter, 

28 get_dataset_is_quadstore, 

29 get_display_rules, 

30 get_provenance_sparql, 

31 get_shacl_graph, 

32 get_sparql, 

33) 

34from heritrace.sparql import get_sparql_bindings 

35from heritrace.utils.display_rules_utils import ( 

36 find_matching_rule, 

37 get_highest_priority_class, 

38 get_sortable_properties, 

39 is_entity_type_visible, 

40) 

41from heritrace.utils.shacl_utils import ( 

42 determine_shape_for_classes, 

43 determine_shape_for_entity_triples, 

44) 

45from heritrace.utils.virtuoso_utils import VIRTUOSO_EXCLUDED_GRAPHS, is_virtuoso 

46 

47_cache: dict[str, tuple[list[dict[str, str | int]], float] | None] = { 

48 "available_classes": None, 

49 "deleted_classes": None, 

50} 

51AVAILABLE_CLASSES_TTL_SECONDS = 60 

52 

53 

54def _parse_n3(value: str) -> Node: 

55 result = from_n3(value) 

56 if not isinstance(result, Node): 

57 msg = f"Cannot parse N3 value: {value}" 

58 raise TypeError(msg) 

59 return result 

60 

61 

62def n3_set_to_graph( 

63 n3_set: set[tuple[str, ...]], 

64 *, 

65 is_quadstore: bool, 

66) -> Graph | Dataset: 

67 if is_quadstore: 

68 g = Dataset(default_union=True) 

69 for tup in n3_set: 

70 quad = ( 

71 _parse_n3(tup[0]), 

72 _parse_n3(tup[1]), 

73 _parse_n3(tup[2]), 

74 _parse_n3(tup[3]), 

75 ) 

76 g.add(quad) # type: ignore[arg-type] 

77 else: 

78 g = Graph() 

79 for tup in n3_set: 

80 g.add((_parse_n3(tup[0]), _parse_n3(tup[1]), _parse_n3(tup[2]))) 

81 return g 

82 

83 

84def convert_to_rdflib_graphs(snapshots: dict, *, is_quadstore: bool) -> dict: 

85 converted = {} 

86 for entity_uri, timestamps in snapshots.items(): 

87 converted[entity_uri] = {} 

88 for ts, n3_set in timestamps.items(): 

89 converted[entity_uri][ts] = n3_set_to_graph( 

90 n3_set, is_quadstore=is_quadstore 

91 ) 

92 return converted 

93 

94 

95def get_triples_from_graph( 

96 graph_or_dataset: Graph | Dataset, 

97 pattern: tuple[URIRef | None, URIRef | None, Node | None], 

98) -> Generator[tuple[Node, Node, Node]]: 

99 """ 

100 Get triples from a Graph or Dataset, handling both cases correctly. 

101 

102 For Dataset (quadstore), converts quads to triples by extracting (s, p, o). 

103 For Graph (triplestore), uses triples() directly. 

104 

105 Args: 

106 graph_or_dataset: Graph or Dataset instance 

107 pattern: Triple pattern tuple (s, p, o) where each can be None 

108 

109 Returns: 

110 Generator of triples (s, p, o) 

111 """ 

112 if isinstance(graph_or_dataset, Dataset): 

113 # For Dataset, use quads() and extract only (s, p, o) 

114 for s, p, o, _g in graph_or_dataset.quads(pattern): 

115 yield (s, p, o) 

116 else: 

117 # For Graph, use triples() directly 

118 yield from graph_or_dataset.triples(pattern) 

119 

120 

121COUNT_LIMIT = int(os.getenv("COUNT_LIMIT", "10000")) 

122 

123 

124@dataclass(slots=True) 

125class _WorkerPool: 

126 executor: ProcessPoolExecutor | None = None 

127 

128 

129_worker_pool = _WorkerPool() 

130 

131 

132def configure_worker_pool(max_workers: int, gunicorn_workers: int) -> None: 

133 if max_workers < 1: 

134 msg = "MAX_WORKERS must be at least 1" 

135 raise ValueError(msg) 

136 if gunicorn_workers < 1: 

137 msg = "GUNICORN_WORKERS must be at least 1" 

138 raise ValueError(msg) 

139 

140 if _worker_pool.executor is not None: 

141 _worker_pool.executor.shutdown() 

142 

143 workers_per_server = max_workers // gunicorn_workers 

144 _worker_pool.executor = ( 

145 ProcessPoolExecutor(max_workers=workers_per_server) 

146 if workers_per_server > 0 

147 else None 

148 ) 

149 

150 

151def shutdown_worker_pool() -> None: 

152 if _worker_pool.executor is not None: 

153 _worker_pool.executor.shutdown() 

154 _worker_pool.executor = None 

155 

156 

157atexit.register(shutdown_worker_pool) 

158 

159 

160def _wrap_virtuoso_graph_pattern(pattern: str) -> str: 

161 """Wrap a SPARQL pattern with Virtuoso GRAPH clause if needed.""" 

162 if is_virtuoso(): 

163 return f""" 

164 GRAPH ?g {{ 

165 {pattern} 

166 }} 

167 FILTER(?g NOT IN (<{">, <".join(VIRTUOSO_EXCLUDED_GRAPHS)}>)) 

168 """ 

169 return pattern 

170 

171 

172def _build_count_query_with_limit(class_uri: str, limit: int) -> str: 

173 """Build a COUNT query with LIMIT for a specific class.""" 

174 

175 return f""" 

176 SELECT (COUNT(?subject) as ?count) 

177 WHERE {{ 

178 {{ 

179 SELECT DISTINCT ?subject 

180 WHERE {{ 

181 ?subject a <{class_uri}> . 

182 }} 

183 LIMIT {limit} 

184 }} 

185 }} 

186 """ 

187 

188 

189def _count_class_instances(class_uri: str, limit: int = COUNT_LIMIT) -> tuple: 

190 """ 

191 Count instances of a class up to a limit. 

192 

193 Returns: 

194 tuple: (display_count, numeric_count) where display_count may be "LIMIT+" 

195 """ 

196 sparql = get_sparql() 

197 query = _build_count_query_with_limit(class_uri, limit + 1) 

198 

199 sparql.setQuery(query) 

200 sparql.setReturnFormat(JSON) 

201 bindings = get_sparql_bindings(sparql.query().convert()) 

202 

203 count = int(bindings[0]["count"]["value"]) 

204 

205 if count > limit: 

206 return f"{limit}+", limit 

207 return str(count), count 

208 

209 

210def _get_entities_with_enhanced_shape_detection( 

211 class_uri: str, classes_with_multiple_shapes: set[str], limit: int = COUNT_LIMIT 

212) -> defaultdict[str, list[dict[str, str]]]: 

213 """ 

214 Get entities for a class using enhanced shape detection 

215 for classes with multiple shapes. 

216 Uses LIMIT to avoid loading all entities. 

217 """ 

218 # Early exit if no classes have multiple shapes 

219 if ( 

220 not classes_with_multiple_shapes 

221 or class_uri not in classes_with_multiple_shapes 

222 ): 

223 return defaultdict(list) 

224 

225 sparql = get_sparql() 

226 

227 subjects_query = f""" 

228 SELECT DISTINCT ?subject 

229 WHERE {{ 

230 ?subject a <{class_uri}> . 

231 }} 

232 LIMIT {limit} 

233 """ 

234 

235 sparql.setQuery(subjects_query) 

236 sparql.setReturnFormat(JSON) 

237 subjects_bindings = get_sparql_bindings(sparql.query().convert()) 

238 

239 subjects = [r["subject"]["value"] for r in subjects_bindings] 

240 

241 if not subjects: 

242 return defaultdict(list) 

243 

244 # Fetch triples only for these specific subjects 

245 subjects_filter = " ".join([f"(<{s}>)" for s in subjects]) 

246 pattern_with_filter = ( 

247 f"?subject a <{class_uri}> . ?subject ?p ?o" 

248 f" . VALUES (?subject) {{ {subjects_filter} }}" 

249 ) 

250 

251 triples_query = f""" 

252 SELECT ?subject ?p ?o 

253 WHERE {{ 

254 {pattern_with_filter} 

255 }} 

256 """ 

257 

258 sparql.setQuery(triples_query) 

259 sparql.setReturnFormat(JSON) 

260 triples_bindings = get_sparql_bindings(sparql.query().convert()) 

261 

262 entities_triples = defaultdict(list) 

263 for binding in triples_bindings: 

264 subject = binding["subject"]["value"] 

265 predicate = binding["p"]["value"] 

266 obj = binding["o"]["value"] 

267 entities_triples[subject].append((subject, predicate, obj)) 

268 

269 shape_to_entities = defaultdict(list) 

270 for subject_uri, triples in entities_triples.items(): 

271 shape_uri = determine_shape_for_entity_triples(triples) 

272 if shape_uri: 

273 entity_key = (class_uri, shape_uri) 

274 if is_entity_type_visible(entity_key): 

275 shape_to_entities[shape_uri].append( 

276 {"uri": subject_uri, "class": class_uri, "shape": shape_uri} 

277 ) 

278 

279 return shape_to_entities 

280 

281 

282def get_classes_from_shacl_or_display_rules() -> list[str]: 

283 """Extract classes from SHACL shapes or display_rules configuration.""" 

284 sh_target_class = URIRef("http://www.w3.org/ns/shacl#targetClass") 

285 classes = set() 

286 

287 shacl_graph = get_shacl_graph() 

288 if shacl_graph: 

289 for shape in shacl_graph.subjects(sh_target_class, None, unique=True): 

290 for target_class in shacl_graph.objects( 

291 shape, sh_target_class, unique=True 

292 ): 

293 classes.add(str(target_class)) 

294 

295 if not classes: 

296 display_rules = get_display_rules() 

297 if display_rules: 

298 for rule in display_rules: 

299 if "target" in rule and "class" in rule["target"]: 

300 classes.add(rule["target"]["class"]) 

301 

302 return list(classes) 

303 

304 

305def _get_classes_from_config() -> list[str]: 

306 classes_from_config = get_classes_from_shacl_or_display_rules() 

307 if classes_from_config: 

308 return classes_from_config 

309 

310 return _get_classes_from_sparql() 

311 

312 

313def _get_classes_from_sparql() -> list[str]: 

314 sparql = get_sparql() 

315 pattern = "?subject a ?class ." 

316 wrapped_pattern = _wrap_virtuoso_graph_pattern(pattern) 

317 

318 query = f""" 

319 SELECT DISTINCT ?class 

320 WHERE {{ 

321 {wrapped_pattern} 

322 }} 

323 """ 

324 

325 sparql.setQuery(query) 

326 sparql.setReturnFormat(JSON) 

327 class_bindings = get_sparql_bindings(sparql.query().convert()) 

328 return [r["class"]["value"] for r in class_bindings] 

329 

330 

331def get_available_classes() -> list[dict[str, str | int]]: 

332 cached = _cache["available_classes"] 

333 if cached is not None: 

334 available_classes, computed_at = cached 

335 if time.monotonic() - computed_at < AVAILABLE_CLASSES_TTL_SECONDS: 

336 return available_classes 

337 

338 custom_filter = get_custom_filter() 

339 class_uris = _get_classes_from_config() 

340 

341 classes_with_counts = [] 

342 for class_uri in class_uris: 

343 display_count, numeric_count = _count_class_instances(class_uri) 

344 classes_with_counts.append( 

345 { 

346 "uri": class_uri, 

347 "display_count": display_count, 

348 "numeric_count": numeric_count, 

349 } 

350 ) 

351 

352 classes_with_counts.sort(key=lambda x: x["numeric_count"], reverse=True) 

353 

354 available_classes = [] 

355 classes_with_multiple_shapes = get_classes_with_multiple_shapes() 

356 

357 for class_data in classes_with_counts: 

358 class_uri = class_data["uri"] 

359 

360 if classes_with_multiple_shapes and class_uri in classes_with_multiple_shapes: 

361 shape_to_entities = _get_entities_with_enhanced_shape_detection( 

362 class_uri, classes_with_multiple_shapes, limit=COUNT_LIMIT 

363 ) 

364 

365 for shape_uri, entities in shape_to_entities.items(): 

366 if entities: 

367 entity_key = (class_uri, shape_uri) 

368 available_classes.append( 

369 { 

370 "uri": class_uri, 

371 "label": custom_filter.human_readable_class(entity_key), 

372 "count": f"{len(entities)}+" 

373 if len(entities) >= COUNT_LIMIT 

374 else str(len(entities)), 

375 "count_numeric": len(entities), 

376 "shape": shape_uri, 

377 } 

378 ) 

379 else: 

380 shape_uri = determine_shape_for_classes([class_uri]) 

381 entity_key = (class_uri, shape_uri) 

382 

383 if is_entity_type_visible(entity_key): 

384 available_classes.append( 

385 { 

386 "uri": class_uri, 

387 "label": custom_filter.human_readable_class(entity_key), 

388 "count": class_data["display_count"], 

389 "count_numeric": class_data["numeric_count"], 

390 "shape": shape_uri, 

391 } 

392 ) 

393 

394 available_classes.sort(key=lambda x: x["label"].lower()) 

395 _cache["available_classes"] = (available_classes, time.monotonic()) 

396 return available_classes 

397 

398 

399def build_sort_clause( 

400 sort_property: str, entity_type: str, shape_uri: str | None = None 

401) -> str: 

402 """ 

403 Build a SPARQL sort clause based on the sortableBy configuration. 

404 

405 Args: 

406 sort_property: The property to sort by 

407 entity_type: The entity type URI 

408 shape_uri: Optional shape URI for more specific sorting rules 

409 

410 Returns: 

411 SPARQL sort clause or empty string 

412 """ 

413 if not sort_property or not entity_type: 

414 return "" 

415 

416 rule = find_matching_rule(entity_type, shape_uri) 

417 

418 if not rule or "sortableBy" not in rule: 

419 return "" 

420 

421 sort_config = next( 

422 (s for s in rule["sortableBy"] if s.get("property") == sort_property), None 

423 ) 

424 

425 if not sort_config: 

426 return "" 

427 

428 return f"OPTIONAL {{ ?subject <{sort_property}> ?sortValue }}" 

429 

430 

431@dataclass(frozen=True, slots=True) 

432class CatalogQuery: 

433 selected_class: str | None 

434 page: int 

435 per_page: int 

436 sort_property: str | None = None 

437 sort_direction: str = "ASC" 

438 selected_shape: str | None = None 

439 

440 

441def _fetch_entity_labels( 

442 subject_uris: list[str], entity_key: tuple[str | None, str | None] 

443) -> list[str]: 

444 if not subject_uris: 

445 return [] 

446 

447 if _worker_pool.executor is None: 

448 return [_fetch_entity_label(uri, entity_key) for uri in subject_uris] 

449 return list( 

450 _worker_pool.executor.map( 

451 _fetch_entity_label, subject_uris, [entity_key] * len(subject_uris) 

452 ) 

453 ) 

454 

455 

456def _fetch_entity_label(uri: str, entity_key: tuple[str | None, str | None]) -> str: 

457 return get_custom_filter().human_readable_entity(uri, entity_key, None) 

458 

459 

460def _get_entities_with_shape_filtering( 

461 query: CatalogQuery, 

462) -> tuple[list[dict[str, str]], int]: 

463 sparql = get_sparql() 

464 selected_class = query.selected_class 

465 selected_shape = query.selected_shape 

466 offset = (query.page - 1) * query.per_page 

467 fetch_limit = query.per_page * 5 

468 

469 subjects_query = f""" 

470 SELECT DISTINCT ?subject 

471 WHERE {{ 

472 ?subject a <{selected_class}> . 

473 }} 

474 LIMIT {fetch_limit} 

475 OFFSET {offset} 

476 """ 

477 

478 sparql.setQuery(subjects_query) 

479 sparql.setReturnFormat(JSON) 

480 subjects_bindings = get_sparql_bindings(sparql.query().convert()) 

481 

482 subjects = [r["subject"]["value"] for r in subjects_bindings] 

483 

484 if not subjects: 

485 return [], 0 

486 

487 subjects_filter = " ".join([f"(<{s}>)" for s in subjects]) 

488 

489 triples_query = f""" 

490 SELECT ?subject ?p ?o 

491 WHERE {{ 

492 ?subject a <{selected_class}> . ?subject ?p ?o . VALUES (?subject) {{ 

493 {subjects_filter} }} 

494 }} 

495 """ 

496 

497 sparql.setQuery(triples_query) 

498 sparql.setReturnFormat(JSON) 

499 triples_bindings = get_sparql_bindings(sparql.query().convert()) 

500 

501 entities_triples = defaultdict(list) 

502 for binding in triples_bindings: 

503 subject = binding["subject"]["value"] 

504 predicate = binding["p"]["value"] 

505 obj = binding["o"]["value"] 

506 entities_triples[subject].append((subject, predicate, obj)) 

507 

508 matching_uris = [ 

509 subject_uri 

510 for subject_uri, triples in entities_triples.items() 

511 if determine_shape_for_entity_triples(list(triples)) == selected_shape 

512 ] 

513 labels = _fetch_entity_labels(matching_uris, (selected_class, selected_shape)) 

514 filtered_entities = [ 

515 {"uri": uri, "label": label} 

516 for uri, label in zip(matching_uris, labels, strict=True) 

517 ] 

518 

519 if query.sort_property and query.sort_direction: 

520 reverse_sort = query.sort_direction.upper() == "DESC" 

521 filtered_entities.sort(key=lambda x: x["label"].lower(), reverse=reverse_sort) 

522 

523 total_count = len(filtered_entities) 

524 return filtered_entities[: query.per_page], total_count 

525 

526 

527def get_entities_for_class( 

528 query: CatalogQuery, 

529 available_classes: list[dict[str, str | int]], 

530) -> tuple[list[dict[str, str]], int]: 

531 if query.selected_class is None: 

532 msg = "selected_class must not be None" 

533 raise ValueError(msg) 

534 sparql = get_sparql() 

535 classes_with_multiple_shapes = get_classes_with_multiple_shapes() 

536 

537 selected_class: str = query.selected_class 

538 selected_shape = query.selected_shape 

539 page = query.page 

540 per_page = query.per_page 

541 sort_property = query.sort_property 

542 sort_direction = query.sort_direction 

543 

544 use_shape_filtering = ( 

545 selected_shape and selected_class in classes_with_multiple_shapes 

546 ) 

547 

548 if use_shape_filtering: 

549 return _get_entities_with_shape_filtering(query) 

550 

551 offset = (page - 1) * per_page 

552 sort_clause = "" 

553 order_clause = "" 

554 

555 if sort_property: 

556 sort_clause = build_sort_clause(sort_property, selected_class, selected_shape) 

557 if sort_clause: 

558 order_clause = f"ORDER BY {sort_direction}(?sortValue)" 

559 

560 entities_query = f""" 

561 SELECT ?subject {"?sortValue" if sort_property else ""} 

562 WHERE {{ 

563 ?subject a <{selected_class}> . {sort_clause} 

564 }} 

565 {order_clause} 

566 LIMIT {per_page} 

567 OFFSET {offset} 

568 """ 

569 

570 class_info = next( 

571 ( 

572 c 

573 for c in available_classes 

574 if c["uri"] == selected_class and c.get("shape") == selected_shape 

575 ), 

576 None, 

577 ) 

578 total_count = int(class_info["count_numeric"]) if class_info else 0 

579 

580 sparql.setQuery(entities_query) 

581 sparql.setReturnFormat(JSON) 

582 entities_bindings = get_sparql_bindings(sparql.query().convert()) 

583 

584 shape = selected_shape or determine_shape_for_classes([selected_class]) 

585 subject_uris = [result["subject"]["value"] for result in entities_bindings] 

586 labels = _fetch_entity_labels(subject_uris, (selected_class, shape)) 

587 entities = [ 

588 {"uri": uri, "label": label} 

589 for uri, label in zip(subject_uris, labels, strict=True) 

590 ] 

591 

592 return entities, total_count 

593 

594 

595def get_catalog_data( 

596 query: CatalogQuery, 

597 available_classes: list[dict[str, str | int]], 

598) -> dict: 

599 entities = [] 

600 total_count = 0 

601 sortable_properties = [] 

602 sort_property = query.sort_property 

603 

604 if query.selected_class: 

605 sortable_properties = get_sortable_properties( 

606 (query.selected_class, query.selected_shape) 

607 ) 

608 

609 if not sort_property and sortable_properties: 

610 sort_property = sortable_properties[0]["property"] 

611 

612 inner_query = CatalogQuery( 

613 selected_class=query.selected_class, 

614 page=query.page, 

615 per_page=query.per_page, 

616 sort_property=sort_property, 

617 sort_direction=query.sort_direction, 

618 selected_shape=query.selected_shape, 

619 ) 

620 entities, total_count = get_entities_for_class(inner_query, available_classes) 

621 

622 return { 

623 "entities": entities, 

624 "total_pages": ( 

625 (total_count + query.per_page - 1) // query.per_page 

626 if total_count > 0 

627 else 0 

628 ), 

629 "current_page": query.page, 

630 "per_page": query.per_page, 

631 "total_count": total_count, 

632 "sort_property": sort_property, 

633 "sort_direction": query.sort_direction, 

634 "sortable_properties": sortable_properties, 

635 "selected_class": query.selected_class, 

636 "selected_shape": query.selected_shape, 

637 } 

638 

639 

640def warm_catalogue( 

641 available_classes: list[dict[str, str | int]], per_page: int 

642) -> None: 

643 total_started_at = time.monotonic() 

644 

645 for class_info in available_classes: 

646 if int(class_info["count_numeric"]) == 0: 

647 continue 

648 

649 class_uri = str(class_info["uri"]) 

650 shape_value = class_info["shape"] 

651 shape_uri = str(shape_value) if shape_value is not None else None 

652 category_started_at = time.monotonic() 

653 

654 get_catalog_data( 

655 CatalogQuery( 

656 selected_class=class_uri, 

657 selected_shape=shape_uri, 

658 page=1, 

659 per_page=per_page, 

660 ), 

661 available_classes, 

662 ) 

663 

664 current_app.logger.info( 

665 "[STARTUP] Warmed catalogue category class=%s shape=%s in %.3f seconds", 

666 class_uri, 

667 shape_uri, 

668 time.monotonic() - category_started_at, 

669 ) 

670 

671 current_app.logger.info( 

672 "[STARTUP] Catalogue warm-up completed in %.3f seconds", 

673 time.monotonic() - total_started_at, 

674 ) 

675 

676 

677def _binding_to_node(binding: dict[str, str]) -> Literal | URIRef: 

678 if binding["type"] not in {"literal", "typed-literal"}: 

679 return URIRef(binding["value"]) 

680 if "datatype" in binding: 

681 return Literal(binding["value"], datatype=URIRef(binding["datatype"])) 

682 # Omit explicit datatype to match Reader's import behavior 

683 return Literal(binding["value"]) 

684 

685 

686def fetch_data_graph_for_subject(subject: URIRef) -> Graph | Dataset: 

687 g = Dataset() if get_dataset_is_quadstore() else Graph() 

688 sparql = get_sparql() 

689 

690 if is_virtuoso(): 

691 # For virtuoso we need to explicitly query the graph 

692 query = f""" 

693 SELECT ?predicate ?object ?g WHERE {{ 

694 GRAPH ?g {{ 

695 <{subject}> ?predicate ?object. 

696 }} 

697 FILTER(?g NOT IN (<{">, <".join(VIRTUOSO_EXCLUDED_GRAPHS)}>)) 

698 }} 

699 """ 

700 elif get_dataset_is_quadstore(): 

701 # For non-virtuoso quadstore, we need to query all graphs 

702 query = f""" 

703 SELECT ?predicate ?object ?g WHERE {{ 

704 GRAPH ?g {{ 

705 <{subject}> ?predicate ?object. 

706 }} 

707 }} 

708 """ 

709 else: 

710 # For regular triplestore 

711 query = f""" 

712 SELECT ?predicate ?object WHERE {{ 

713 <{subject}> ?predicate ?object. 

714 }} 

715 """ 

716 

717 sparql.setQuery(query) 

718 sparql.setReturnFormat(JSON) 

719 bindings = get_sparql_bindings(sparql.query().convert()) 

720 

721 for result in bindings: 

722 value = _binding_to_node(result["object"]) 

723 

724 # Add triple/quad based on store type 

725 if get_dataset_is_quadstore(): 

726 graph_uri = URIRef(result["g"]["value"]) 

727 g.add( 

728 ( # type: ignore[arg-type] 

729 subject, 

730 URIRef(result["predicate"]["value"]), 

731 value, 

732 graph_uri, 

733 ) 

734 ) 

735 else: 

736 g.add((subject, URIRef(result["predicate"]["value"]), value)) 

737 

738 return g 

739 

740 

741def parse_sparql_update(query: str) -> dict[str, list[tuple[Node, Node, Node]]]: 

742 parsed = parseUpdate(query) 

743 translated = translateUpdate(parsed).algebra 

744 modifications = {} 

745 

746 def extract_quads( 

747 quads: defaultdict[Node, list[tuple[Node, Node, Node]]], 

748 ) -> list[tuple[Node, Node, Node]]: 

749 return [ 

750 (triple[0], triple[1], triple[2]) 

751 for triples in quads.values() 

752 for triple in triples 

753 ] 

754 

755 for operation in translated: 

756 if operation.name == "DeleteData": 

757 if hasattr(operation, "quads") and operation.quads: 

758 deletions = extract_quads(operation.quads) 

759 else: 

760 deletions = operation.triples 

761 if deletions: 

762 modifications.setdefault("Deletions", []).extend(deletions) 

763 elif operation.name == "InsertData": 

764 if hasattr(operation, "quads") and operation.quads: 

765 additions = extract_quads(operation.quads) 

766 else: 

767 additions = operation.triples 

768 if additions: 

769 modifications.setdefault("Additions", []).extend(additions) 

770 

771 return modifications 

772 

773 

774def fetch_current_state_with_related_entities( 

775 provenance: dict, 

776) -> Graph | Dataset: 

777 """ 

778 Fetch the current state of an entity and all its related entities known from 

779 provenance. 

780 

781 Args: 

782 provenance (dict): Dictionary containing provenance metadata for main entity and 

783 related entities 

784 

785 Returns: 

786 Dataset: A graph containing the current state of all entities 

787 """ 

788 combined_graph = Dataset() if get_dataset_is_quadstore() else Graph() 

789 

790 # Fetch state for all entities mentioned in provenance 

791 for entity_uri in provenance: 

792 current_graph = fetch_data_graph_for_subject(URIRef(entity_uri)) 

793 

794 if get_dataset_is_quadstore(): 

795 for quad in current_graph.quads(): # type: ignore[union-attr] 

796 combined_graph.add(quad) # type: ignore[call-overload] 

797 else: 

798 for triple in current_graph: 

799 combined_graph.add(triple) # type: ignore[call-overload] 

800 

801 return combined_graph 

802 

803 

804@dataclass(frozen=True, slots=True) 

805class DeletedEntitiesQuery: 

806 page: int = 1 

807 per_page: int = 50 

808 sort_direction: str = "DESC" 

809 selected_class: str | None = None 

810 selected_shape: str | None = None 

811 

812 

813PROV = "http://www.w3.org/ns/prov#" 

814OCO_HAS_UPDATE_QUERY = "https://w3id.org/oc/ontology/hasUpdateQuery" 

815RDF_TYPE_STATEMENT = "<http://www.w3.org/1999/02/22-rdf-syntax-ns#type>" 

816DELETION_TIME_PROPERTY = { 

817 "property": "deletionTime", 

818 "displayName": "Deletion Time", 

819 "sortType": "date", 

820} 

821RELATED_STATE_DEPTH = 5 

822 

823 

824def _deletion_snapshots_pattern(selected_class: str | None) -> str: 

825 """ 

826 Match the snapshots that deleted their entity. 

827 

828 A deletion snapshot is generated and invalidated at the same instant, and no 

829 later snapshot derives from it. Restoring an entity adds a snapshot derived 

830 from the deletion one, which is what excludes restored entities here. 

831 """ 

832 class_pattern = "" 

833 if selected_class: 

834 class_pattern = f""" 

835 ?snapshot <{OCO_HAS_UPDATE_QUERY}> ?candidateUpdateQuery . 

836 FILTER(CONTAINS( 

837 ?candidateUpdateQuery, 

838 "{RDF_TYPE_STATEMENT} <{selected_class}>" 

839 ))""" 

840 return f""" 

841 ?snapshot <{PROV}invalidatedAtTime> ?invalidationTime ; 

842 <{PROV}generatedAtTime> ?deletionTime . 

843 FILTER(?deletionTime = ?invalidationTime) 

844 FILTER NOT EXISTS {{ 

845 ?laterSnapshot <{PROV}wasDerivedFrom> ?snapshot . 

846 }}{class_pattern}""" 

847 

848 

849def _deleted_entity_types(entity_uri: str, update_query: str) -> list[str]: 

850 """Read the entity types out of the DELETE DATA recorded by the snapshot.""" 

851 statement = re.escape(f"<{entity_uri}> {RDF_TYPE_STATEMENT} ") 

852 return re.findall(f"{statement}<([^>]+)>", update_query) 

853 

854 

855def _count_deleted_class_instances( 

856 class_uri: str, limit: int = COUNT_LIMIT 

857) -> tuple[str, int]: 

858 """ 

859 Count deleted entities of a class up to a limit. 

860 

861 Returns: 

862 tuple: (display_count, numeric_count) where display_count may be "LIMIT+" 

863 """ 

864 provenance_sparql = get_provenance_sparql() 

865 provenance_sparql.setQuery(f""" 

866 SELECT (COUNT(?snapshot) AS ?count) 

867 WHERE {{ 

868 {{ 

869 SELECT ?snapshot 

870 WHERE {{{_deletion_snapshots_pattern(class_uri)} 

871 }} 

872 LIMIT {limit + 1} 

873 }} 

874 }} 

875 """) 

876 provenance_sparql.setReturnFormat(JSON) 

877 bindings = get_sparql_bindings(provenance_sparql.query().convert()) 

878 

879 count = int(bindings[0]["count"]["value"]) 

880 

881 if count > limit: 

882 return f"{limit}+", limit 

883 return str(count), count 

884 

885 

886def get_deleted_available_classes() -> list[dict[str, str | int]]: 

887 """Count the deleted entities of every class the configuration displays.""" 

888 cached = _cache["deleted_classes"] 

889 if cached is not None: 

890 deleted_classes, computed_at = cached 

891 if time.monotonic() - computed_at < AVAILABLE_CLASSES_TTL_SECONDS: 

892 return deleted_classes 

893 

894 custom_filter = get_custom_filter() 

895 

896 deleted_classes = [] 

897 for class_uri in _get_classes_from_config(): 

898 shape_uri = determine_shape_for_classes([class_uri]) 

899 entity_key = (class_uri, shape_uri) 

900 if not is_entity_type_visible(entity_key): 

901 continue 

902 

903 display_count, numeric_count = _count_deleted_class_instances(class_uri) 

904 if numeric_count == 0: 

905 continue 

906 

907 deleted_classes.append( 

908 { 

909 "uri": class_uri, 

910 "label": custom_filter.human_readable_class(entity_key), 

911 "count": display_count, 

912 "count_numeric": numeric_count, 

913 "shape": shape_uri, 

914 } 

915 ) 

916 

917 deleted_classes.sort(key=lambda x: str(x["label"]).lower()) 

918 _cache["deleted_classes"] = (deleted_classes, time.monotonic()) 

919 return deleted_classes 

920 

921 

922def warm_time_vault() -> None: 

923 started_at = time.monotonic() 

924 deleted_classes = get_deleted_available_classes() 

925 current_app.logger.info( 

926 "[STARTUP] Time Vault warm-up completed for %d categories in %.3f seconds", 

927 len(deleted_classes), 

928 time.monotonic() - started_at, 

929 ) 

930 

931 

932def _referenced_uris(graph: Graph) -> set[str]: 

933 return { 

934 str(obj) 

935 for _, predicate, obj in graph 

936 if isinstance(obj, URIRef) and predicate != RDF.type 

937 } 

938 

939 

940def _expand_with_current_state(graph: Graph) -> None: 

941 """ 

942 Add the entities the deleted ones point at, so that display rules reaching 

943 into neighbours can still resolve a label. The neighbours are read from the 

944 dataset at their current state: only the deleted entities themselves are 

945 gone from it. 

946 """ 

947 sparql = get_sparql() 

948 known = {str(subject) for subject in graph.subjects()} 

949 frontier = _referenced_uris(graph) - known 

950 

951 for _ in range(RELATED_STATE_DEPTH): 

952 if not frontier: 

953 return 

954 

955 values = " ".join(f"<{uri}>" for uri in frontier) 

956 pattern = f"VALUES ?subject {{ {values} }} ?subject ?predicate ?object ." 

957 sparql.setQuery(f""" 

958 SELECT ?subject ?predicate ?object 

959 WHERE {{ 

960 {_wrap_virtuoso_graph_pattern(pattern)} 

961 }} 

962 """) 

963 sparql.setReturnFormat(JSON) 

964 bindings = get_sparql_bindings(sparql.query().convert()) 

965 

966 for binding in bindings: 

967 graph.add( 

968 ( 

969 URIRef(binding["subject"]["value"]), 

970 URIRef(binding["predicate"]["value"]), 

971 _binding_to_node(binding["object"]), 

972 ) 

973 ) 

974 

975 known |= frontier 

976 frontier = _referenced_uris(graph) - known 

977 

978 

979def process_deleted_entities(bindings: list[dict]) -> list[dict[str, str]]: 

980 """ 

981 Build the listing entries for one page of deleted entities. 

982 

983 The state each entity had when it was deleted is already recorded in the 

984 snapshot as a DELETE DATA update query, so nothing has to be replayed from 

985 the provenance history to type and label it. 

986 """ 

987 custom_filter = get_custom_filter() 

988 state = Graph() 

989 typed_bindings = [] 

990 

991 for binding in bindings: 

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

993 update_query = binding["updateQuery"]["value"] 

994 entity_types = _deleted_entity_types(entity_uri, update_query) 

995 highest_priority_type = get_highest_priority_class(entity_types) 

996 if not highest_priority_type: 

997 continue 

998 for triple in parse_sparql_update(update_query)["Deletions"]: 

999 state.add(triple) 

1000 typed_bindings.append((binding, entity_uri, highest_priority_type)) 

1001 

1002 _expand_with_current_state(state) 

1003 

1004 entities = [] 

1005 for binding, entity_uri, highest_priority_type in typed_bindings: 

1006 entity_key = ( 

1007 highest_priority_type, 

1008 determine_shape_for_classes([highest_priority_type]), 

1009 ) 

1010 entities.append( 

1011 { 

1012 "uri": entity_uri, 

1013 "deletionTime": binding["deletionTime"]["value"], 

1014 "deletedBy": custom_filter.format_agent_reference( 

1015 binding["agent"]["value"] if "agent" in binding else "" 

1016 ), 

1017 "lastValidSnapshotTime": binding["lastValidSnapshotTime"]["value"], 

1018 "type": custom_filter.human_readable_predicate( 

1019 highest_priority_type, entity_key 

1020 ), 

1021 "label": custom_filter.human_readable_entity( 

1022 entity_uri, entity_key, state 

1023 ), 

1024 } 

1025 ) 

1026 

1027 return entities 

1028 

1029 

1030def get_deleted_entities_with_filtering( 

1031 query: DeletedEntitiesQuery, 

1032) -> tuple[ 

1033 list[dict[str, str]], 

1034 list[dict[str, str | int]], 

1035 str | None, 

1036 str | None, 

1037 list[dict[str, str]], 

1038 int, 

1039]: 

1040 sortable_properties = [DELETION_TIME_PROPERTY] 

1041 available_classes = get_deleted_available_classes() 

1042 if not available_classes: 

1043 return [], [], None, None, sortable_properties, 0 

1044 

1045 requested_shape = query.selected_shape or None 

1046 selected = next( 

1047 ( 

1048 class_info 

1049 for class_info in available_classes 

1050 if class_info["uri"] == query.selected_class 

1051 and (requested_shape is None or class_info["shape"] == requested_shape) 

1052 ), 

1053 available_classes[0], 

1054 ) 

1055 selected_class = str(selected["uri"]) 

1056 shape = selected["shape"] 

1057 selected_shape = str(shape) if shape is not None else None 

1058 total_count = int(selected["count_numeric"]) 

1059 

1060 direction = "ASC" if query.sort_direction.upper() == "ASC" else "DESC" 

1061 offset = (query.page - 1) * query.per_page 

1062 

1063 provenance_sparql = get_provenance_sparql() 

1064 provenance_sparql.setQuery(f""" 

1065 SELECT ?entity ?deletionTime ?agent ?lastValidSnapshotTime ?updateQuery 

1066 WHERE {{ 

1067 {{ 

1068 SELECT ?snapshot ?deletionTime 

1069 WHERE {{{_deletion_snapshots_pattern(selected_class)} 

1070 }} 

1071 ORDER BY {direction}(?deletionTime) 

1072 LIMIT {query.per_page} 

1073 OFFSET {offset} 

1074 }} 

1075 ?snapshot <{PROV}specializationOf> ?entity ; 

1076 <{PROV}wasDerivedFrom> ?lastValidSnapshot ; 

1077 <{OCO_HAS_UPDATE_QUERY}> ?updateQuery . 

1078 ?lastValidSnapshot <{PROV}generatedAtTime> ?lastValidSnapshotTime . 

1079 OPTIONAL {{ ?snapshot <{PROV}wasAttributedTo> ?agent . }} 

1080 }} 

1081 ORDER BY {direction}(?deletionTime) 

1082 """) 

1083 provenance_sparql.setReturnFormat(JSON) 

1084 bindings = get_sparql_bindings(provenance_sparql.query().convert()) 

1085 

1086 return ( 

1087 process_deleted_entities(bindings), 

1088 available_classes, 

1089 selected_class, 

1090 selected_shape, 

1091 sortable_properties, 

1092 total_count, 

1093 ) 

1094 

1095 

1096def find_orphaned_entities( 

1097 subject: URIRef, 

1098 entity_type: str, 

1099 predicate: URIRef | None = None, 

1100 object_value: str | None = None, 

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

1102 sparql = get_sparql() 

1103 display_rules = get_display_rules() 

1104 

1105 intermediate_classes = set() 

1106 

1107 for rule in display_rules: 

1108 if ( 

1109 "target" in rule 

1110 and "class" in rule["target"] 

1111 and rule["target"]["class"] == entity_type 

1112 ): 

1113 for prop in rule.get("displayProperties", []): 

1114 if "intermediateRelation" in prop: 

1115 intermediate_classes.add(prop["intermediateRelation"]["class"]) 

1116 

1117 orphan_query = f""" 

1118 SELECT DISTINCT ?entity ?type 

1119 WHERE {{ 

1120 {f"<{subject}> <{predicate}> ?entity ." if predicate and object_value else ""} 

1121 {f"FILTER(?entity = <{object_value}>)" if predicate and object_value else ""} 

1122 

1123 # If no specific predicate, get all connected entities 

1124 {f"<{subject}> ?p ?entity ." if not predicate else ""} 

1125 

1126 FILTER(isIRI(?entity)) 

1127 ?entity a ?type . 

1128 

1129 # No incoming references from other entities 

1130 FILTER NOT EXISTS {{ 

1131 ?other ?anyPredicate ?entity . 

1132 FILTER(?other != <{subject}>) 

1133 }} 

1134 

1135 # No outgoing references to active entities 

1136 FILTER NOT EXISTS {{ 

1137 ?entity ?outgoingPredicate ?connectedEntity . 

1138 ?connectedEntity ?furtherPredicate ?furtherObject . 

1139 {f"FILTER(?connectedEntity != <{subject}>)" if not predicate else ""} 

1140 }} 

1141 

1142 # Exclude intermediate relation entities 

1143 FILTER(?type NOT IN (<{">, <".join(intermediate_classes)}>)) 

1144 }} 

1145 """ 

1146 

1147 # Query to find orphaned intermediate relations 

1148 if predicate and object_value: 

1149 intermediate_query = f""" 

1150 SELECT DISTINCT ?entity ?type 

1151 WHERE {{ 

1152 <{object_value}> a ?type . 

1153 FILTER(?type IN (<{">, <".join(intermediate_classes)}>)) 

1154 BIND(<{object_value}> AS ?entity) 

1155 }} 

1156 """ 

1157 else: 

1158 # Se stiamo cancellando l'intera entità, trova tutte le entità intermedie 

1159 # collegate 

1160 intermediate_query = f""" 

1161 SELECT DISTINCT ?entity ?type 

1162 WHERE {{ 

1163 # Find intermediate relations connected to the entity being deleted 

1164 {{ 

1165 <{subject}> ?p ?entity . 

1166 ?entity a ?type . 

1167 FILTER(?type IN (<{">, <".join(intermediate_classes)}>)) 

1168 }} UNION {{ 

1169 ?entity ?p <{subject}> . 

1170 ?entity a ?type . 

1171 FILTER(?type IN (<{">, <".join(intermediate_classes)}>)) 

1172 }} 

1173 }} 

1174 """ 

1175 

1176 orphaned = [] 

1177 intermediate_orphans = [] 

1178 

1179 # Execute queries and process results 

1180 for query, result_list in [ 

1181 (orphan_query, orphaned), 

1182 (intermediate_query, intermediate_orphans), 

1183 ]: 

1184 sparql.setQuery(query) 

1185 sparql.setReturnFormat(JSON) 

1186 query_bindings = get_sparql_bindings(sparql.query().convert()) 

1187 

1188 for result in query_bindings: 

1189 result_list.append( 

1190 {"uri": result["entity"]["value"], "type": result["type"]["value"]} 

1191 ) 

1192 

1193 return orphaned, intermediate_orphans 

1194 

1195 

1196def import_entity_graph( 

1197 editor: Editor, 

1198 subject: URIRef, 

1199 max_depth: int = 5, 

1200 *, 

1201 include_referencing_entities: bool = False, 

1202) -> Editor: 

1203 imported_subjects: set[str] = set() 

1204 subject_str = str(subject) 

1205 

1206 if include_referencing_entities: 

1207 sparql = get_sparql() 

1208 

1209 if editor.dataset_is_quadstore: 

1210 query = f""" 

1211 SELECT DISTINCT ?s 

1212 WHERE {{ 

1213 GRAPH ?g {{ 

1214 ?s ?p <{subject}> . 

1215 }} 

1216 FILTER(?p != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>) 

1217 }} 

1218 """ 

1219 else: 

1220 query = f""" 

1221 SELECT DISTINCT ?s 

1222 WHERE {{ 

1223 ?s ?p <{subject}> . 

1224 FILTER(?p != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>) 

1225 }} 

1226 """ 

1227 

1228 sparql.setQuery(query) 

1229 sparql.setReturnFormat(JSON) 

1230 ref_bindings = get_sparql_bindings(sparql.query().convert()) 

1231 

1232 for result in ref_bindings: 

1233 referencing_subject = result["s"]["value"] 

1234 if ( 

1235 referencing_subject != subject_str 

1236 and referencing_subject not in imported_subjects 

1237 ): 

1238 imported_subjects.add(referencing_subject) 

1239 editor.import_entity(URIRef(referencing_subject)) 

1240 

1241 # Breadth-first traversal so each entity is visited at its minimal distance 

1242 # from the subject: a depth-first walk would consume one level per hop along 

1243 # ordering chains (e.g. oco:hasNext) and silently skip entities pushed beyond 

1244 # max_depth, leaving them without provenance snapshots. 

1245 queue: deque[tuple[str, int]] = deque([(subject_str, 1)]) 

1246 while queue: 

1247 current_subject, current_depth = queue.popleft() 

1248 if current_depth > max_depth or current_subject in imported_subjects: 

1249 continue 

1250 

1251 imported_subjects.add(current_subject) 

1252 editor.import_entity(URIRef(current_subject)) 

1253 

1254 query = f""" 

1255 SELECT ?p ?o 

1256 WHERE {{ 

1257 <{current_subject}> ?p ?o . 

1258 FILTER(isIRI(?o)) 

1259 FILTER(?p != <http://www.w3.org/1999/02/22-rdf-syntax-ns#type>) 

1260 }} 

1261 """ 

1262 

1263 sparql = get_sparql() 

1264 sparql.setQuery(query) 

1265 sparql.setReturnFormat(JSON) 

1266 inner_bindings = get_sparql_bindings(sparql.query().convert()) 

1267 

1268 for result in inner_bindings: 

1269 queue.append((result["o"]["value"], current_depth + 1)) 

1270 

1271 return editor 

1272 

1273 

1274def get_entity_types(subject_uri: str) -> list[str]: 

1275 sparql = get_sparql() 

1276 

1277 query = f""" 

1278 SELECT ?type WHERE {{ 

1279 <{subject_uri}> a ?type . 

1280 }} 

1281 """ 

1282 

1283 sparql.setQuery(query) 

1284 sparql.setReturnFormat(JSON) 

1285 bindings = get_sparql_bindings(sparql.query().convert()) 

1286 

1287 return [result["type"]["value"] for result in bindings] 

1288 

1289 

1290def collect_referenced_entities( 

1291 data: dict[str, str | dict | list] | list | str, 

1292 existing_entities: set[str] | None = None, 

1293) -> set[str]: 

1294 """ 

1295 Recursively collect all URIs of existing entities referenced in the structured data. 

1296 

1297 This function traverses the structured data to find explicit references to existing 

1298 entities 

1299 that need to be imported into the editor before calling preexisting_finished(). 

1300 

1301 Args: 

1302 data: The structured data (can be dict, list, or string) 

1303 existing_entities: Set to collect URIs (created if None) 

1304 

1305 Returns: 

1306 Set of URIs (strings) of existing entities that should be imported 

1307 """ 

1308 

1309 if existing_entities is None: 

1310 existing_entities = set() 

1311 

1312 if isinstance(data, dict): 

1313 if data.get("is_existing_entity") is True and "entity_uri" in data: 

1314 existing_entities.add(str(data["entity_uri"])) 

1315 

1316 # If it's an entity with entity_type, it's a new entity being created 

1317 elif "entity_type" in data: 

1318 properties = data.get("properties", {}) 

1319 if isinstance(properties, dict): 

1320 for prop_values in properties.values(): 

1321 collect_referenced_entities(prop_values, existing_entities) 

1322 else: 

1323 for value in data.values(): 

1324 collect_referenced_entities(value, existing_entities) 

1325 

1326 elif isinstance(data, list): 

1327 for item in data: 

1328 collect_referenced_entities(item, existing_entities) 

1329 

1330 return existing_entities 

1331 

1332 

1333def import_referenced_entities( 

1334 editor: Editor, 

1335 structured_data: dict[str, str | dict | list] | list | str, 

1336) -> None: 

1337 referenced_entities = collect_referenced_entities(structured_data) 

1338 for entity_uri in referenced_entities: 

1339 try: 

1340 editor.import_entity(URIRef(entity_uri)) 

1341 except (SPARQLWrapperException, OSError, ValueError): # noqa: PERF203 

1342 logging.getLogger(__name__).debug( 

1343 "Failed to import referenced entity %s", entity_uri 

1344 ) 

1345 continue