@@ -540,6 +540,127 @@ def _detect_qvs_class_min_pattern(
540540 return None
541541
542542
543+ # -------------------------------
544+ # Helper detectors for GraphDiffs
545+ # -------------------------------
546+
547+ def _detect_graph_class_cardinality (g : Graph , result : Node ) -> Optional ["GraphClassCardinality" ]:
548+ """Detect a graph-level class cardinality violation produced by custom CONSTRAINT component."""
549+ if g .value (result , SH .sourceConstraintComponent ) == CONSTRAINT .countConstraintComponent :
550+ expected_count = g .value (result , SH .sourceShape / CONSTRAINT .exactCount ) # type: ignore
551+ of_class = g .value (result , SH .sourceShape / CONSTRAINT ["class" ]) # type: ignore
552+ if expected_count is not None and of_class is not None :
553+ validation_report = g .cbd (result )
554+ return GraphClassCardinality (
555+ None , validation_report , g , of_class , int (expected_count ) # type: ignore[arg-type]
556+ )
557+ return None
558+
559+
560+ def _detect_required_class (
561+ g : Graph , result : Node , focus : Optional [URIRef ]
562+ ) -> Optional ["RequiredClass" ]:
563+ """Detect a RequiredClass violation (focus must be an instance of a class)."""
564+ if g .value (result , SH .sourceConstraintComponent ) == SH .ClassConstraintComponent and focus :
565+ requiring_shape = g .value (result , SH .sourceShape )
566+ expected_class = g .value (requiring_shape , SH ["class" ]) if requiring_shape else None
567+ if isinstance (expected_class , URIRef ):
568+ validation_report = g .cbd (result )
569+ return RequiredClass (focus , validation_report , g , expected_class )
570+ return None
571+
572+
573+ def _detect_path_class_count (
574+ g : Graph , result : Node , focus : Optional [URIRef ]
575+ ) -> Optional ["PathClassCount" ]:
576+ """Detect missing related entities with a given class along a path (supports qualifiedValueShape)."""
577+ if not focus :
578+ return None
579+
580+ # First, handle the qualifiedValueShape + qualifiedMinCount pattern even if the engine
581+ # surfaces a ClassConstraintComponent on a value node.
582+ qvs_match = _detect_qvs_class_min_pattern (g , result )
583+ if qvs_match :
584+ path , minc_i , maxc_i , classname = qvs_match
585+ validation_report = g .cbd (result )
586+ return PathClassCount (focus , validation_report , g , path , minc_i , maxc_i , classname )
587+
588+ # Fall back to standard class/minCount detection on the source shape
589+ classpath = SH ["class" ] | (SH .qualifiedValueShape / SH ["class" ]) # type: ignore
590+ path = g .value (result , SH .resultPath )
591+ if not path :
592+ return None
593+ min_count_lit = g .value (result , SH .sourceShape / (SH .minCount | SH .qualifiedMinCount )) # type: ignore
594+ max_count_lit = g .value (result , SH .sourceShape / (SH .maxCount | SH .qualifiedMaxCount )) # type: ignore
595+ classname = g .value (result , SH .sourceShape / classpath )
596+ if classname is None :
597+ return None
598+ minc = _to_int_maybe (min_count_lit )
599+ maxc = _to_int_maybe (max_count_lit )
600+ if minc is None and maxc is None :
601+ return None
602+ validation_report = g .cbd (result )
603+ return PathClassCount (focus , validation_report , g , path , minc , maxc , classname ) # type: ignore[arg-type]
604+
605+
606+ def _detect_path_shape_count (
607+ g : Graph , result : Node , focus : Optional [URIRef ]
608+ ) -> Optional ["PathShapeCount" ]:
609+ """Detect missing related entities that must conform to a node shape along a path."""
610+ if not focus :
611+ return None
612+ shapepath = SH ["node" ] | (SH .qualifiedValueShape / SH ["node" ]) # type: ignore
613+ path = g .value (result , SH .resultPath )
614+ shapename = g .value (result , SH .sourceShape / shapepath ) # type: ignore
615+ if not (path and shapename ):
616+ return None
617+ min_count_lit = g .value (result , SH .sourceShape / (SH .minCount | SH .qualifiedMinCount )) # type: ignore
618+ max_count_lit = g .value (result , SH .sourceShape / (SH .maxCount | SH .qualifiedMaxCount )) # type: ignore
619+ minc = _to_int_maybe (min_count_lit )
620+ maxc = _to_int_maybe (max_count_lit )
621+ if minc is None and maxc is None :
622+ return None
623+ extra_body , deps = get_template_parts_from_shape (shapename , g ) # type: ignore[arg-type]
624+ validation_report = g .cbd (result )
625+ return PathShapeCount (
626+ focus ,
627+ validation_report ,
628+ g ,
629+ path , # type: ignore[arg-type]
630+ minc ,
631+ maxc ,
632+ shapename , # type: ignore[arg-type]
633+ extra_body ,
634+ tuple (deps ) if deps else None ,
635+ )
636+
637+
638+ def _detect_required_path (
639+ g : Graph , result : Node , focus : Optional [URIRef ]
640+ ) -> Optional ["RequiredPath" ]:
641+ """Detect a missing path with min/max constraints and no specific class/shape requirement."""
642+ if not focus :
643+ return None
644+ path = g .value (result , SH .resultPath )
645+ if not path :
646+ return None
647+ min_count_lit = g .value (result , SH .sourceShape / (SH .minCount | SH .qualifiedMinCount )) # type: ignore
648+ max_count_lit = g .value (result , SH .sourceShape / (SH .maxCount | SH .qualifiedMaxCount )) # type: ignore
649+ minc = _to_int_maybe (min_count_lit )
650+ maxc = _to_int_maybe (max_count_lit )
651+ if minc is None and maxc is None :
652+ return None
653+ validation_report = g .cbd (result )
654+ return RequiredPath (
655+ focus ,
656+ validation_report ,
657+ g ,
658+ path , # type: ignore[arg-type]
659+ minc ,
660+ maxc ,
661+ )
662+
663+
543664@dataclass
544665class ValidationContext :
545666 """Holds the necessary information for processing the results of SHACL
@@ -629,139 +750,50 @@ def get_reasons_with_severity(
629750 def _report_to_diffset (self ) -> Dict [Optional [URIRef ], Set [GraphDiff ]]:
630751 """Interpret a SHACL validation report and say what is missing.
631752
632- :return: a set of GraphDiffs that each abstract a SHACL shape violation
633- :rtype: Set[GraphDiff]
753+ This implementation is organized as a sequence of small, focused detectors
754+ that each attempt to interpret a single ValidationResult node as a specific
755+ GraphDiff. The first matching detector wins for a given result.
634756 """
635- classpath = SH ["class" ] | (SH .qualifiedValueShape / SH ["class" ]) # type: ignore
636- shapepath = SH ["node" ] | (SH .qualifiedValueShape / SH ["node" ]) # type: ignore
637- # TODO: for future use
638- # proppath = SH["property"] | (SH.qualifiedValueShape / SH["property"]) # type: ignore
639-
640757 g = self .report + self .shapes_graph
641758 diffs : Dict [Optional [URIRef ], Set [GraphDiff ]] = defaultdict (set )
642759
643760 for result in g .objects (predicate = SH .result ):
644- # check if the failure is due to our count constraint component
645761 focus = g .value (result , SH .focusNode )
646- # get the subgraph corresponding to this ValidationReport -- see
647- # https://www.w3.org/TR/shacl/#results-validation-result for details
648- # on the structure and expected properties
649- validation_report = g .cbd (result )
650762
651- # First, detect the qualifiedValueShape + qualifiedMinCount pattern.
652- # Even when the engine reports a ClassConstraintComponent on a value node,
653- # the actionable fix is typically to add a new related entity of the required class.
654- qvs_match = _detect_qvs_class_min_pattern (g , result )
655- if qvs_match and focus :
656- path , minc_i , maxc_i , classname = qvs_match
657- diffs [focus ].add (
658- PathClassCount (
659- focus ,
660- validation_report ,
661- g ,
662- path ,
663- minc_i ,
664- maxc_i ,
665- classname ,
666- )
667- )
668- # We handled this result explicitly; move to the next one.
763+ # 1) Graph-level class cardinality (custom CONSTRAINT component)
764+ graph_card = _detect_graph_class_cardinality (g , result )
765+ if graph_card is not None :
766+ diffs [None ].add (graph_card )
767+ continue
768+
769+ # 2) Path to class (handles qualifiedValueShape + qualifiedMinCount pattern)
770+ pcc = _detect_path_class_count (g , result , focus )
771+ if pcc is not None :
772+ diffs [focus ].add (pcc )
773+ continue
774+
775+ # 3) Path to node shape
776+ psc = _detect_path_shape_count (g , result , focus )
777+ if psc is not None :
778+ diffs [focus ].add (psc )
669779 continue
670- if (
671- g .value (result , SH .sourceConstraintComponent )
672- == CONSTRAINT .countConstraintComponent
673- ):
674- expected_count = g .value (
675- result , SH .sourceShape / CONSTRAINT .exactCount # type: ignore
676- )
677- of_class = g .value (result , SH .sourceShape / CONSTRAINT ["class" ]) # type: ignore
678- # here, our 'self.focus' is the graph itself, which we don't want to have bound
679- # to the templates during evaluation (for this specific kind of diff).
680- # For this reason we override focus to be None
681- diffs [None ].add (
682- GraphClassCardinality (
683- None , validation_report , g , of_class , int (expected_count )
684- )
685- )
686- elif (
687- g .value (result , SH .sourceConstraintComponent )
688- == SH .ClassConstraintComponent
689- ):
690- requiring_shape = g .value (result , SH .sourceShape )
691- expected_class = g .value (requiring_shape , SH ["class" ])
692- if expected_class is None or isinstance (expected_class , BNode ):
693- continue
694- diffs [focus ].add (
695- RequiredClass (focus , validation_report , g , expected_class )
696- )
697- elif (
698- g .value (result , SH .sourceConstraintComponent )
699- == SH .NodeConstraintComponent
700- ):
701- # TODO: handle node constraint components
702- pass
703- # check if property shape
704- elif g .value (result , SH .resultPath ):
705- path = g .value (result , SH .resultPath )
706- min_count = g .value (
707- result , SH .sourceShape / (SH .minCount | SH .qualifiedMinCount ) # type: ignore
708- )
709- max_count = g .value (
710- result , SH .sourceShape / (SH .maxCount | SH .qualifiedMaxCount ) # type: ignore
711- )
712- classname = g .value (
713- result ,
714- SH .sourceShape / classpath ,
715- )
716780
717- # TODO: finish this for some shapes
718- # shapes_of_object = g.value(result, SH.sourceShape / SH.qualifiedValueShape)
719- # for soo in shapes_of_object:
720- # soo_graph = g.cbd(soo)
721- # handle properties (on qualifiedValueShapes?)
722- # extra = g.value(result, SH.sourceShape / proppath) # type: ignore
723-
724- if focus and (min_count or max_count ) and classname :
725- diffs [focus ].add (
726- PathClassCount (
727- focus ,
728- validation_report ,
729- g ,
730- path ,
731- int (min_count ) if min_count else None ,
732- int (max_count ) if max_count else None ,
733- classname ,
734- )
735- )
736- continue
737- shapename = g .value (result , SH .sourceShape / shapepath ) # type: ignore
738- if focus and (min_count or max_count ) and shapename :
739- extra_body , deps = get_template_parts_from_shape (shapename , g )
740- diffs [focus ].add (
741- PathShapeCount (
742- focus ,
743- validation_report ,
744- g ,
745- path ,
746- int (min_count ) if min_count else None ,
747- int (max_count ) if max_count else None ,
748- shapename ,
749- extra_body ,
750- tuple (deps ),
751- )
752- )
753- continue
754- if focus and (min_count or max_count ):
755- diffs [focus ].add (
756- RequiredPath (
757- focus ,
758- validation_report ,
759- g ,
760- path ,
761- int (min_count ) if min_count else None ,
762- int (max_count ) if max_count else None ,
763- )
764- )
781+ # 4) Required path only (min/max count without class/shape requirement)
782+ rp = _detect_required_path (g , result , focus )
783+ if rp is not None :
784+ diffs [focus ].add (rp )
785+ continue
786+
787+ # 5) Required class on the (focus or value) node
788+ rc = _detect_required_class (g , result , focus )
789+ if rc is not None :
790+ diffs [focus ].add (rc )
791+ continue
792+
793+ # 6) NodeConstraintComponent handling (TODO)
794+ if g .value (result , SH .sourceConstraintComponent ) == SH .NodeConstraintComponent :
795+ # Currently unhandled; reserved for future expansion
796+ continue
765797
766798 # TODO: this is still kind of broken...ideally we would actually interpret the shapes
767799 # inside the or clause
0 commit comments