Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/api/routers/internal/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
121 changes: 108 additions & 13 deletions src/back/core/reasoning/ReasoningService.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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),
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
31 changes: 30 additions & 1 deletion src/back/core/w3c/owl/OntologyGenerator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
60 changes: 31 additions & 29 deletions src/back/core/w3c/r2rml/R2RMLGenerator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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:
Expand All @@ -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:
Expand Down
14 changes: 11 additions & 3 deletions src/back/objects/digitaltwin/DigitalTwin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down