Coverage for src / time_agnostic_library / agnostic_entity.py: 100%
570 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-06-12 21:46 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-06-12 21:46 +0000
1# SPDX-FileCopyrightText: 2021-2026 Arcangelo Massari <arcangelo.massari@unibo.it>
2#
3# SPDX-License-Identifier: ISC
5import re
7from time_agnostic_library.prov_entity import ProvEntity
8from time_agnostic_library.sparql import Sparql, _n3_value
9from time_agnostic_library.support import _cached_parse as _parse_datetime
10from time_agnostic_library.support import convert_to_datetime
12_OPERATION_RE = re.compile(r"(DELETE|INSERT)\s+DATA", re.IGNORECASE)
13_GRAPH_BLOCK_RE = re.compile(r"GRAPH\s*<([^>]+)>\s*\{", re.IGNORECASE)
15_RDF_TERM_RE = re.compile(
16 r"<([^>]+)>"
17 r'|"((?:[^"\\]|\\.)*)"\^\^<([^>]+)>'
18 r'|"((?:[^"\\]|\\.)*)"@([a-zA-Z][\w-]*)'
19 r'|"((?:[^"\\]|\\.)*)"'
20 r"|'((?:[^'\\]|\\.)*)'"
21 r"|(_:\S+)",
22 re.DOTALL,
23)
25_ESCAPE_CHAR_RE = re.compile(r"\\(.)")
26_ESCAPE_CHAR_MAP = {"n": "\n", "r": "\r", "t": "\t"}
28_RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"
30_TRIPLE_LEN = 3
33def _unescape_literal(s: str) -> str:
34 if "\\" not in s:
35 return s
36 return _ESCAPE_CHAR_RE.sub(
37 lambda m: _ESCAPE_CHAR_MAP.get(m.group(1), m.group(1)), s
38 )
41def _normalize_literal(raw: str) -> str:
42 unescaped = _unescape_literal(raw)
43 return (
44 unescaped.replace("\\", "\\\\")
45 .replace('"', '\\"')
46 .replace("\n", "\\n")
47 .replace("\r", "\\r")
48 )
51def _regex_match_to_n3(match: re.Match) -> str:
52 uri = match.group(1)
53 if uri is not None:
54 return f"<{uri}>"
56 typed_value = match.group(2)
57 if typed_value is not None:
58 return f'"{_normalize_literal(typed_value)}"^^<{match.group(3)}>'
60 lang_value = match.group(4)
61 if lang_value is not None:
62 return f'"{_normalize_literal(lang_value)}"@{match.group(5)}'
64 double_quoted = match.group(6)
65 if double_quoted is not None:
66 return f'"{_normalize_literal(double_quoted)}"'
68 single_quoted = match.group(7)
69 if single_quoted is not None:
70 return f'"{_normalize_literal(single_quoted)}"'
72 return match.group(8)
75_BRACE_OR_QUOTE_RE = re.compile(r'[{}\'"]')
78def _find_matching_close_brace(text: str, start: int) -> int:
79 pos = start
80 length = len(text)
81 depth = 1
82 while pos < length:
83 m = _BRACE_OR_QUOTE_RE.search(text, pos)
84 if m is None:
85 return length
86 pos = m.start()
87 char = text[pos]
88 if char == "{":
89 depth += 1
90 pos += 1
91 elif char == "}":
92 depth -= 1
93 if depth == 0:
94 return pos
95 pos += 1
96 else:
97 quote_char = char
98 pos += 1
99 while pos < length:
100 q = text.find(quote_char, pos)
101 if q == -1:
102 pos = length
103 break
104 num_backslashes = 0
105 check = q - 1
106 while check >= start and text[check] == "\\":
107 num_backslashes += 1
108 check -= 1
109 if num_backslashes % 2 == 0:
110 pos = q + 1
111 break
112 pos = q + 1
113 return length
116def _fast_parse_update(
117 update_query: str,
118) -> list[tuple[str, list[tuple[str, str, str, str]]]]:
119 operations: list[tuple[str, list[tuple[str, str, str, str]]]] = []
120 operation_matches = list(_OPERATION_RE.finditer(update_query))
121 query_len = len(update_query)
123 for i, operation_match in enumerate(operation_matches):
124 operation_type = (
125 "DeleteData"
126 if operation_match.group(1).upper() == "DELETE"
127 else "InsertData"
128 )
130 op_start = operation_match.end()
131 op_end = (
132 operation_matches[i + 1].start()
133 if i + 1 < len(operation_matches)
134 else query_len
135 )
136 operation_body = update_query[op_start:op_end]
138 quads: list[tuple[str, str, str, str]] = []
139 for graph_match in _GRAPH_BLOCK_RE.finditer(operation_body):
140 graph_n3 = f"<{graph_match.group(1)}>"
141 triples_start = graph_match.end()
142 triples_end = _find_matching_close_brace(operation_body, triples_start)
143 triples_text = operation_body[triples_start:triples_end]
145 terms: list[str] = []
146 for m in _RDF_TERM_RE.finditer(triples_text):
147 terms.append(_regex_match_to_n3(m))
148 if len(terms) == _TRIPLE_LEN:
149 quads.append((terms[0], terms[1], terms[2], graph_n3))
150 terms.clear()
152 operations.append((operation_type, quads))
154 return operations
157def _compose_update_queries(
158 update_queries: list[str],
159) -> tuple[set[tuple[str, ...]], set[tuple[str, ...]]]:
160 additions: set[tuple[str, ...]] = set()
161 deletions: set[tuple[str, ...]] = set()
162 for uq in update_queries:
163 for op_type, quads in _fast_parse_update(uq):
164 if op_type == "DeleteData":
165 for quad in quads:
166 if quad in additions:
167 additions.discard(quad)
168 else:
169 deletions.add(quad)
170 elif op_type == "InsertData":
171 for quad in quads:
172 if quad in deletions:
173 deletions.discard(quad)
174 else:
175 additions.add(quad)
176 return additions, deletions
179CONFIG_PATH = "./config.json"
182_GEN_AT_TIME_N3 = f"<{ProvEntity.iri_generated_at_time}>"
183_HAS_UQ_N3 = f"<{ProvEntity.iri_has_update_query}>"
186def _extract_snapshot_update_queries(
187 quads: set[tuple[str, ...]],
188) -> dict[str, str | None]:
189 by_subject: dict[str, dict[str, str]] = {}
190 for quad in quads:
191 if quad[1] in (_GEN_AT_TIME_N3, _HAS_UQ_N3):
192 by_subject.setdefault(quad[0], {})[quad[1]] = _n3_value(quad[2])
193 result: dict[str, str | None] = {}
194 for props in by_subject.values():
195 if _GEN_AT_TIME_N3 in props:
196 result[props[_GEN_AT_TIME_N3]] = props.get(_HAS_UQ_N3)
197 return result
200_PROV_PREFIX = ProvEntity.PROV
201_RDF_TYPE_N3 = f"<{_RDF_TYPE}>"
204def _find_related_object_uris(entity_uri: str, graphs: dict) -> set[str]:
205 entity_n3 = f"<{entity_uri}>"
206 result = set()
207 for quad_set in graphs.values():
208 if quad_set is None:
209 continue
210 for quad in quad_set:
211 if (
212 quad[0] == entity_n3
213 and quad[2].startswith("<")
214 and _PROV_PREFIX not in quad[1]
215 and quad[1] != _RDF_TYPE_N3
216 ):
217 result.add(_n3_value(quad[2]))
218 return result
221class AgnosticEntity:
222 def __init__(
223 self,
224 res: str,
225 config: dict,
226 *,
227 include_related_objects: bool = False,
228 include_merged_entities: bool = False,
229 include_reverse_relations: bool = False,
230 ):
231 self.res = res
232 self.include_related_objects = include_related_objects
233 self.include_merged_entities = include_merged_entities
234 self.include_reverse_relations = include_reverse_relations
235 self.config = config
237 def get_history(self, *, include_prov_metadata: bool = False) -> tuple:
238 if (
239 self.include_related_objects
240 or self.include_merged_entities
241 or self.include_reverse_relations
242 ):
243 histories = {}
244 self._collect_all_related_entities_histories(
245 histories, include_prov_metadata=include_prov_metadata
246 )
247 return self._get_merged_histories(
248 histories, include_prov_metadata=include_prov_metadata
249 )
250 entity_history = self._get_entity_current_state(
251 include_prov_metadata=include_prov_metadata
252 )
253 entity_history = self._get_old_graphs(entity_history)
254 for uri, time_dict in entity_history[0].items():
255 for ts, quad_set in time_dict.items():
256 if quad_set is None:
257 entity_history[0][uri][ts] = set()
258 return tuple(entity_history)
260 def get_histories_by_entity(self, *, include_prov_metadata: bool = False) -> tuple:
261 histories = {}
262 self._collect_all_related_entities_histories(
263 histories, include_prov_metadata=include_prov_metadata
264 )
265 entity_histories = {}
266 metadata = {}
267 for entity_uri, (entity_history_dict, entity_metadata) in histories.items():
268 entity_states = entity_history_dict[entity_uri]
269 if not entity_states:
270 continue
271 entity_histories[entity_uri] = {
272 timestamp: quad_set if quad_set is not None else set()
273 for timestamp, quad_set in entity_states.items()
274 }
275 if include_prov_metadata and entity_metadata:
276 metadata[entity_uri] = entity_metadata[entity_uri]
277 return entity_histories, metadata
279 def _collect_all_related_entities_histories(
280 self, histories: dict, *, include_prov_metadata: bool
281 ) -> None:
282 main_entity = AgnosticEntity(
283 self.res,
284 self.config,
285 include_related_objects=False,
286 include_merged_entities=False,
287 include_reverse_relations=False,
288 )
289 entity_history = main_entity._get_entity_current_state(
290 include_prov_metadata=include_prov_metadata
291 )
292 entity_history = main_entity._get_old_graphs(entity_history)
293 histories[self.res] = (entity_history[0], entity_history[1])
295 processed_entities = {self.res}
297 if self.include_related_objects:
298 self._collect_related_objects_recursively(
299 self.res,
300 processed_entities,
301 histories,
302 include_prov_metadata=include_prov_metadata,
303 )
305 if self.include_merged_entities:
306 self._collect_merged_entities_recursively(
307 self.res,
308 processed_entities,
309 histories,
310 include_prov_metadata=include_prov_metadata,
311 )
313 if self.include_reverse_relations:
314 self._collect_reverse_relations_recursively(
315 self.res,
316 processed_entities,
317 histories,
318 include_prov_metadata=include_prov_metadata,
319 )
321 def _collect_related_objects_recursively(
322 self,
323 entity_uri: str,
324 processed_entities: set[str],
325 histories: dict,
326 *,
327 include_prov_metadata: bool,
328 depth: int | None = None,
329 ) -> None:
330 if depth is not None and depth <= 0:
331 return
333 next_depth = None if depth is None else depth - 1
335 entity_graphs = (
336 histories[entity_uri][0][entity_uri] if entity_uri in histories else None
337 )
338 if not entity_graphs:
339 return
341 for obj_uri in _find_related_object_uris(entity_uri, entity_graphs):
342 if obj_uri not in processed_entities:
343 processed_entities.add(obj_uri)
344 agnostic_entity = AgnosticEntity(
345 obj_uri,
346 self.config,
347 include_related_objects=False,
348 include_merged_entities=False,
349 include_reverse_relations=False,
350 )
351 entity_history = agnostic_entity._get_entity_current_state(
352 include_prov_metadata=include_prov_metadata
353 )
354 entity_history = agnostic_entity._get_old_graphs(entity_history)
355 histories[obj_uri] = (entity_history[0], entity_history[1])
356 self._collect_related_objects_recursively(
357 obj_uri,
358 processed_entities,
359 histories,
360 include_prov_metadata=include_prov_metadata,
361 depth=next_depth,
362 )
364 def _collect_merged_entities_recursively(
365 self,
366 entity_uri: str,
367 processed_entities: set[str],
368 histories: dict,
369 *,
370 include_prov_metadata: bool,
371 depth: int | None = None,
372 ) -> None:
373 if depth is not None and depth <= 0:
374 return
376 next_depth = None if depth is None else depth - 1
378 merged_entities = self._find_merged_entities(entity_uri)
380 for merged_entity_uri in merged_entities:
381 if merged_entity_uri not in processed_entities:
382 processed_entities.add(merged_entity_uri)
383 agnostic_entity = AgnosticEntity(
384 merged_entity_uri,
385 self.config,
386 include_related_objects=False,
387 include_merged_entities=False,
388 include_reverse_relations=False,
389 )
390 entity_history = agnostic_entity._get_entity_current_state(
391 include_prov_metadata=include_prov_metadata
392 )
393 entity_history = agnostic_entity._get_old_graphs(entity_history)
394 histories[merged_entity_uri] = (entity_history[0], entity_history[1])
395 self._collect_merged_entities_recursively(
396 merged_entity_uri,
397 processed_entities,
398 histories,
399 include_prov_metadata=include_prov_metadata,
400 depth=next_depth,
401 )
403 def _collect_reverse_relations_recursively(
404 self,
405 entity_uri: str,
406 processed_entities: set[str],
407 histories: dict,
408 *,
409 include_prov_metadata: bool,
410 depth: int | None = None,
411 ) -> None:
412 if depth is not None and depth <= 0:
413 return
415 next_depth = None if depth is None else depth - 1
417 reverse_related_entities = self._find_reverse_related_entities(entity_uri)
419 for reverse_entity_uri in reverse_related_entities:
420 if reverse_entity_uri not in processed_entities:
421 processed_entities.add(reverse_entity_uri)
422 agnostic_entity = AgnosticEntity(
423 reverse_entity_uri,
424 self.config,
425 include_related_objects=False,
426 include_merged_entities=False,
427 include_reverse_relations=False,
428 )
429 entity_history = agnostic_entity._get_entity_current_state(
430 include_prov_metadata=include_prov_metadata
431 )
432 entity_history = agnostic_entity._get_old_graphs(entity_history)
433 histories[reverse_entity_uri] = (entity_history[0], entity_history[1])
434 self._collect_reverse_relations_recursively(
435 reverse_entity_uri,
436 processed_entities,
437 histories,
438 include_prov_metadata=include_prov_metadata,
439 depth=next_depth,
440 )
442 def _get_merged_histories(
443 self, histories: dict, *, include_prov_metadata: bool
444 ) -> tuple:
445 entity_histories = {}
446 metadata = {}
447 for entity_uri, (entity_history_dict, entity_metadata) in histories.items():
448 entity_histories[entity_uri] = entity_history_dict[entity_uri]
449 if include_prov_metadata and entity_metadata:
450 metadata[entity_uri] = entity_metadata[entity_uri]
452 main_entity_times = sorted(
453 entity_histories[self.res].keys(), key=_parse_datetime
454 )
456 merged_histories = {self.res: {}}
458 related_sorted_times = {}
459 for entity_uri, entity_history in entity_histories.items():
460 if entity_uri == self.res:
461 continue
462 related_sorted_times[entity_uri] = sorted(
463 ((t, _parse_datetime(t)) for t in entity_history), key=lambda x: x[1]
464 )
466 for timestamp in main_entity_times:
467 merged_set = set(entity_histories[self.res][timestamp])
468 timestamp_dt = _parse_datetime(timestamp)
470 for entity_uri, sorted_times in related_sorted_times.items():
471 relevant_time = None
472 for etime, etime_dt in sorted_times:
473 if etime_dt <= timestamp_dt:
474 relevant_time = etime
475 else:
476 break
477 if relevant_time:
478 merged_set.update(entity_histories[entity_uri][relevant_time])
480 merged_histories[self.res][timestamp] = merged_set
482 return merged_histories, metadata
484 def get_state_at_time(
485 self,
486 time: tuple[str | None, str | None],
487 *,
488 include_prov_metadata: bool = False,
489 ) -> tuple:
490 if (
491 self.include_related_objects
492 or self.include_merged_entities
493 or self.include_reverse_relations
494 ):
495 histories = {}
496 self._collect_all_related_entities_states_at_time(
497 histories, time, include_prov_metadata=include_prov_metadata
498 )
499 return self._get_merged_histories_at_time(
500 histories, include_prov_metadata=include_prov_metadata
501 )
502 return self._get_entity_state_at_time(
503 time, include_prov_metadata=include_prov_metadata
504 )
506 def get_delta(
507 self,
508 time_start: str,
509 time_end: str,
510 ) -> tuple[set[tuple[str, ...]], set[tuple[str, ...]]]:
511 is_quadstore = self.config["provenance"]["is_quadstore"]
512 graph_statement = f"GRAPH <{self.res}/prov/>" if is_quadstore else ""
513 query_snapshots = f"""
514 SELECT ?time ?updateQuery
515 WHERE {{
516 {graph_statement}
517 {{
518 ?snapshot <{ProvEntity.iri_specialization_of}> <{self.res}>;
519 <{ProvEntity.iri_generated_at_time}> ?time.
520 OPTIONAL {{
521 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery.
522 }}
523 }}
524 }}
525 """
526 results = Sparql(query_snapshots, config=self.config).run_select_query()
527 bindings = results["results"]["bindings"]
528 if not bindings:
529 return set(), set()
530 start_dt = _parse_datetime(time_start)
531 end_dt = _parse_datetime(time_end)
532 parsed = [(b, _parse_datetime(b["time"]["value"])) for b in bindings]
533 first_snapshot_dt = min(dt for _, dt in parsed)
534 if first_snapshot_dt > start_dt:
535 entity_graphs, _, _ = self._get_entity_state_at_time(
536 (time_end, time_end), include_prov_metadata=False
537 )
538 if not entity_graphs:
539 return set(), set()
540 state_at_end = next(iter(entity_graphs.values()))
541 return state_at_end, set()
542 relevant = sorted(
543 (
544 (b, dt)
545 for b, dt in parsed
546 if start_dt < dt <= end_dt
547 and "updateQuery" in b
548 and "value" in b["updateQuery"]
549 ),
550 key=lambda x: x[1],
551 )
552 return _compose_update_queries([b["updateQuery"]["value"] for b, _ in relevant])
554 def _collect_all_related_entities_states_at_time(
555 self,
556 histories: dict,
557 time: tuple[str | None, str | None],
558 *,
559 include_prov_metadata: bool,
560 ) -> None:
561 main_entity = AgnosticEntity(
562 self.res,
563 self.config,
564 include_related_objects=False,
565 include_merged_entities=False,
566 include_reverse_relations=False,
567 )
568 entity_graphs, entity_snapshots, other_snapshots_metadata = (
569 main_entity._get_entity_state_at_time(
570 time, include_prov_metadata=include_prov_metadata
571 )
572 )
573 histories[self.res] = (
574 entity_graphs,
575 entity_snapshots,
576 other_snapshots_metadata,
577 )
579 processed_entities = {self.res}
581 if self.include_related_objects:
582 self._collect_related_objects_states_at_time(
583 self.res,
584 processed_entities,
585 histories,
586 time,
587 include_prov_metadata=include_prov_metadata,
588 )
590 if self.include_merged_entities:
591 self._collect_merged_entities_states_at_time(
592 self.res,
593 processed_entities,
594 histories,
595 time,
596 include_prov_metadata=include_prov_metadata,
597 )
599 if self.include_reverse_relations:
600 self._collect_reverse_relations_states_at_time(
601 self.res,
602 processed_entities,
603 histories,
604 time,
605 include_prov_metadata=include_prov_metadata,
606 )
608 def _collect_related_objects_states_at_time(
609 self,
610 entity_uri: str,
611 processed_entities: set[str],
612 histories: dict,
613 time: tuple[str | None, str | None],
614 *,
615 include_prov_metadata: bool,
616 depth: int | None = None,
617 ) -> None:
618 if depth is not None and depth <= 0:
619 return
621 next_depth = None if depth is None else depth - 1
623 entity_graphs = histories[entity_uri][0] if entity_uri in histories else None
624 if not entity_graphs:
625 return
627 for obj_uri in _find_related_object_uris(entity_uri, entity_graphs):
628 if obj_uri not in processed_entities:
629 processed_entities.add(obj_uri)
630 agnostic_entity = AgnosticEntity(
631 obj_uri,
632 self.config,
633 include_related_objects=False,
634 include_merged_entities=False,
635 include_reverse_relations=False,
636 )
637 entity_graphs_new, entity_snapshots, other_snapshots_metadata = (
638 agnostic_entity._get_entity_state_at_time(
639 time, include_prov_metadata=include_prov_metadata
640 )
641 )
642 histories[obj_uri] = (
643 entity_graphs_new,
644 entity_snapshots,
645 other_snapshots_metadata,
646 )
647 self._collect_related_objects_states_at_time(
648 obj_uri,
649 processed_entities,
650 histories,
651 time,
652 include_prov_metadata=include_prov_metadata,
653 depth=next_depth,
654 )
656 def _collect_merged_entities_states_at_time(
657 self,
658 entity_uri: str,
659 processed_entities: set[str],
660 histories: dict,
661 time: tuple[str | None, str | None],
662 *,
663 include_prov_metadata: bool,
664 depth: int | None = None,
665 ) -> None:
666 if depth is not None and depth <= 0:
667 return
669 next_depth = None if depth is None else depth - 1
671 merged_entities = self._find_merged_entities(entity_uri)
673 for merged_entity_uri in merged_entities:
674 if merged_entity_uri not in processed_entities:
675 processed_entities.add(merged_entity_uri)
676 agnostic_entity = AgnosticEntity(
677 merged_entity_uri,
678 self.config,
679 include_related_objects=False,
680 include_merged_entities=False,
681 include_reverse_relations=False,
682 )
683 entity_graphs, entity_snapshots, other_snapshots_metadata = (
684 agnostic_entity._get_entity_state_at_time(
685 time, include_prov_metadata=include_prov_metadata
686 )
687 )
688 histories[merged_entity_uri] = (
689 entity_graphs,
690 entity_snapshots,
691 other_snapshots_metadata,
692 )
693 self._collect_merged_entities_states_at_time(
694 merged_entity_uri,
695 processed_entities,
696 histories,
697 time,
698 include_prov_metadata=include_prov_metadata,
699 depth=next_depth,
700 )
702 def _collect_reverse_relations_states_at_time(
703 self,
704 entity_uri: str,
705 processed_entities: set[str],
706 histories: dict,
707 time: tuple[str | None, str | None],
708 *,
709 include_prov_metadata: bool,
710 depth: int | None = None,
711 ) -> None:
712 if depth is not None and depth <= 0:
713 return
715 next_depth = None if depth is None else depth - 1
717 reverse_related_entities = self._find_reverse_related_entities(entity_uri)
719 for reverse_entity_uri in reverse_related_entities:
720 if reverse_entity_uri not in processed_entities:
721 processed_entities.add(reverse_entity_uri)
722 agnostic_entity = AgnosticEntity(
723 reverse_entity_uri,
724 self.config,
725 include_related_objects=False,
726 include_merged_entities=False,
727 include_reverse_relations=False,
728 )
729 entity_graphs, entity_snapshots, other_snapshots_metadata = (
730 agnostic_entity._get_entity_state_at_time(
731 time, include_prov_metadata=include_prov_metadata
732 )
733 )
734 histories[reverse_entity_uri] = (
735 entity_graphs,
736 entity_snapshots,
737 other_snapshots_metadata,
738 )
739 self._collect_reverse_relations_states_at_time(
740 reverse_entity_uri,
741 processed_entities,
742 histories,
743 time,
744 include_prov_metadata=include_prov_metadata,
745 depth=next_depth,
746 )
748 def _get_merged_histories_at_time(
749 self, histories: dict, *, include_prov_metadata: bool
750 ) -> tuple:
751 entity_histories = {}
752 entity_snapshots_metadata = {}
753 other_snapshots_metadata = {} if include_prov_metadata else None
755 for entity_uri, (
756 entity_graphs,
757 entity_snapshots,
758 other_snapshots,
759 ) in histories.items():
760 entity_histories[entity_uri] = entity_graphs
761 entity_snapshots_metadata[entity_uri] = entity_snapshots
762 if (
763 include_prov_metadata
764 and other_snapshots
765 and other_snapshots_metadata is not None
766 ):
767 other_snapshots_metadata[entity_uri] = other_snapshots
769 main_entity_times = sorted(
770 set(entity_histories[self.res].keys()), key=_parse_datetime
771 )
773 merged_histories = {self.res: {}}
775 related_sorted_times = {}
776 for entity_uri, graphs_at_times in entity_histories.items():
777 if entity_uri == self.res:
778 continue
779 related_sorted_times[entity_uri] = sorted(
780 ((t, _parse_datetime(t)) for t in graphs_at_times), key=lambda x: x[1]
781 )
783 for timestamp in main_entity_times:
784 merged_set = set(entity_histories[self.res][timestamp])
785 timestamp_dt = _parse_datetime(timestamp)
787 for entity_uri, sorted_times in related_sorted_times.items():
788 graphs_at_times = entity_histories[entity_uri]
789 if timestamp in graphs_at_times:
790 related_quads = graphs_at_times[timestamp]
791 else:
792 relevant_time = None
793 for rt, rt_dt in sorted_times:
794 if rt_dt <= timestamp_dt:
795 relevant_time = rt
796 else:
797 break
798 if relevant_time:
799 related_quads = graphs_at_times[relevant_time]
800 else:
801 continue
802 merged_set.update(related_quads)
804 merged_histories[self.res][timestamp] = merged_set
806 return merged_histories, entity_snapshots_metadata, other_snapshots_metadata
808 def _get_entity_state_at_time(
809 self, time: tuple[str | None, str | None], *, include_prov_metadata: bool
810 ) -> tuple:
811 other_snapshots_metadata = {}
812 is_quadstore = self.config["provenance"]["is_quadstore"]
813 graph_statement = f"GRAPH <{self.res}/prov/>" if is_quadstore else ""
814 if include_prov_metadata:
815 query_snapshots = f"""
816 SELECT ?snapshot ?time ?responsibleAgent ?updateQuery
817 ?primarySource ?description ?invalidatedAtTime
818 WHERE {{
819 {graph_statement}
820 {{
821 ?snapshot <{ProvEntity.iri_specialization_of}> <{self.res}>;
822 <{ProvEntity.iri_generated_at_time}> ?time;
823 <{ProvEntity.iri_was_attributed_to}> ?responsibleAgent.
824 OPTIONAL {{
825 ?snapshot <{ProvEntity.iri_invalidated_at_time}>
826 ?invalidatedAtTime.
827 }}
828 OPTIONAL {{
829 ?snapshot <{ProvEntity.iri_description}> ?description.
830 }}
831 OPTIONAL {{
832 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery.
833 }}
834 OPTIONAL {{
835 ?snapshot <{ProvEntity.iri_had_primary_source}>
836 ?primarySource.
837 }}
838 }}
839 }}
840 """
841 else:
842 query_snapshots = f"""
843 SELECT ?snapshot ?time ?updateQuery
844 WHERE {{
845 {graph_statement}
846 {{
847 ?snapshot <{ProvEntity.iri_specialization_of}> <{self.res}>;
848 <{ProvEntity.iri_generated_at_time}> ?time.
849 OPTIONAL {{
850 ?snapshot <{ProvEntity.iri_has_update_query}> ?updateQuery.
851 }}
852 }}
853 }}
854 """
855 results = Sparql(query_snapshots, config=self.config).run_select_query()
856 bindings = results["results"]["bindings"]
857 if not bindings:
858 return {}, {}, other_snapshots_metadata
859 sorted_results = sorted(
860 bindings, key=lambda x: _parse_datetime(x["time"]["value"]), reverse=True
861 )
862 relevant_results = _filter_timestamps_by_interval(
863 time, sorted_results, time_index="time"
864 )
865 if include_prov_metadata:
866 relevant_snapshot_uris = {
867 relevant_result["snapshot"]["value"]
868 for relevant_result in relevant_results
869 }
870 other_snapshots = [
871 snapshot
872 for snapshot in bindings
873 if snapshot["snapshot"]["value"] not in relevant_snapshot_uris
874 ]
875 for other_snapshot in other_snapshots:
876 snapshot_uri = other_snapshot["snapshot"]["value"]
877 other_snapshots_metadata[snapshot_uri] = {
878 "generatedAtTime": other_snapshot["time"]["value"],
879 "invalidatedAtTime": other_snapshot.get(
880 "invalidatedAtTime", {}
881 ).get("value"),
882 "wasAttributedTo": other_snapshot["responsibleAgent"]["value"],
883 "hasUpdateQuery": other_snapshot.get("updateQuery", {}).get(
884 "value"
885 ),
886 "hadPrimarySource": other_snapshot.get("primarySource", {}).get(
887 "value"
888 ),
889 "description": other_snapshot.get("description", {}).get("value"),
890 }
891 if not relevant_results:
892 interval_start = _parse_datetime(time[0]) if time[0] else None
893 if interval_start:
894 earlier_snapshots = [
895 r
896 for r in bindings
897 if _parse_datetime(r["time"]["value"]) <= interval_start
898 ]
899 if earlier_snapshots:
900 latest_snapshot = max(
901 earlier_snapshots,
902 key=lambda x: _parse_datetime(x["time"]["value"]),
903 )
904 relevant_results = [latest_snapshot]
905 else:
906 return {}, {}, other_snapshots_metadata
907 else:
908 return {}, {}, other_snapshots_metadata
909 entity_snapshots = {}
910 entity_graphs: dict[str, set[tuple[str, ...]]] = {}
911 entity_quads = self._query_dataset(self.res)
912 sorted_parsed = [
913 (r, _parse_datetime(r["time"]["value"])) for r in sorted_results
914 ]
915 last_idx = len(relevant_results) - 1
916 for i, relevant_result in enumerate(relevant_results):
917 relevant_result_time = relevant_result["time"]["value"]
918 relevant_result_dt = _parse_datetime(relevant_result_time)
919 update_parts = [
920 r["updateQuery"]["value"]
921 for r, r_dt in sorted_parsed
922 if "updateQuery" in r
923 and "value" in r["updateQuery"]
924 and r_dt > relevant_result_dt
925 ]
926 entity_present_graph = entity_quads if i == last_idx else set(entity_quads)
927 if update_parts:
928 self._manage_update_queries(
929 entity_present_graph, ";".join(update_parts)
930 )
931 timestamp_key = convert_to_datetime(relevant_result_time, stringify=True)
932 entity_graphs[timestamp_key] = entity_present_graph # type: ignore[index]
933 if include_prov_metadata:
934 snapshot_uri = relevant_result["snapshot"]["value"]
935 entity_snapshots[snapshot_uri] = {
936 "generatedAtTime": relevant_result_time,
937 "invalidatedAtTime": relevant_result.get(
938 "invalidatedAtTime", {}
939 ).get("value"),
940 "wasAttributedTo": relevant_result["responsibleAgent"]["value"],
941 "hasUpdateQuery": relevant_result.get("updateQuery", {}).get(
942 "value"
943 ),
944 "hadPrimarySource": relevant_result.get("primarySource", {}).get(
945 "value"
946 ),
947 "description": relevant_result.get("description", {}).get("value"),
948 }
949 return entity_graphs, entity_snapshots, other_snapshots_metadata
951 def _include_prov_metadata(
952 self, triples_generated_at_time: list, current_state: set[tuple[str, ...]]
953 ) -> dict:
954 res_n3 = f"<{self.res}>"
955 entity_n3 = f"<{ProvEntity.iri_entity}>"
956 for quad in current_state:
957 if quad[0] == res_n3 and quad[1] == _RDF_TYPE_N3 and quad[2] == entity_n3:
958 return {}
959 prov_properties = {
960 f"<{ProvEntity.iri_invalidated_at_time}>": "invalidatedAtTime",
961 f"<{ProvEntity.iri_was_attributed_to}>": "wasAttributedTo",
962 f"<{ProvEntity.iri_had_primary_source}>": "hadPrimarySource",
963 f"<{ProvEntity.iri_description}>": "description",
964 f"<{ProvEntity.iri_has_update_query}>": "hasUpdateQuery",
965 f"<{ProvEntity.iri_was_derived_from}>": "wasDerivedFrom",
966 }
967 prov_metadata: dict = {self.res: {}}
968 for triple in triples_generated_at_time:
969 time = convert_to_datetime(_n3_value(triple[2]), stringify=True)
970 snapshot_uri_str = _n3_value(triple[0])
971 prov_metadata[self.res][snapshot_uri_str] = {
972 "generatedAtTime": time,
973 "invalidatedAtTime": None,
974 "wasAttributedTo": None,
975 "hadPrimarySource": None,
976 "description": None,
977 "hasUpdateQuery": None,
978 "wasDerivedFrom": [],
979 }
980 prov_prop_n3_set = set(prov_properties)
981 index: dict[str, dict[str, list[str]]] = {}
982 for quad in current_state:
983 if quad[1] in prov_prop_n3_set:
984 index.setdefault(quad[0], {}).setdefault(quad[1], []).append(
985 _n3_value(quad[2])
986 )
987 for metadata in dict(prov_metadata).values():
988 for se_uri_str, snapshot_data in metadata.items():
989 se_n3 = f"<{se_uri_str}>"
990 se_props = index.get(se_n3, {})
991 for prov_prop_n3, abbr in prov_properties.items():
992 for value in se_props.get(prov_prop_n3, ()):
993 if abbr == "wasDerivedFrom":
994 snapshot_data[abbr].append(value)
995 else:
996 snapshot_data[abbr] = value
997 if isinstance(snapshot_data.get("wasDerivedFrom"), list):
998 snapshot_data["wasDerivedFrom"] = sorted(
999 snapshot_data["wasDerivedFrom"]
1000 )
1002 return prov_metadata
1004 def _get_entity_current_state(self, *, include_prov_metadata: bool = False) -> list:
1005 entity_current_state: list = [{self.res: {}}]
1006 prov_quads = self._query_provenance(include_prov_metadata=include_prov_metadata)
1007 if len(prov_quads) == 0:
1008 entity_current_state.append({})
1009 return entity_current_state
1010 dataset_quads = self._query_dataset(self.res)
1011 gen_at_time_n3 = f"<{ProvEntity.iri_generated_at_time}>"
1012 triples_generated_at_time = [
1013 quad for quad in prov_quads if quad[1] == gen_at_time_n3
1014 ]
1015 most_recent_time = None
1016 most_recent_time_str: str | None = None
1017 for quad in triples_generated_at_time:
1018 snapshot_time_str = _n3_value(quad[2])
1019 snapshot_date_time = _parse_datetime(snapshot_time_str)
1020 if most_recent_time:
1021 if snapshot_date_time > most_recent_time:
1022 most_recent_time = snapshot_date_time
1023 most_recent_time_str = snapshot_time_str
1024 else:
1025 most_recent_time = snapshot_date_time
1026 most_recent_time_str = snapshot_time_str
1027 entity_current_state[0][self.res][snapshot_time_str] = None
1028 entity_current_state[0][self.res][most_recent_time_str] = dataset_quads
1029 if include_prov_metadata:
1030 prov_metadata = self._include_prov_metadata(
1031 triples_generated_at_time, prov_quads
1032 )
1033 entity_current_state.append(prov_metadata)
1034 else:
1035 entity_current_state.append(None)
1036 entity_current_state.append(prov_quads)
1037 return entity_current_state
1039 def _get_old_graphs(self, entity_current_state: list) -> list:
1040 prov_quads_index = 2
1041 prov_quads = (
1042 entity_current_state.pop(prov_quads_index)
1043 if len(entity_current_state) > prov_quads_index
1044 else set()
1045 )
1046 snapshot_update_queries = _extract_snapshot_update_queries(prov_quads)
1047 ordered_data: list[tuple[str, set[tuple[str, ...]]]] = sorted(
1048 entity_current_state[0][self.res].items(),
1049 key=lambda x: _parse_datetime(str(x[0])),
1050 reverse=True,
1051 )
1052 if not ordered_data:
1053 return entity_current_state
1054 for index, date_graph in enumerate(ordered_data):
1055 if index > 0:
1056 next_snapshot = ordered_data[index - 1][0]
1057 previous_graph = set(entity_current_state[0][self.res][next_snapshot])
1058 update_query = snapshot_update_queries.get(str(next_snapshot))
1059 if update_query is None:
1060 entity_current_state[0][self.res][date_graph[0]] = previous_graph
1061 else:
1062 self._manage_update_queries(previous_graph, update_query)
1063 entity_current_state[0][self.res][date_graph[0]] = previous_graph
1064 for time in list(entity_current_state[0][self.res]):
1065 quad_set = entity_current_state[0][self.res].pop(time)
1066 time_str = str(convert_to_datetime(str(time), stringify=True))
1067 entity_current_state[0][self.res][time_str] = quad_set
1068 return entity_current_state
1070 def iter_versions(self):
1071 prov_quads = self._query_provenance(include_prov_metadata=False)
1072 if len(prov_quads) == 0:
1073 return
1074 dataset_quads = self._query_dataset(self.res)
1075 working: set[tuple[str, ...]] = set(dataset_quads)
1076 snapshots = _extract_snapshot_update_queries(prov_quads)
1077 ordered = sorted(
1078 snapshots.items(), key=lambda x: _parse_datetime(x[0]), reverse=True
1079 )
1080 for i, (time_str, _update_query) in enumerate(ordered):
1081 if i > 0:
1082 prev_update = ordered[i - 1][1]
1083 if prev_update is not None:
1084 self._manage_update_queries(working, prev_update)
1085 normalized = str(convert_to_datetime(time_str, stringify=True))
1086 yield normalized, set(working)
1088 @classmethod
1089 def _manage_update_queries(cls, graph: set, update_query: str) -> None:
1090 operations = _fast_parse_update(update_query)
1091 for operation_type, quads in operations:
1092 if operation_type == "DeleteData":
1093 for quad in quads:
1094 graph.add(quad)
1095 elif operation_type == "InsertData":
1096 for quad in quads:
1097 graph.discard(quad)
1099 def _query_dataset(self, entity_uri: str | None = None) -> set[tuple[str, ...]]:
1100 entity_uri = self.res if entity_uri is None else entity_uri
1102 is_quadstore = self.config["dataset"]["is_quadstore"]
1104 if is_quadstore:
1105 query_dataset = f"""
1106 SELECT ?s ?p ?o ?g
1107 WHERE {{
1108 GRAPH ?g {{
1109 VALUES ?s {{<{entity_uri}>}}
1110 ?s ?p ?o
1111 }}
1112 }}
1113 """
1114 else:
1115 query_dataset = f"""
1116 SELECT ?s ?p ?o
1117 WHERE {{
1118 VALUES ?s {{<{entity_uri}>}}
1119 ?s ?p ?o
1120 }}
1121 """
1123 return Sparql(query_dataset, config=self.config).run_select_to_quad_set()
1125 def _query_provenance(
1126 self, *, include_prov_metadata: bool = False
1127 ) -> set[tuple[str, ...]]:
1128 if include_prov_metadata:
1129 query_provenance = f"""
1130 SELECT ?s ?p ?o WHERE {{
1131 ?s <{ProvEntity.iri_specialization_of}> <{self.res}>;
1132 <{ProvEntity.iri_was_attributed_to}> ?_agent;
1133 <{ProvEntity.iri_generated_at_time}> ?_t;
1134 <{ProvEntity.iri_description}> ?_desc.
1135 ?s ?p ?o.
1136 VALUES ?p {{
1137 <{ProvEntity.iri_generated_at_time}>
1138 <{ProvEntity.iri_was_attributed_to}>
1139 <{ProvEntity.iri_had_primary_source}>
1140 <{ProvEntity.iri_description}>
1141 <{ProvEntity.iri_has_update_query}>
1142 <{ProvEntity.iri_invalidated_at_time}>
1143 <{ProvEntity.iri_was_derived_from}>
1144 <{ProvEntity.iri_specialization_of}>
1145 }}
1146 }}
1147 """
1148 else:
1149 query_provenance = f"""
1150 SELECT ?s ?p ?o WHERE {{
1151 ?s <{ProvEntity.iri_specialization_of}> <{self.res}>;
1152 <{ProvEntity.iri_generated_at_time}> ?_t.
1153 ?s ?p ?o.
1154 VALUES ?p {{
1155 <{ProvEntity.iri_generated_at_time}>
1156 <{ProvEntity.iri_has_update_query}>
1157 <{ProvEntity.iri_was_derived_from}>
1158 <{ProvEntity.iri_specialization_of}>
1159 }}
1160 }}
1161 """
1162 return Sparql(query_provenance, config=self.config).run_select_to_quad_set()
1164 def _find_merged_entities(self, entity_uri: str) -> set[str]:
1165 merged_entity_uris = set()
1166 query_simple = f"""
1167 SELECT ?merged_entity_uri
1168 WHERE {{
1169 ?snapshot <{ProvEntity.iri_specialization_of}> <{entity_uri}> .
1170 ?snapshot <{ProvEntity.iri_was_derived_from}> ?derived_snapshot .
1171 ?derived_snapshot <{ProvEntity.iri_specialization_of}>
1172 ?merged_entity_uri .
1173 FILTER (?merged_entity_uri != <{entity_uri}>)
1174 }}
1175 """
1176 results = Sparql(query_simple, config=self.config).run_select_query()
1177 bindings = results.get("results", {}).get("bindings", [])
1178 for binding in bindings:
1179 if (
1180 "merged_entity_uri" in binding
1181 and "value" in binding["merged_entity_uri"]
1182 ):
1183 merged_entity_uris.add(binding["merged_entity_uri"]["value"])
1185 return merged_entity_uris
1187 def _find_reverse_related_entities(self, entity_uri: str) -> set[str]:
1188 reverse_related_entity_uris = set()
1190 is_quadstore = self.config["dataset"]["is_quadstore"]
1192 if is_quadstore:
1193 query = f"""
1194 SELECT ?subject
1195 WHERE {{
1196 GRAPH ?g {{
1197 ?subject ?predicate <{entity_uri}> .
1198 FILTER(?predicate != <{_RDF_TYPE}>
1199 && !strstarts(str(?predicate), "{ProvEntity.PROV}"))
1200 }}
1201 }}
1202 """
1203 else:
1204 query = f"""
1205 SELECT ?subject
1206 WHERE {{
1207 ?subject ?predicate <{entity_uri}> .
1208 FILTER(?predicate != <{_RDF_TYPE}>
1209 && !strstarts(str(?predicate), "{ProvEntity.PROV}"))
1210 }}
1211 """
1213 prov_query = f"""
1214 SELECT DISTINCT ?subject
1215 WHERE {{
1216 ?snapshot <{ProvEntity.iri_specialization_of}> ?subject .
1217 ?snapshot <{ProvEntity.iri_has_update_query}> ?update_query .
1218 FILTER(CONTAINS(?update_query, "<{entity_uri}>"))
1219 }}
1220 """
1222 self._add_reverse_related_entities_from_query(
1223 query, entity_uri, reverse_related_entity_uris
1224 )
1225 self._add_reverse_related_entities_from_query(
1226 prov_query, entity_uri, reverse_related_entity_uris
1227 )
1229 return reverse_related_entity_uris
1231 def _add_reverse_related_entities_from_query(
1232 self, select_query: str, entity_uri: str, reverse_related_entity_uris: set[str]
1233 ) -> None:
1234 results = Sparql(select_query, config=self.config).run_select_query()
1235 bindings = results.get("results", {}).get("bindings", [])
1236 for binding in bindings:
1237 if "subject" in binding and "value" in binding["subject"]:
1238 subject_uri = binding["subject"]["value"]
1239 if subject_uri != entity_uri:
1240 reverse_related_entity_uris.add(subject_uri)
1243def _filter_timestamps_by_interval(
1244 interval: tuple[str | None, str | None] | None,
1245 iterator: list,
1246 time_index: str | None = None,
1247) -> list:
1248 if interval:
1249 after_time = _parse_datetime(interval[0]) if interval[0] else None
1250 before_time = _parse_datetime(interval[1]) if interval[1] else None
1251 relevant_timestamps = []
1252 for timestamp in iterator:
1253 if time_index is not None and time_index in timestamp:
1254 time_binding = timestamp[time_index]
1255 if "value" in time_binding:
1256 time_str = time_binding["value"]
1257 time = _parse_datetime(time_str)
1258 else:
1259 continue
1260 else:
1261 continue
1262 if after_time and before_time:
1263 if after_time <= time <= before_time:
1264 relevant_timestamps.append(timestamp)
1265 elif after_time and not before_time:
1266 if time >= after_time:
1267 relevant_timestamps.append(timestamp)
1268 elif before_time and not after_time:
1269 if time <= before_time:
1270 relevant_timestamps.append(timestamp)
1271 else:
1272 relevant_timestamps.append(timestamp)
1273 else:
1274 relevant_timestamps = iterator.copy()
1275 return relevant_timestamps