Skip to content

Commit 839bd5e

Browse files
authored
feat: Support RDF blank nodes in attribute editor for fixed and default values (#22, RDFA-321)
Added RDF blank-node support for attribute `cims:isFixed` / `cims:isDefault` values, in addition to literals. Signed-off-by: Jan-Hendrik Spahn <jan-hendrik.spahn@soptim.de>
1 parent ef1d6ad commit 839bd5e

24 files changed

Lines changed: 1037 additions & 229 deletions

File tree

backend/src/main/java/org/rdfarchitect/api/dto/attributes/AttributeMapper.java

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
package org.rdfarchitect.api.dto.attributes;
1919

20+
import org.apache.jena.datatypes.TypeMapper;
2021
import org.mapstruct.Mapper;
2122
import org.mapstruct.Mapping;
2223
import org.mapstruct.factory.Mappers;
@@ -31,6 +32,7 @@
3132
import org.rdfarchitect.models.cim.data.dto.relations.RDFSLabel;
3233
import org.rdfarchitect.models.cim.data.dto.relations.datatype.CIMSDataType;
3334
import org.rdfarchitect.models.cim.data.dto.relations.uri.URI;
35+
import org.rdfarchitect.shacl.XSDDatatypeMapper;
3436

3537
import java.util.List;
3638

@@ -94,18 +96,14 @@ default CIMSIsFixed buildFixedValue(AttributeDTO dto) {
9496
if (dto.getFixedValue() == null) {
9597
return null;
9698
}
97-
return new CIMSIsFixed(
98-
dto.getFixedValue(),
99-
new URI(dto.getDataType().getPrefix() + dto.getDataType().getLabel()));
99+
return new CIMSIsFixed(dto.getFixedValue(), buildXsdDatatype(dto));
100100
}
101101

102102
default CIMSIsDefault buildDefaultValue(AttributeDTO dto) {
103103
if (dto.getDefaultValue() == null) {
104104
return null;
105105
}
106-
return new CIMSIsDefault(
107-
dto.getDefaultValue(),
108-
new URI(dto.getDataType().getPrefix() + dto.getDataType().getLabel()));
106+
return new CIMSIsDefault(dto.getDefaultValue(), buildXsdDatatype(dto));
109107
}
110108

111109
default CIMSDataType buildDataType(DataTypeDTO dto) {
@@ -114,4 +112,24 @@ default CIMSDataType buildDataType(DataTypeDTO dto) {
114112
new RDFSLabel(dto.getLabel(), "en"),
115113
CIMSDataType.Type.valueOf(dto.getType().toString()));
116114
}
115+
116+
/**
117+
* Resolves the canonical XSD datatype URI for an attribute fixed/default value. Only primitive
118+
* datatypes map to an XSD URI; for non-primitive types (or unknown labels) the value is
119+
* persisted without a typed datatype, leaving the type to be resolved server-side by {@link
120+
* org.rdfarchitect.models.cim.queries.update.AttributeFixedDefaultResolver}.
121+
*/
122+
private static URI buildXsdDatatype(AttributeDTO dto) {
123+
if (dto.getDataType() == null || dto.getDataType().getLabel() == null) {
124+
return null;
125+
}
126+
if (dto.getDataType().getType() != DataTypeDTO.Type.PRIMITIVE) {
127+
return null;
128+
}
129+
var datatype = XSDDatatypeMapper.classLabelToDatatype(dto.getDataType().getLabel());
130+
if (TypeMapper.getInstance().getTypeByName(datatype.getURI()) == null) {
131+
return null;
132+
}
133+
return new URI(datatype.getURI());
134+
}
117135
}

backend/src/main/java/org/rdfarchitect/database/inmemory/InMemorySparqlExecutor.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,19 @@
2525
import org.apache.jena.query.ResultSet;
2626
import org.apache.jena.query.ResultSetFactory;
2727
import org.apache.jena.query.TxnType;
28-
import org.apache.jena.update.Update;
2928
import org.apache.jena.update.UpdateExecutionFactory;
29+
import org.apache.jena.update.UpdateRequest;
3030
import org.rdfarchitect.rdf.graph.wrapper.GraphRewindableWithUUIDs;
3131

