Skip to content
Open
181 changes: 181 additions & 0 deletions graphly-pr-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Guide PR Graphly (Logre fixes)

Ce document decrit, de maniere detaillee, les modifications a appliquer dans Graphly pour preparer le PR. Il est destine a l agent de codage charge d implementer les corrections.

Objectif general
- Aligner Graphly upstream avec les correctifs necessaires a Logre.
- Supprimer la dependance a une copie locale vendoree dans Logre.
- Garantir un comportement stable pour SHACL, export Turtle et AllegroGraph.

Perimetre
- Fichiers cibles dans Graphly:
- `graphly/models/shacl.py`
- `graphly/schema/graph.py`
- `graphly/sparql/allegrograph.py`
- Aucun changement d API publique, uniquement robustesse et comportement correct.

Pre-requis
- Repo Graphly clone localement.
- Branche dediee (ex: `logre-compat-fixes`).

---

1) SHACL: separation range_class_uri / range_datatype

Contexte
- Dans Logre, certaines shapes utilisent `sh:datatype` pour definir un range literal.
- L implementation actuelle fusionne `datatype` dans `range_class_uri` ce qui perturbe la resolution des classes.

Objectif
- Conserver la valeur `range_class_uri` pour les classes, et gerer `range_datatype` a part.
- Choisir `range_target` comme `range_uri` (classe) ou `range_datatype` (datatype) si present.

Fichier cible
- `graphly/models/shacl.py`

Modifications a faire
1. Dans la requete SPARQL de `get_properties`, ajouter un champ `range_datatype`:

Avant
```python
(COALESCE(?range_class_uri_, ?datatype_, '') as ?range_class_uri)
```

Apres
```python
(COALESCE(?range_class_uri_, '') as ?range_class_uri)
(COALESCE(?datatype_, '') as ?range_datatype)
```

2. Dans la boucle Python, recuperer `range_datatype` et ajuster le calcul du range:

Avant
```python
range_uri = resp.get('range_class_uri')
range = self.find_class(range_uri) if range_uri else None
```

Apres
```python
range_uri = resp.get('range_class_uri')
range_datatype = resp.get('range_datatype')
range_target = range_uri or range_datatype
range = self.find_class(range_target) if range_target else None
```

Remarque
- On ne change pas la signature des classes ni la structure des objets `Property`.

---

2) Graph: dump_turtle plus robuste

Contexte
- Certains endpoints renvoient des objets non str (ou des objets with __str__).
- `dump_turtle` doit proteger contre ces variations sans casser les blank nodes.

Objectif
- Forcer `str()` pour `s`, `p`, `o` avant de passer dans `prepare`.
- Conserver la logique des blank nodes.

Fichier cible
- `graphly/schema/graph.py`

Modifications a faire
Dans `dump_turtle`, remplacer la construction de `s`, `p`, `o` par la version suivante:

```python
subj = str(triple['s'])
obj = triple['o']

if subj.startswith('_:'):
s = subj
elif triple['s_is_blank'] == 'true':
s = f"_:{subj}"
else:
s = prepare(subj, prefixes.shorts())

p = prepare(str(triple['p']), prefixes.shorts())

obj_str = str(obj)
if obj_str.startswith('_:'):
o = obj_str
elif triple['o_is_blank'] == 'true':
o = f"_:{obj_str}"
else:
o = prepare(obj, prefixes.shorts())
```

Remarque
- Ne pas changer `dump_nquad` (la logique reste ok).

---

3) AllegroGraph: prefixes et mutation

Contexte
- AllegroGraph requiert les prefixes `franz` + `franzOption_defaultDatasetBehavior`.
- L implementation actuelle mute `Prefixes` en place, ce qui peut avoir des effets de bord.
- `insert`/`delete` ne transmettent pas `prefixes`.

Objectif
- Toujours ajouter les prefixes requis sans muter l objet partage.
- Ajouter le prefix `franz` (namespace complet) en plus du prefix option.
- Propager `prefixes` dans `insert` et `delete`.

Fichier cible
- `graphly/sparql/allegrograph.py`

Modifications a faire
1. Definir les prefixes requis:

```python
franz_prefix = Prefix('franz', 'http://franz.com/ns/allegrograph/7.0/')
additional_prefix = Prefix('franzOption_defaultDatasetBehavior', 'franz:rdf')
```

2. Mettre a jour `run` pour travailler sur une copie:

```python
required_prefixes = [self.franz_prefix, self.additional_prefix]
if prefixes is None:
local_prefixes = Prefixes(required_prefixes.copy())
else:
local_prefixes = Prefixes(prefixes.prefix_list.copy())
for prefix in required_prefixes:
if not local_prefixes.has(prefix.short):
local_prefixes.add(prefix)
return super().run(text, local_prefixes)
```

3. Mettre a jour `insert` pour propager `prefixes`:

```python
def insert(self, triples, graph_uri=None, prefixes=None):
self.delete(triples, graph_uri, prefixes)
super().insert(triples, graph_uri, prefixes)
```

Remarques
- Laisser `delete` tel quel si sa signature accepte `prefixes` dans la classe parente.
- Conserver `technology_name = 'Allegrograph'`.

---

Verification minimale
- Lint basique (si present) ou au moins import des modules modifies.
- Pas de tests automatise requis, mais verifier que les imports restent valides.

Message de commit propose
- `Fix SHACL range datatype handling and AllegroGraph prefixes`

PR description (synthese)
- Stabilise SHACL range handling by separating datatype and class range.
- Harden Graph.dump_turtle string casting for blank nodes.
- Ensure AllegroGraph required prefixes without mutating shared Prefixes.

---

