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
« 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
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
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
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
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
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
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
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
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.
102 For Dataset (quadstore), converts quads to triples by extracting (s, p, o).
103 For Graph (triplestore), uses triples() directly.
105 Args:
106 graph_or_dataset: Graph or Dataset instance
107 pattern: Triple pattern tuple (s, p, o) where each can be None
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)
121COUNT_LIMIT = int(os.getenv("COUNT_LIMIT", "10000"))
124@dataclass(slots=True)
125class _WorkerPool:
126 executor: ProcessPoolExecutor | None = None
129_worker_pool = _WorkerPool()
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)
140 if _worker_pool.executor is not None:
141 _worker_pool.executor.shutdown()
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 )
151def shutdown_worker_pool() -> None:
152 if _worker_pool.executor is not None:
153 _worker_pool.executor.shutdown()
154 _worker_pool.executor = None
157atexit.register(shutdown_worker_pool)
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
172def _build_count_query_with_limit(class_uri: str, limit: int) -> str:
173 """Build a COUNT query with LIMIT for a specific class."""
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 """
189def _count_class_instances(class_uri: str, limit: int = COUNT_LIMIT) -> tuple:
190 """
191 Count instances of a class up to a limit.
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)
199 sparql.setQuery(query)
200 sparql.setReturnFormat(JSON)
201 bindings = get_sparql_bindings(sparql.query().convert())
203 count = int(bindings[0]["count"]["value"])
205 if count > limit:
206 return f"{limit}+", limit
207 return str(count), count
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)
225 sparql = get_sparql()
227 subjects_query = f"""
228 SELECT DISTINCT ?subject
229 WHERE {{
230 ?subject a <{class_uri}> .
231 }}
232 LIMIT {limit}
233 """
235 sparql.setQuery(subjects_query)
236 sparql.setReturnFormat(JSON)
237 subjects_bindings = get_sparql_bindings(sparql.query().convert())
239 subjects = [r["subject"]["value"] for r in subjects_bindings]
241 if not subjects:
242 return defaultdict(list)
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 )
251 triples_query = f"""
252 SELECT ?subject ?p ?o
253 WHERE {{
254 {pattern_with_filter}
255 }}
256 """
258 sparql.setQuery(triples_query)
259 sparql.setReturnFormat(JSON)
260 triples_bindings = get_sparql_bindings(sparql.query().convert())
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))
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 )
279 return shape_to_entities
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()
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))
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"])
302 return list(classes)
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
310 return _get_classes_from_sparql()
313def _get_classes_from_sparql() -> list[str]:
314 sparql = get_sparql()
315 pattern = "?subject a ?class ."
316 wrapped_pattern = _wrap_virtuoso_graph_pattern(pattern)
318 query = f"""
319 SELECT DISTINCT ?class
320 WHERE {{
321 {wrapped_pattern}
322 }}
323 """
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]
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
338 custom_filter = get_custom_filter()
339 class_uris = _get_classes_from_config()
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 )
352 classes_with_counts.sort(key=lambda x: x["numeric_count"], reverse=True)
354 available_classes = []
355 classes_with_multiple_shapes = get_classes_with_multiple_shapes()
357 for class_data in classes_with_counts:
358 class_uri = class_data["uri"]
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 )
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)
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 )
394 available_classes.sort(key=lambda x: x["label"].lower())
395 _cache["available_classes"] = (available_classes, time.monotonic())
396 return available_classes
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.
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
410 Returns:
411 SPARQL sort clause or empty string
412 """
413 if not sort_property or not entity_type:
414 return ""
416 rule = find_matching_rule(entity_type, shape_uri)
418 if not rule or "sortableBy" not in rule:
419 return ""
421 sort_config = next(
422 (s for s in rule["sortableBy"] if s.get("property") == sort_property), None
423 )
425 if not sort_config:
426 return ""
428 return f"OPTIONAL {{ ?subject <{sort_property}> ?sortValue }}"
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
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 []
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 )
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)
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
469 subjects_query = f"""
470 SELECT DISTINCT ?subject
471 WHERE {{
472 ?subject a <{selected_class}> .
473 }}
474 LIMIT {fetch_limit}
475 OFFSET {offset}
476 """
478 sparql.setQuery(subjects_query)
479 sparql.setReturnFormat(JSON)
480 subjects_bindings = get_sparql_bindings(sparql.query().convert())
482 subjects = [r["subject"]["value"] for r in subjects_bindings]
484 if not subjects:
485 return [], 0
487 subjects_filter = " ".join([f"(<{s}>)" for s in subjects])
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 """
497 sparql.setQuery(triples_query)
498 sparql.setReturnFormat(JSON)
499 triples_bindings = get_sparql_bindings(sparql.query().convert())
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))
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 ]
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)
523 total_count = len(filtered_entities)
524 return filtered_entities[: query.per_page], total_count
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()
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
544 use_shape_filtering = (
545 selected_shape and selected_class in classes_with_multiple_shapes
546 )
548 if use_shape_filtering:
549 return _get_entities_with_shape_filtering(query)
551 offset = (page - 1) * per_page
552 sort_clause = ""
553 order_clause = ""
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)"
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 """
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
580 sparql.setQuery(entities_query)
581 sparql.setReturnFormat(JSON)
582 entities_bindings = get_sparql_bindings(sparql.query().convert())
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 ]
592 return entities, total_count
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
604 if query.selected_class:
605 sortable_properties = get_sortable_properties(
606 (query.selected_class, query.selected_shape)
607 )
609 if not sort_property and sortable_properties:
610 sort_property = sortable_properties[0]["property"]
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)
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 }
640def warm_catalogue(
641 available_classes: list[dict[str, str | int]], per_page: int
642) -> None:
643 total_started_at = time.monotonic()
645 for class_info in available_classes:
646 if int(class_info["count_numeric"]) == 0:
647 continue
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()
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 )
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 )
671 current_app.logger.info(
672 "[STARTUP] Catalogue warm-up completed in %.3f seconds",
673 time.monotonic() - total_started_at,
674 )
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"])
686def fetch_data_graph_for_subject(subject: URIRef) -> Graph | Dataset:
687 g = Dataset() if get_dataset_is_quadstore() else Graph()
688 sparql = get_sparql()
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 """
717 sparql.setQuery(query)
718 sparql.setReturnFormat(JSON)
719 bindings = get_sparql_bindings(sparql.query().convert())
721 for result in bindings:
722 value = _binding_to_node(result["object"])
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))
738 return g
741def parse_sparql_update(query: str) -> dict[str, list[tuple[Node, Node, Node]]]:
742 parsed = parseUpdate(query)
743 translated = translateUpdate(parsed).algebra
744 modifications = {}
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 ]
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)
771 return modifications
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.
781 Args:
782 provenance (dict): Dictionary containing provenance metadata for main entity and
783 related entities
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()
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))
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]
801 return combined_graph
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
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
824def _deletion_snapshots_pattern(selected_class: str | None) -> str:
825 """
826 Match the snapshots that deleted their entity.
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}"""
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)
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.
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())
879 count = int(bindings[0]["count"]["value"])
881 if count > limit:
882 return f"{limit}+", limit
883 return str(count), count
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
894 custom_filter = get_custom_filter()
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
903 display_count, numeric_count = _count_deleted_class_instances(class_uri)
904 if numeric_count == 0:
905 continue
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 )
917 deleted_classes.sort(key=lambda x: str(x["label"]).lower())
918 _cache["deleted_classes"] = (deleted_classes, time.monotonic())
919 return deleted_classes
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 )
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 }
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
951 for _ in range(RELATED_STATE_DEPTH):
952 if not frontier:
953 return
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())
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 )
975 known |= frontier
976 frontier = _referenced_uris(graph) - known
979def process_deleted_entities(bindings: list[dict]) -> list[dict[str, str]]:
980 """
981 Build the listing entries for one page of deleted entities.
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 = []
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))
1002 _expand_with_current_state(state)
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 )
1027 return entities
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
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"])
1060 direction = "ASC" if query.sort_direction.upper() == "ASC" else "DESC"
1061 offset = (query.page - 1) * query.per_page
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())
1086 return (
1087 process_deleted_entities(bindings),
1088 available_classes,
1089 selected_class,
1090 selected_shape,
1091 sortable_properties,
1092 total_count,
1093 )
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()
1105 intermediate_classes = set()
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"])
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 ""}
1123 # If no specific predicate, get all connected entities
1124 {f"<{subject}> ?p ?entity ." if not predicate else ""}
1126 FILTER(isIRI(?entity))
1127 ?entity a ?type .
1129 # No incoming references from other entities
1130 FILTER NOT EXISTS {{
1131 ?other ?anyPredicate ?entity .
1132 FILTER(?other != <{subject}>)
1133 }}
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 }}
1142 # Exclude intermediate relation entities
1143 FILTER(?type NOT IN (<{">, <".join(intermediate_classes)}>))
1144 }}
1145 """
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 """
1176 orphaned = []
1177 intermediate_orphans = []
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())
1188 for result in query_bindings:
1189 result_list.append(
1190 {"uri": result["entity"]["value"], "type": result["type"]["value"]}
1191 )
1193 return orphaned, intermediate_orphans
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)
1206 if include_referencing_entities:
1207 sparql = get_sparql()
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 """
1228 sparql.setQuery(query)
1229 sparql.setReturnFormat(JSON)
1230 ref_bindings = get_sparql_bindings(sparql.query().convert())
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))
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
1251 imported_subjects.add(current_subject)
1252 editor.import_entity(URIRef(current_subject))
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 """
1263 sparql = get_sparql()
1264 sparql.setQuery(query)
1265 sparql.setReturnFormat(JSON)
1266 inner_bindings = get_sparql_bindings(sparql.query().convert())
1268 for result in inner_bindings:
1269 queue.append((result["o"]["value"], current_depth + 1))
1271 return editor
1274def get_entity_types(subject_uri: str) -> list[str]:
1275 sparql = get_sparql()
1277 query = f"""
1278 SELECT ?type WHERE {{
1279 <{subject_uri}> a ?type .
1280 }}
1281 """
1283 sparql.setQuery(query)
1284 sparql.setReturnFormat(JSON)
1285 bindings = get_sparql_bindings(sparql.query().convert())
1287 return [result["type"]["value"] for result in bindings]
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.
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().
1301 Args:
1302 data: The structured data (can be dict, list, or string)
1303 existing_entities: Set to collect URIs (created if None)
1305 Returns:
1306 Set of URIs (strings) of existing entities that should be imported
1307 """
1309 if existing_entities is None:
1310 existing_entities = set()
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"]))
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)
1326 elif isinstance(data, list):
1327 for item in data:
1328 collect_referenced_entities(item, existing_entities)
1330 return existing_entities
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