From 254502e17f04007b665ef84c308be6d29ed62949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jerem=C3=ADas=20P=C3=A9rez=20Fern=C3=A1ndez?= Date: Sat, 22 Aug 2026 05:57:27 +0000 Subject: [PATCH] fix(ontology): rebuild stale class URIs in OWL/R2RML/Digital Twin generation and persist Constraints tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OntologyGenerator._resolve_uri, R2RMLGenerator and DigitalTwin graph construction all reconstruct a class/property URI from its local name against the domain's current Base URI when the stored URI does not start with it, instead of using the stale URI as-is. This is the same re-basing problem fixed in the reasoning engines (see the companion PR), applied here to: axioms saved from the Expressions & Axioms editor (an axiom referencing a stale class URI no longer matched the class's own owl:Class declaration in the exported OWL, leaving it effectively orphaned); R2RML generation (mapped instances got an rdf:type disconnected from the real class, and their data-property lookup used the wrong URI too); and the Digital Twin graph (instances of a re-based class did not attach to it). R2RMLGenerator additionally reorders TriplesMap generation so the class comment and logical table are built after the class URI is resolved, and looks up data properties using the resolved URI. Adds two endpoints, POST /ontology/constraints/save and POST /ontology/constraints/delete, backing the Designer's Constraints tab (entity disjointWith/equivalentTo; relationship cardinality, functional/inverse-functional/symmetric/transitive). Previously the tab had no endpoint to persist to, so changes made there were never saved. ReasoningService adds _get_constraints(), reading domain.constraints — where the Constraints tab actually stores its data — so a relationship marked Transitive or Symmetric there is picked up by the graph-reasoning pass. Previously graph reasoning only looked at each property's internal characteristics field, which the Constraints tab never wrote to, so marking a relationship Transitive/Symmetric there had no real effect on reasoning even though the UI accepted the setting. --- src/api/routers/internal/ontology.py | 52 +++++++++ src/back/core/reasoning/ReasoningService.py | 121 +++++++++++++++++--- src/back/core/w3c/owl/OntologyGenerator.py | 31 ++++- src/back/core/w3c/r2rml/R2RMLGenerator.py | 60 +++++----- src/back/objects/digitaltwin/DigitalTwin.py | 14 ++- 5 files changed, 232 insertions(+), 46 deletions(-) diff --git a/src/api/routers/internal/ontology.py b/src/api/routers/internal/ontology.py index 7be82191..df5a2501 100644 --- a/src/api/routers/internal/ontology.py +++ b/src/api/routers/internal/ontology.py @@ -418,6 +418,58 @@ async def list_constraints(session_mgr: SessionManager = Depends(get_session_man domain = get_domain(session_mgr) return {"success": True, "constraints": domain.constraints} +@router.post("/constraints/save") +async def save_constraint( + request: Request, session_mgr: SessionManager = Depends(get_session_manager) +): + """Add or update a legacy constraint by index. + + Mirrors the ``rules/{rule_type}/save`` pattern: ``index == -1`` appends a + new constraint, otherwise the constraint at that index is replaced. Used + by the Designer's entity Constraints tab (disjointWith/equivalentTo) and + relationship Constraints tab (cardinality, functional/inverseFunctional/ + symmetric/transitive characteristics). + """ + with map_route_errors("Saving constraint failed", logger): + data = await request.json() + constraint = data.get("constraint", {}) + index = data.get("index", -1) + + if not constraint: + raise ValidationError("Constraint payload is required") + + domain = get_domain(session_mgr) + constraints = list(domain.constraints or []) + + if 0 <= index < len(constraints): + constraints[index] = constraint + else: + constraints.append(constraint) + + domain.constraints = constraints + domain.save() + return {"success": True, "message": "Constraint saved", "constraints": constraints} + +@router.post("/constraints/delete") +async def delete_constraint( + request: Request, session_mgr: SessionManager = Depends(get_session_manager) +): + """Delete a legacy constraint by index.""" + with map_route_errors("Deleting constraint failed", logger): + data = await request.json() + index = data.get("index", -1) + + domain = get_domain(session_mgr) + constraints = list(domain.constraints or []) + + if not (0 <= index < len(constraints)): + raise ValidationError("Invalid constraint index") + + constraints.pop(index) + domain.constraints = constraints + domain.save() + return {"success": True, "message": "Constraint deleted", "constraints": constraints} + # =========================================== # Data Quality (SHACL Shapes) diff --git a/src/back/core/reasoning/ReasoningService.py b/src/back/core/reasoning/ReasoningService.py index 0b7470ec..91131126 100644 --- a/src/back/core/reasoning/ReasoningService.py +++ b/src/back/core/reasoning/ReasoningService.py @@ -277,9 +277,10 @@ def run_graph_reasoning(self, options: Optional[Dict] = None) -> ReasoningResult result = ReasoningResult() table_name = self._get_graph_name() ontology = self._get_ontology_dict() + constraints = self._get_constraints() transitive_props = self._find_properties_by_characteristic( - ontology, "transitive" + ontology, "transitive", constraints ) logger.info( "Graph reasoning: %d transitive properties found%s", @@ -304,7 +305,9 @@ def run_graph_reasoning(self, options: Optional[Dict] = None) -> ReasoningResult except Exception as e: logger.warning("Transitive closure for %s failed: %s", prop_uri, e) - symmetric_props = self._find_properties_by_characteristic(ontology, "symmetric") + symmetric_props = self._find_properties_by_characteristic( + ontology, "symmetric", constraints + ) logger.info( "Graph reasoning: %d symmetric properties found%s", len(symmetric_props), @@ -584,6 +587,28 @@ def _get_ontology_dict(self) -> Dict: return self._domain._data.get("ontology", {}) return {} + def _get_constraints(self) -> List[Dict]: + """Retrieve the domain's legacy/relationship constraints list. + + This is a **separate storage** from ``ontology["properties"][i] + ["characteristics"]``: it's the flat list of ``{"type": ..., + "property": ...}`` dicts kept on ``domain.constraints`` (see + ``DomainSession.constraints``), which is what the Designer's + entity/relationship "Constraints" tabs actually read and write via + ``GET /ontology/constraints/list`` and ``POST /ontology/constraints + /save`` (``ontology-shared-panels.js``'s + ``saveEntityConstraintsToServer`` / ``saveRelationshipConstraintsToServer``). + + ``ontology["properties"][i]["characteristics"]`` is never populated + by that save path, so property-characteristic lookups (used by + Graph reasoning for transitive closure / symmetric expansion) must + also consult this list — see ``_find_properties_by_characteristic``. + """ + constraints = getattr(self._domain, "constraints", None) + if isinstance(constraints, list): + return constraints + return [] + def _get_graph_name(self) -> str: info = getattr(self._domain, "info", None) name = ( @@ -611,26 +636,96 @@ def _normalize_property_uri( @staticmethod def _find_properties_by_characteristic( - ontology: Dict, characteristic: str + ontology: Dict, + characteristic: str, + constraints: Optional[List[Dict]] = None, ) -> List[str]: - """Extract property URIs with *characteristic* (normalized to data namespace).""" + """Extract property URIs that carry *characteristic*, normalized to + the data namespace. + + Property characteristics (Functional, Inverse Functional, Symmetric, + Transitive) can live in **two different places** depending on how + they were saved: + + 1. ``ontology["properties"][i]["characteristics"]`` — a per-property + embedded list. Kept for forward-compatibility / defensiveness, + but as of this version nothing actually writes to it. + 2. ``constraints`` — the flat ``domain.constraints`` list, e.g. + ``{"type": "transitive", "property": "tieneCapital"}``. This is + the storage the Designer's relationship Constraints tab actually + persists to (via ``POST /ontology/constraints/save``), so it's + the authoritative source in practice. Entries are matched by + property name (full name, local name, or URI local name) against + the ontology's ``properties`` list to resolve the full, + namespace-normalized URI. + """ base_uri = ontology.get("base_uri", "") data_ns, sep = ReasoningService._namespace_parts(base_uri) - result = [] target = characteristic.lower() - for prop in ontology.get("properties", []): + result: List[str] = [] + seen = set() + + props = ontology.get("properties", []) + + # Name -> property dict lookup (by name, local name, and URI local + # name, all case-insensitive) so constraint entries — which only + # store a property *name* — can be resolved back to a full property + # dict (and from there, a normalized URI). + by_name: Dict[str, Dict] = {} + for prop in props: + name = prop.get("name", "") or prop.get("localName", "") + if name: + by_name.setdefault(name.lower(), prop) + by_name.setdefault( + ReasoningService._local_name(name).lower(), prop + ) + uri = prop.get("uri", "") + if uri: + by_name.setdefault(ReasoningService._local_name(uri).lower(), prop) + + def _add_prop(prop: Dict) -> None: + name = prop.get("name", "") or prop.get("localName", "") + uri = ReasoningService._normalize_property_uri( + prop.get("uri", ""), data_ns, base_uri, sep, name + ) + if uri and uri not in seen: + seen.add(uri) + result.append(uri) + + # Source 1: characteristics embedded directly on the property dict. + for prop in props: chars = prop.get("characteristics", []) if isinstance(chars, list) and target in [ c.lower() for c in chars if isinstance(c, str) ]: - name = prop.get("name", "") or prop.get("localName", "") + _add_prop(prop) + + # Source 2: the flat domain.constraints list — the actual storage + # populated by the Designer's relationship Constraints tab. + for c in constraints or []: + if not isinstance(c, dict): + continue + if str(c.get("type", "")).lower() != target: + continue + prop_name = c.get("property", "") or c.get("propertyName", "") + if not prop_name: + continue + prop = by_name.get(prop_name.lower()) or by_name.get( + ReasoningService._local_name(prop_name).lower() + ) + if prop: + _add_prop(prop) + else: + # Property not found in the ontology's properties list + # (e.g. stale constraint referencing a renamed/removed + # property) — fall back to building the URI directly from + # the constraint's stored property name so the constraint + # isn't silently dropped. uri = ReasoningService._normalize_property_uri( - prop.get("uri", ""), - data_ns, - base_uri, - sep, - name, + "", data_ns, base_uri, sep, prop_name ) - if uri: + if uri and uri not in seen: + seen.add(uri) result.append(uri) + return result diff --git a/src/back/core/w3c/owl/OntologyGenerator.py b/src/back/core/w3c/owl/OntologyGenerator.py index 25a744a8..06f43d2b 100644 --- a/src/back/core/w3c/owl/OntologyGenerator.py +++ b/src/back/core/w3c/owl/OntologyGenerator.py @@ -168,10 +168,39 @@ def _is_stale_datatype_shadow(self, prop_name: str, domain: str) -> bool: return self._local_name(prop_name).lower() not in class_attrs def _resolve_uri(self, ref: str): - """Convert a name or full URI string to a URIRef, or None if empty.""" + """Convert a name or full URI string to a URIRef, or None if empty. + + Values coming straight from stored ``classes``/``properties`` entries + (as opposed to a bare local name) occasionally carry a full URI under + a namespace that no longer matches the domain's current ``base_uri`` + — e.g. after a rename/rebrand of the ontology's namespace, when only + newly-created entities picked up the new one. Every *other* place in + this generator (``_add_class``, ``_add_property``, + ``_add_data_property_for_class``, ``_add_groups``, ...) sidesteps + that problem entirely by always rebuilding the URI from the entity's + local *name* plus the current ``base_uri`` — it never trusts a + stored ``uri`` field directly. Expressions & Axioms is the one path + that hands this method a full URI straight from the picker (which + reads the class's stored, possibly stale ``uri``), so we normalize + it the same way here: keep the local name, but always re-anchor it + under the CURRENT ``base_uri``. This guarantees an axiom/expression + subject or object always resolves to the exact same URIRef the + class's own ``a owl:Class`` declaration uses, regardless of what + namespace happens to be stored on the class. + + Mirrors the same defensive pattern already used elsewhere in the + app for this exact class of drift (see + ``ReasoningService._normalize_property_uri`` and + ``AggregateRuleEngine._resolve_rule``'s ``uri_map`` construction). + """ if not ref: return None if ref.startswith("http://") or ref.startswith("https://"): + if ref.startswith(self.base_uri): + return URIRef(ref) + local = self._local_name(ref) + if local: + return URIRef(self.base_uri + local) return URIRef(ref) return URIRef(self.base_uri + ref) diff --git a/src/back/core/w3c/r2rml/R2RMLGenerator.py b/src/back/core/w3c/r2rml/R2RMLGenerator.py index dcd72f2e..aa3ee7e9 100644 --- a/src/back/core/w3c/r2rml/R2RMLGenerator.py +++ b/src/back/core/w3c/r2rml/R2RMLGenerator.py @@ -240,27 +240,6 @@ def _add_entity_mapping( triples_map = self._uriref(f"{self.base_uri}TriplesMap_{map_name}") g.add((triples_map, RDF.type, self.rr.TriplesMap)) - # Add comment for clarity - comment = f"Mapping for {class_label or table} to {class_uri}" - g.add((triples_map, RDFS.comment, Literal(comment))) - - # Logical Table - using SQL query or table name - logical_table = BNode(f"lt_{map_name}") - g.add((triples_map, self.rr.logicalTable, logical_table)) - - if sql_query: - # New SQL-based mapping - g.add((logical_table, self.rr.sqlQuery, Literal(sql_query))) - else: - # Legacy table-based mapping - g.add( - ( - logical_table, - self.rr.tableName, - Literal(f"{catalog}.{schema}.{table}"), - ) - ) - # Subject Map subject_map = BNode(f"sm_{map_name}") g.add((triples_map, self.rr.subjectMap, subject_map)) @@ -281,17 +260,40 @@ def _add_entity_mapping( ) # Add class if specified + resolved_class_uri = class_uri if class_uri: + domain_root = self.base_uri.rstrip("/").rstrip("#") if class_uri.startswith("http://") or class_uri.startswith("https://"): - g.add((subject_map, self.rr["class"], self._uriref(class_uri))) + if not class_uri.startswith(domain_root): + # Stale class URI from a previous base_uri — rebuild using '#', + # matching how classes are declared in the OWL ontology. + local = self._extract_local_name(class_uri) + resolved_class_uri = f"{domain_root}#{self._sanitize_name(local)}" else: - g.add( - ( - subject_map, - self.rr["class"], - self._uriref(f"{self.base_uri}{class_uri}"), - ) + resolved_class_uri = f"{self.base_uri}{class_uri}" + + g.add((subject_map, self.rr["class"], self._uriref(resolved_class_uri))) + + # Add comment for clarity + comment = f"Mapping for {class_label or table} to {resolved_class_uri}" + g.add((triples_map, RDFS.comment, Literal(comment))) + + # Logical Table - using SQL query or table name + logical_table = BNode(f"lt_{map_name}") + g.add((triples_map, self.rr.logicalTable, logical_table)) + + if sql_query: + # New SQL-based mapping + g.add((logical_table, self.rr.sqlQuery, Literal(sql_query))) + else: + # Legacy table-based mapping + g.add( + ( + logical_table, + self.rr.tableName, + Literal(f"{catalog}.{schema}.{table}"), ) + ) # Add label column mapping if specified if label_column: @@ -304,7 +306,7 @@ def _add_entity_mapping( g.add((obj_map, self.rr.column, Literal(self._quote_column(label_column)))) # Ontology property-URI lookup for this class - ont_props = (data_prop_uri_lookup or {}).get(class_uri, {}) + ont_props = (data_prop_uri_lookup or {}).get(resolved_class_uri, {}) # Add attribute mappings (DatatypeProperty mappings) if attribute_mappings: diff --git a/src/back/objects/digitaltwin/DigitalTwin.py b/src/back/objects/digitaltwin/DigitalTwin.py index 4f68efb8..db563eb4 100644 --- a/src/back/objects/digitaltwin/DigitalTwin.py +++ b/src/back/objects/digitaltwin/DigitalTwin.py @@ -313,9 +313,17 @@ def augment_mappings_from_config( if class_uri in excluded_class_uris: continue - full_class_uri = ( - class_uri if class_uri.startswith("http") else f"{base_uri}{class_uri}" - ) + full_class_uri = None + domain_root = base_uri.rstrip("/").rstrip("#") + if class_uri.startswith("http://") or class_uri.startswith("https://"): + if class_uri.startswith(domain_root): + full_class_uri = class_uri + else: + # Stale class URI from a previous base_uri — rebuild using '#', + # matching how classes are declared in the OWL ontology. + full_class_uri = f"{domain_root}#{extract_local_name(class_uri)}" + else: + full_class_uri = f"{base_uri}{class_uri}" sanitized_label = DigitalTwin._safe_class_label(class_label, class_uri)