Notes
- Aucun changement dans Logre ici; ce PR vise uniquement Graphly.
- Ces correctifs doivent ensuite etre pinnees dans Logre (requirements) jusqu au merge upstream.
95 changes: 62 additions & 33 deletions graphly/models/shacl.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,20 @@ class SHACL(Model):
get_classes: Retrieves SHACL-defined classes from a given RDF graph.
get_properties: Retrieves SHACL-defined properties from a given RDF graph.
"""


def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None, type_property: str = 'rdf:type', label_property: str = "rdfs:label", comment_property: str = "rdfs:comment") -> None:
def __init__(
self,
sparql: Sparql | None = None,
uri: str = None,
prefixes: Prefixes = None,
type_property: str = "rdf:type",
label_property: str = "rdfs:label",
comment_property: str = "rdfs:comment",
) -> None:
"""
Initialize a SHACL-based Model instance with default or custom property identifiers.

This constructor sets the framework name to "SHACL" and delegates the
This constructor sets the framework name to "SHACL" and delegates the
initialization of type, label, and comment properties to the base Model class.

Args:
Expand All @@ -43,22 +50,23 @@ def __init__(self, sparql: Sparql, uri: str = None, prefixes: Prefixes = None, t
comment_property (str, optional): The property used to define entity comments or descriptions. Defaults to 'rdfs:comment'.
"""
self.framework_name = "SHACL"
super().__init__(sparql, uri, prefixes, type_property, label_property, comment_property)
super().__init__(
sparql, uri, prefixes, type_property, label_property, comment_property
)


def get_classes(self) -> List[Resource]:
"""
Retrieve SHACL-defined classes from the given RDF graph.

Constructs and executes a SPARQL query to find all `sh:NodeShape` nodes
and their associated target classes, extracting both the class URI and
Constructs and executes a SPARQL query to find all `sh:NodeShape` nodes
and their associated target classes, extracting both the class URI and
optional label. The results are returned as a list of `Resource` instances.

Args:

Returns:
List[Resource]: A list of `Resource` objects representing the SHACL-defined
classes, each with a `class_uri` of "owl:Class". Returns an empty list if
List[Resource]: A list of `Resource` objects representing the SHACL-defined
classes, each with a `class_uri` of "owl:Class". Returns an empty list if
no classes are found.
"""
# Prepare the query
Expand All @@ -80,28 +88,31 @@ def get_classes(self) -> List[Resource]:
response = self.run(query)

# Transform into a list of Resource instances, or an empty list
classes = [Resource.from_dict({**obj, "class_uri": "owl:Class"}) for obj in response] if response else []
classes = (
[Resource.from_dict({**obj, "class_uri": "owl:Class"}) for obj in response]
if response
else []
)

# Add Value classes
classes += super().get_value_classes()

return classes


def get_properties(self) -> List[Property]:
"""
Retrieve SHACL-defined properties from the given RDF graph.

Constructs and executes a SPARQL query to extract properties defined
via SHACL shapes, including their target classes, labels, order,
minimum and maximum counts, domain, and range. The results are returned
Constructs and executes a SPARQL query to extract properties defined
via SHACL shapes, including their target classes, labels, order,
minimum and maximum counts, domain, and range. The results are returned
as a list of `Property` instances.

Args:

Returns:
List[Property]: A list of `Property` objects representing the SHACL-defined
properties, including domain and range class information. Returns an empty
List[Property]: A list of `Property` objects representing the SHACL-defined
properties, including domain and range class information. Returns an empty
list if no properties are found.
"""
# Prepare the query
Expand All @@ -115,7 +126,8 @@ def get_properties(self) -> List[Property]:
(COALESCE(?max_count_, '') as ?max_count)
(COALESCE(?domain_class_uri_, '') as ?domain_class_uri)
?uri
(COALESCE(?range_class_uri_, ?datatype_, '') as ?range_class_uri)
(COALESCE(?range_class_uri_, '') as ?range_class_uri)
(COALESCE(?datatype_, '') as ?range_datatype)
WHERE {{
{self.sparql_begin}
?shape sh:property ?node .
Expand All @@ -138,24 +150,41 @@ def get_properties(self) -> List[Property]:

# Execute the query
response = self.run(query)

# Transform into a list of Property instances, or an empty list
properties = []
for resp in response:
# Get domain and range
try:
domain = self.find_class(resp['domain_class_uri'])
range = self.find_class(resp['range_class_uri'])
except:
raise Exception('Graphly was not able to get domain class or range class for the following property: ' + resp['uri'] + ' - ' + resp['label'])

# Get the class from which the property belongs
try:
card_of = self.find_class(resp['card_of_class_uri'])
except:
raise Exception('Graphly was not able to get concerned class (card of class URI) for the following property: ' + resp['uri'] + ' - ' + resp['label'])
uri = resp.get("uri")
if not uri:
continue

# Create and add a new property
properties.append(Property(resp['uri'], resp['label'], "", domain, range, card_of, order=resp['order'], min_count=resp['min_count'], max_count=resp['max_count']))
domain_class_uri = resp.get("domain_class_uri") or ""
range_class_uri = resp.get("range_class_uri") or ""
range_datatype = resp.get("range_datatype") or ""
range_target = range_class_uri or range_datatype

return properties
domain = self.find_class(domain_class_uri) if domain_class_uri else None
range = self.find_class(range_target) if range_target else None

card_of_class_uri = resp.get("card_of_class_uri") or ""
card_of = (
self.find_class(card_of_class_uri) if card_of_class_uri else domain
)

# Create and add a new property
label = resp.get("label") or uri
properties.append(
Property(
uri,
label,
"",
domain,
range,
card_of,
order=resp["order"],
min_count=resp["min_count"],
max_count=resp["max_count"],
)
)

return properties
Loading