3232
@UtilityClass
3333
public class InMemorySparqlExecutor {
3434

35+
/**
36+
* Opens a single WRITE transaction on {@code graph} and runs {@code update}. Use this only when
37+
* the {@link UpdateRequest} can be fully prepared <em>before</em> the transaction opens.
38+
*/
3539
public void executeSingleUpdate(
36-
GraphRewindableWithUUIDs graph, Update update, String graphUri) {
40+
GraphRewindableWithUUIDs graph, UpdateRequest update, String graphUri) {
3741
try {
3842
graph.begin(TxnType.WRITE);
3943
var dataset = SessionDataStore.wrapGraphInDataset(graph, graphUri);

backend/src/main/java/org/rdfarchitect/models/cim/CIMQuerySolutionParser.java

Lines changed: 48 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121

2222
import org.apache.jena.graph.Node;
2323
import org.apache.jena.query.QuerySolution;
24+
import org.apache.jena.rdf.model.Literal;
2425
import org.apache.jena.rdf.model.RDFNode;
25-
import org.apache.jena.rdf.model.Resource;
2626
import org.rdfarchitect.models.cim.data.dto.relations.CIMSAssociationUsed;
2727
import org.rdfarchitect.models.cim.data.dto.relations.CIMSBelongsToCategory;
2828
import org.rdfarchitect.models.cim.data.dto.relations.CIMSInverseRoleName;
@@ -39,7 +39,6 @@
3939
import org.rdfarchitect.models.cim.data.dto.relations.datatype.RDFSRange;
4040
import org.rdfarchitect.models.cim.data.dto.relations.uri.URI;
4141

42-
import java.util.AbstractMap;
4342
import java.util.UUID;
4443

4544
/**
@@ -163,73 +162,72 @@ public CIMSInverseRoleName getInverseRoleName(String inverseRoleNameVar) {
163162
}
164163

165164
/**
166-
* Extracts the {@link CIMSIsDefault} from the query solution.
165+
* Extracts the {@link CIMSIsDefault} from the query solution. The outer variable is bound
166+
* either directly to the literal or to a blank-node wrapper; in the blank-node case the inner
167+
* variable holds the wrapper's {@code rdfs:Literal} object (see {@link
168+
* org.rdfarchitect.models.cim.queries.select.CIMQueryBuilder#appendIsDefaultQuery}).
167169
*
168-
* @param isDefaultVar The variable name of the isDefault.
169-
* @return The is default or null, if the given variables doesn't exist in the solution.
170+
* @param isDefaultVar Outer variable name (literal or blank node).
171+
* @param isDefaultInnerVar Inner variable name (literal inside the blank-node wrapper).
172+
* @return The is default or {@code null} when the outer variable is not bound.
170173
*/
171-
public CIMSIsDefault getIsDefault(String isDefaultVar) {
172-
if (!qs.contains(isDefaultVar)) {
174+
public CIMSIsDefault getIsDefault(String isDefaultVar, String isDefaultInnerVar) {
175+
var parsed = parseValueNode(isDefaultVar, isDefaultInnerVar);
176+
if (parsed == null) {
173177
return null;
174178
}
175-
var isDefaultRDFNode = qs.get(isDefaultVar);
176-
var tuple = getValueDatatypePair(isDefaultRDFNode);
177-
return new CIMSIsDefault(tuple.getKey(), tuple.getValue());
179+
return new CIMSIsDefault(parsed.value(), parsed.dataType(), parsed.blankNode());
178180
}
179181

180182
/**
181-
* Extracts the {@link CIMSIsFixed} from the query solution.
183+
* Extracts the {@link CIMSIsFixed} from the query solution. The outer variable is bound either
184+
* directly to the literal or to a blank-node wrapper; in the blank-node case the inner variable
185+
* holds the wrapper's {@code rdfs:Literal} object (see {@link
186+
* org.rdfarchitect.models.cim.queries.select.CIMQueryBuilder#appendIsFixedQuery}).
182187
*
183-
* @param isFixedVar The variable name of the isFixed.
184-
* @return The is fixed or null, if the given variables doesn't exist in the solution.
188+
* @param isFixedVar Outer variable name (literal or blank node).
189+
* @param isFixedInnerVar Inner variable name (literal inside the blank-node wrapper).
190+
* @return The is fixed or {@code null} when the outer variable is not bound.
185191
*/
186-
public CIMSIsFixed getIsFixed(String isFixedVar) {
187-
if (!qs.contains(isFixedVar)) {
192+
public CIMSIsFixed getIsFixed(String isFixedVar, String isFixedInnerVar) {
193+
var parsed = parseValueNode(isFixedVar, isFixedInnerVar);
194+
if (parsed == null) {
188195
return null;
189196
}
190-
var isFixedRDFNode = qs.get(isFixedVar);
191-
var tuple = getValueDatatypePair(isFixedRDFNode);
192-
return new CIMSIsFixed(tuple.getKey(), tuple.getValue());
197+
return new CIMSIsFixed(parsed.value(), parsed.dataType(), parsed.blankNode());
193198
}
194199

200+
private record ParsedValueNode(String value, URI dataType, boolean blankNode) {}
201+
195202
/**
196-
* Helper method to extract the value and datatype of a RDFNode.
203+
* Resolves an attribute value-node from its outer/inner variable bindings:
197204
*
198-
* @param node The RDFNode to extract the value and datatype from.
199-
* @return A {@link AbstractMap.SimpleEntry} with the value as key and the datatype as value.
205+
* <ul>
206+
* <li>outer literal → direct shape, datatype taken from the literal,
207+
* <li>outer blank node + bound inner literal → blank-node shape, datatype from the inner
208+
* literal,
209+
* <li>outer blank node without inner literal → ignored (malformed wrapper),
210+
* <li>outer not bound → {@code null}.
211+
* </ul>
200212
*/
201-
private AbstractMap.SimpleEntry<String, URI> getValueDatatypePair(RDFNode node) {
202-
URI datatype = null;
203-
String value = null;
204-
if (node.isLiteral()) {
205-
value = node.asNode().getLiteralLexicalForm();
206-
var dataTypeUri = node.asNode().getLiteralDatatypeURI();
207-
datatype = dataTypeUri.isEmpty() ? null : new URI(dataTypeUri);
213+
private ParsedValueNode parseValueNode(String outerVar, String innerVar) {
214+
if (!qs.contains(outerVar)) {
215+
return null;
216+
}
217+
RDFNode outer = qs.get(outerVar);
218+
if (outer.isLiteral()) {
219+
return fromLiteral(outer.asLiteral(), false);
208220
}
209-
var tuple = getPredicateObjectPair(node);
210-
value = tuple.getKey() != null ? tuple.getKey() : value;
211-
datatype = tuple.getValue() != null ? tuple.getValue() : datatype;
212-
return new AbstractMap.SimpleEntry<>(value, datatype);
221+
if (outer.isAnon() && qs.contains(innerVar) && qs.get(innerVar).isLiteral()) {
222+
return fromLiteral(qs.get(innerVar).asLiteral(), true);
223+
}
224+
return null;
213225
}
214226

215-
/**
216-
* Helper method to extract the first predicate and object of a blankNode.
217-
*
218-
* @param node The RDFNode to extract the predicate and object from.
219-
* @return A {@link AbstractMap.SimpleEntry} with the predicate as key and the object as value.
220-
*/
221-
private AbstractMap.SimpleEntry<String, URI> getPredicateObjectPair(RDFNode node) {
222-
URI datatype = null;
223-
String value = null;
224-
if (node.isAnon()) {
225-
var it = ((Resource) node).listProperties();
226-
if (it.hasNext()) {
227-
var stmt = it.next().asTriple();
228-
value = stmt.getObject().toString();
229-
datatype = new URI(stmt.getPredicate().toString());
230-
}
231-
}
232-
return new AbstractMap.SimpleEntry<>(value, datatype);
227+
private ParsedValueNode fromLiteral(Literal literal, boolean blankNode) {
228+
var dataTypeUri = literal.getDatatypeURI();
229+
var dataType = dataTypeUri == null || dataTypeUri.isEmpty() ? null : new URI(dataTypeUri);
230+
return new ParsedValueNode(literal.getLexicalForm(), dataType, blankNode);
233231
}
234232

235233
/**

backend/src/main/java/org/rdfarchitect/models/cim/data/CIMObjectFactory.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ public static CIMClass createCIMClass(QuerySolution classQuerySolution) {
8888
}
8989

9090
/**
91-
* Creates a {@link CIMAttribute} from a given query solution.
91+
* Creates a {@link CIMAttribute} from a given query solution. The query is expected to bind
92+
* both the outer and the inner value-node variables for {@code isFixed} / {@code isDefault} so
93+
* blank-node value wrappers can be resolved without a separate model lookup.
9294
*
9395
* @param querySolution The query solution to create the attribute from.
9496
* @return The created attribute.
@@ -110,8 +112,9 @@ public static CIMAttribute createCIMAttribute(QuerySolution querySolution) {
110112
.dataType(dataType)
111113
.stereotype(parser.getStereotype(CIMQueryVars.STEREOTYPE))
112114
.comment(parser.getComment(CIMQueryVars.COMMENT))
113-
.fixedValue(parser.getIsFixed(CIMQueryVars.IS_FIXED))
114-
.defaultValue(parser.getIsDefault(CIMQueryVars.IS_DEFAULT))
115+
.fixedValue(parser.getIsFixed(CIMQueryVars.IS_FIXED, CIMQueryVars.IS_FIXED_INNER))
116+
.defaultValue(
117+
parser.getIsDefault(CIMQueryVars.IS_DEFAULT, CIMQueryVars.IS_DEFAULT_INNER))
115118
.build();
116119
}
117120

backend/src/main/java/org/rdfarchitect/models/cim/data/CIMObjectFetcher.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,10 @@ public List<CIMClass> fetchCIMClassList(Query query) {
137137
}
138138

139139
/**
140-
* Fetches a List of {@link CIMAttribute CIMAttributes}.
140+
* Fetches a List of {@link CIMAttribute CIMAttributes}. Blank-node fixed/default values are
141+
* resolved by the SPARQL query itself (see {@link
142+
* org.rdfarchitect.models.cim.queries.select.CIMQueryBuilder#appendIsFixedQuery}), so the
143+
* factory only needs the {@link ResultSet}.
141144
*
142145
* @param query {@link Query} to fetch attributes.
143146
* @return List of {@link CIMAttribute CIMAttributes}.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright (c) 2024-2026 SOPTIM AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
18+
package org.rdfarchitect.models.cim.data.dto.relations;
19+
20+
import lombok.AccessLevel;
21+
import lombok.AllArgsConstructor;
22+
import lombok.Data;
23+
import lombok.NoArgsConstructor;
24+
25+
import org.apache.jena.datatypes.TypeMapper;
26+
import org.apache.jena.rdf.model.Literal;
27+
import org.apache.jena.rdf.model.ResourceFactory;
28+
import org.rdfarchitect.models.cim.data.dto.relations.uri.URI;
29+
30+
/**
31+
* Shared base for attribute value nodes such as {@link CIMSIsFixed} and {@link CIMSIsDefault}.
32+
*
33+
* <p>Holds the literal lexical {@code value}, an optional XSD {@code dataType} and a {@code
34+
* blankNode} flag indicating whether the value should be persisted as an RDF blank-node wrapper
35+
* rather than as a direct literal.
36+
*/
37+
@Data
38+
@NoArgsConstructor
39+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
40+
public abstract class AttributeValueNode {
41+
42+
private String value;
43+
44+
private URI dataType;
45+
46+
private boolean blankNode;
47+
48+
public Literal asLiteral() {
49+
if (dataType == null) {
50+
return ResourceFactory.createPlainLiteral(value);
51+
}
52+
return ResourceFactory.createTypedLiteral(
53+
value, TypeMapper.getInstance().getSafeTypeByName(dataType.toString()));
54+
}
55+
}

backend/src/main/java/org/rdfarchitect/models/cim/data/dto/relations/CIMSIsDefault.java

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,33 +17,26 @@
1717

1818
package org.rdfarchitect.models.cim.data.dto.relations;
1919

20-
import lombok.AllArgsConstructor;
21-
import lombok.Data;
20+
import lombok.EqualsAndHashCode;
2221
import lombok.NoArgsConstructor;
22+
import lombok.ToString;
2323

24-
import org.apache.jena.datatypes.TypeMapper;
25-
import org.apache.jena.rdf.model.Literal;
26-
import org.apache.jena.rdf.model.ResourceFactory;
2724
import org.rdfarchitect.models.cim.data.dto.relations.uri.URI;
2825

29-
@Data
30-
@AllArgsConstructor
26+
@EqualsAndHashCode(callSuper = true)
27+
@ToString(callSuper = true)
3128
@NoArgsConstructor
32-
public class CIMSIsDefault {
29+
public class CIMSIsDefault extends AttributeValueNode {
3330

3431
public CIMSIsDefault(String value) {
35-
this.value = value;
32+
this(value, null, false);
3633
}
3734

38-
private String value;
39-
40-
private URI dataType;
35+
public CIMSIsDefault(String value, URI dataType) {
36+
this(value, dataType, false);
37+
}
4138

42-
public Literal asLiteral() {
43-
if (dataType == null) {
44-
return ResourceFactory.createPlainLiteral(value);
45-
}
46-
return ResourceFactory.createTypedLiteral(
47-
value, TypeMapper.getInstance().getSafeTypeByName(dataType.toString()));
39+
public CIMSIsDefault(String value, URI dataType, boolean blankNode) {
40+
super(value, dataType, blankNode);
4841
}
4942
}

0 commit comments

Comments
 (0)