Repository: apache/jena (java)

Issue:
ResultSetException: Datatype is rdf:langString but no language given
### Version

5.0.0

### What happened?

I am running a query on the dbpedia.org SPARQL endpoint. It fails while iterating the result set. Works fine in Jena 4.1.0. Perhaps the data is invalid on dbpedia.org, please investigate.

http://dbpedia.org/sparql
`SELECT DISTINCT * WHERE { <http://dbpedia.org/resource/OpenLink_Software> ?p ?o } LIMIT 100`

### Relevant output and stacktrace

```shell
Exception
=========
org.apache.jena.sparql.resultset.ResultSetException: Datatype is rdf:langString but no language given
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming.moveToNext(RowSetJSONStreaming.java:214)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming.moveToNext(RowSetJSONStreaming.java:66)
	at org.apache.jena.atlas.iterator.IteratorSlotted.hasNext(IteratorSlotted.java:63)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetBuffered.nextFromDelegate(RowSetBuffered.java:165)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetBuffered.moveToNext(RowSetBuffered.java:152)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetBuffered.moveToNext(RowSetBuffered.java:43)
	at org.apache.jena.atlas.iterator.IteratorSlotted.hasNext(IteratorSlotted.java:63)
	at org.apache.jena.sparql.engine.ResultSetStream.hasNext(ResultSetStream.java:75)
	at MyTestProgram
Caused by: org.apache.jena.sparql.resultset.ResultSetException: Datatype is rdf:langString but no language given
	at org.apache.jena.riot.rowset.rw.rs_json.IteratorRsJSON.moveToNext(IteratorRsJSON.java:75)
	at org.apache.jena.atlas.iterator.IteratorSlotted.hasNext(IteratorSlotted.java:63)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming.computeNextActual(RowSetJSONStreaming.java:225)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming.moveToNext(RowSetJSONStreaming.java:210)
	... 19 more
Caused by: org.apache.jena.shared.JenaException: Datatype is rdf:langString but no language given
	at org.apache.jena.graph.NodeFactory.createLiteral(NodeFactory.java:240)
	at org.apache.jena.graph.NodeFactory.createLiteral(NodeFactory.java:177)
	at org.apache.jena.sparql.util.NodeFactoryExtra.createLiteralNode(NodeFactoryExtra.java:100)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming.parseOneTerm(RowSetJSONStreaming.java:404)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming.parseBinding(RowSetJSONStreaming.java:358)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming$RsJsonEltEncoderDft.newBindingElt(RowSetJSONStreaming.java:571)
	at org.apache.jena.riot.rowset.rw.rs_json.RowSetJSONStreaming$RsJsonEltEncoderDft.newBindingElt(RowSetJSONStreaming.java:541)
	at org.apache.jena.riot.rowset.rw.rs_json.IteratorRsJSON.computeNextActual(IteratorRsJSON.java:136)
	at org.apache.jena.riot.rowset.rw.rs_json.IteratorRsJSON.moveToNext(IteratorRsJSON.java:72)
	... 22 more
```


### Are you interested in making a pull request?

None

Pull request:
GH-2555: Allow ^^rdf:langString in result sets
GitHub issue resolved #2555

----

 - [x] Tests are included.
 - [x] Commits have been squashed to remove intermediate development commit messages.
 - [x] Key commit messages start with the issue number (GH-xxxx)

By submitting this pull request, I acknowledge that I am making a contribution to the Apache Software Foundation under the terms and conditions of the [Contributor's Agreement](https://www.apache.org/licenses/contributor-agreements.html).


Patch:
diff --git a/jena-arq/pom.xml b/jena-arq/pom.xml
index 0efa9cd1c8..952b52a4ae 100644
--- a/jena-arq/pom.xml
+++ b/jena-arq/pom.xml
@@ -127,6 +127,11 @@
       <scope>test</scope>
     </dependency>
 
+    <dependency>
+      <groupId>org.junit.platform</groupId>
+      <artifactId>junit-platform-suite-engine</artifactId>
+    </dependency>
+
     <dependency>
       <groupId>org.awaitility</groupId>
       <artifactId>awaitility</artifactId>
diff --git a/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReader.java b/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReader.java
index 264f9cf9f6..c45b525488 100644
--- a/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReader.java
+++ b/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReader.java
@@ -21,12 +21,12 @@ package org.apache.jena.riot.rowset;
 import java.io.InputStream;
 import java.io.Reader;
 
+import org.apache.jena.riot.Lang;
 import org.apache.jena.sparql.exec.QueryExecResult;
 import org.apache.jena.sparql.exec.RowSet;
 import org.apache.jena.sparql.util.Context;
 
 public interface RowSetReader {
-
     /**
      * Read from an {@code InputStream} and produce a {@link RowSet}.
      * Note that return row set may stream and so the input stream may be read
@@ -67,4 +67,9 @@ public interface RowSetReader {
     public default RowSet read(Reader in, Context context) {
         throw new UnsupportedOperationException("RowSetReader.read - input from a Java Reader not supported.  Use an InputStream.");
     }
+
+    /** Convenience operation - create a {@link RowSetReader} for {@link Lang} or return null. */
+    public static RowSetReader createReader(Lang lang) {
+        return RowSetReaderRegistry.createReader(lang);
+    }
 }
diff --git a/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReaderRegistry.java b/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReaderRegistry.java
index 582fe3ba31..20da936741 100644
--- a/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReaderRegistry.java
+++ b/jena-arq/src/main/java/org/apache/jena/riot/rowset/RowSetReaderRegistry.java
@@ -35,6 +35,15 @@ public class RowSetReaderRegistry {
         return registry.get(lang);
     }
 
+    /** Create a {@link RowSetReader} for {@link Lang} or return null. */
+    public static RowSetReader createReader(Lang lang) {
+        Objects.requireNonNull(lang);
+        RowSetReaderFactory factory = getFactory(lang);
+        if ( factory == null )
+            return null;
+        return factory.create(lang);
+    }
+
     /** Register a {@link RowSetReaderFactory} for a {@link Lang} */
     public static void register(Lang lang, RowSetReaderFactory factory) {
         Objects.requireNonNull(lang);
diff --git a/jena-core/src/main/java/org/apache/jena/graph/NodeFactory.java b/jena-core/src/main/java/org/apache/jena/graph/NodeFactory.java
index 264046092f..4979cb06c8 100644
--- a/jena-core/src/main/java/org/apache/jena/graph/NodeFactory.java
+++ b/jena-core/src/main/java/org/apache/jena/graph/NodeFactory.java
@@ -224,22 +224,40 @@ public class NodeFactory {
                     if ( ! dtype.equals(RDFLangString.rdfLangString) )
                         throw new JenaException("Datatype is not rdf:langString but a language was given");
                 } else {
-                    if (  ! dtype.equals(RDFDirLangString.rdfDirLangString) )
+                    if ( ! dtype.equals(RDFDirLangString.rdfDirLangString) )
                         throw new JenaException("Datatype is not rdf:dirLangString but a language and initial text direction was given");
                 }
             }
 
             return createLiteralDirLang(lex, langFmt, textDir);
         }
+
         if ( dtype == null )
             // No datatype, no lang (it is null or "") => xsd:string.
             return createLiteralString(lex);
 
-        // No lang, with a datatype
-        if ( dtype.equals(RDFLangString.rdfLangString) )
-            throw new JenaException("Datatype is rdf:langString but no language given");
-        if ( dtype.equals(RDFDirLangString.rdfDirLangString) && noTextDir(textDir) )
-            throw new JenaException("Datatype is rdf:dirLangString but no initial text direction given");
+        // No language. Has a datatype.
+        boolean hasTextDirLang = ( textDir != null );
+        if ( hasTextDirLang ) {
+            if ( dtype.equals(RDFDirLangString.rdfDirLangString) ) {
+                // No language. Datatype is rdf:dirLangString, Does have an initial text direction
+                throw new JenaException("Datatype is rdf:dirLangString and has an initial text direction but no language given");
+            } else if ( dtype.equals(RDFLangString.rdfLangString) ) {
+                // No language. Datatype is rdf:langString, Does have an initial text direction.
+                throw new JenaException("Datatype is rdf:langString and has an initial text direction but no language given");
+            }
+        }
+
+        // Datatype. No language, no initial text direction.
+        // Allow "abc"^^rdf:langString
+        // Allow "abc"^^rdf:dirLangString
+
+        // To disallow.
+//        if ( dtype.equals(RDFLangString.rdfLangString) )
+//            throw new JenaException("Datatype is rdf:langString but no language given");
+//        if ( dtype.equals(RDFDirLangString.rdfDirLangString) && noTextDir(textDir) )
+//            throw new JenaException("Datatype is rdf:dirLangString but no initial text direction given");
+
         Node n = createLiteralDT(lex, dtype);
         return n;
     }
diff --git a/jena-ontapi/src/main/java/org/apache/jena/ontapi/utils/OntModels.java b/jena-ontapi/src/main/java/org/apache/jena/ontapi/utils/OntModels.java
index 4af32c8b52..ae0e180272 100644
--- a/jena-ontapi/src/main/java/org/apache/jena/ontapi/utils/OntModels.java
+++ b/jena-ontapi/src/main/java/org/apache/jena/ontapi/utils/OntModels.java
@@ -18,6 +18,7 @@
 
 package org.apache.jena.ontapi.utils;
 
+import org.apache.jena.graph.Triple;
 import org.apache.jena.ontapi.OntJenaException;
 import org.apache.jena.ontapi.common.OntConfig;
 import org.apache.jena.ontapi.common.OntEnhGraph;
@@ -39,7 +40,6 @@ import org.apache.jena.ontapi.model.OntObjectProperty;
 import org.apache.jena.ontapi.model.OntSWRL;
 import org.apache.jena.ontapi.model.OntStatement;
 import org.apache.jena.ontapi.model.RDFNodeList;
-import org.apache.jena.graph.Triple;
 import org.apache.jena.rdf.model.Property;
 import org.apache.jena.rdf.model.RDFNode;
 import org.apache.jena.rdf.model.Resource;
@@ -48,7 +48,9 @@ import org.apache.jena.reasoner.Reasoner;
 import org.apache.jena.util.iterator.ExtendedIterator;
 
 import java.util.ArrayDeque;
+import java.util.Collection;
 import java.util.Deque;
+import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -372,4 +374,45 @@ public class OntModels {
     public static OntConfig config(OntModel m) {
         return (m instanceof OntEnhGraph) ? ((OntEnhGraph) m).getOntPersonality().getConfig() : null;
     }
+
+    /**
+     * Answer a stream of the named hierarchy roots of a given OntModel.
+     * This will be similar to the results of {@code OntModel.hierarchyRoot()},
+     * with the added constraint that every member of the returned stream will be a named class,
+     * not an anonymous class expression.
+     * The named root classes are calculated from the root classes
+     * by recursively replacing every anonymous class with its direct subclasses.
+     * Thus, it can be seen
+     * that the values in the stream consist of the shallowest fringe of named classes in the hierarchy.
+     *
+     * @param m {@link OntModel}
+     * @return a {@code Stream} of {@link OntClass.Named}
+     * @see OntModel#hierarchyRoots()
+     */
+    public static Stream<OntClass.Named> namedHierarchyRoots(OntModel m) {
+        Set<OntClass> named = new HashSet<>();
+        Set<OntClass> anonymous = new HashSet<>();
+        collectNamedHierarchyRoots(m.getOWLThing(), m.hierarchyRoots(), named, anonymous);
+        while (!anonymous.isEmpty()) {
+            OntClass anon = anonymous.iterator().next();
+            anonymous.remove(anon);
+            collectNamedHierarchyRoots(m.getOWLThing(), anon.subClasses(true), named, anonymous);
+        }
+        return named.stream().map(OntClass::asNamed);
+    }
+
+    private static void collectNamedHierarchyRoots(OntClass thing,
+                                                   Stream<OntClass> classes,
+                                                   Collection<OntClass> named,
+                                                   Collection<OntClass> anonymous) {
+        classes.forEach(clazz -> {
+            if (named.contains(clazz) || anonymous.contains(clazz)) {
+                return;
+            }
+            if (clazz.superClasses(false)
+                    .allMatch(it -> it.isAnon() || it.equals(clazz) || it.equals(thing))) {
+                (clazz.isAnon() ? anonymous : named).add(clazz);
+            }
+        });
+    }
 }
diff --git a/pom.xml b/pom.xml
index f41ac5e555..352b29ddc6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -66,7 +66,7 @@
     <ver.jakarta.json>2.0.1</ver.jakarta.json>
 
     <!-- These two must be in-step -->
-    <ver.jetty>12.0.10</ver.jetty>
+    <ver.jetty>12.0.11</ver.jetty>
     <ver.jakarta-servlet>6.1.0</ver.jakarta-servlet>
 
     <ver.shiro>2.0.1</ver.shiro>
@@ -93,7 +93,7 @@
 
     <ver.dexxcollection>0.7</ver.dexxcollection>
     <ver.micrometer>1.13.1</ver.micrometer>
-    <ver.roaringbitmap>1.1.0</ver.roaringbitmap>
+    <ver.roaringbitmap>1.2.0</ver.roaringbitmap>
 
     <!-- Testing -->
     <ver.junit4>4.13.2</ver.junit4>


Test patch:
diff --git a/jena-arq/src/test/java/org/apache/jena/riot/resultset/TS_ResultSetRIOT.java b/jena-arq/src/test/java/org/apache/jena/riot/resultset/TS_ResultSetRIOT.java
index f29ee4b0d8..8b214fedf5 100644
--- a/jena-arq/src/test/java/org/apache/jena/riot/resultset/TS_ResultSetRIOT.java
+++ b/jena-arq/src/test/java/org/apache/jena/riot/resultset/TS_ResultSetRIOT.java
@@ -20,15 +20,15 @@ package org.apache.jena.riot.resultset;
 
 import org.apache.jena.riot.resultset.rw.TestResultSetWriterCSV;
 import org.apache.jena.riot.resultset.rw.TestResultSetWriterTSV;
-import org.junit.runner.RunWith ;
-import org.junit.runners.Suite ;
+import org.junit.platform.suite.api.*;
 
-@RunWith(Suite.class)
-@Suite.SuiteClasses( {
+@Suite
+@SelectClasses({
     TestResultSetIO.class
+    , TestResultSetLang.class
     , TestResultSetWriterCSV.class
     , TestResultSetWriterTSV.class
     , TestResultSetLang.class
-})
+    })
 
 public class TS_ResultSetRIOT { }
diff --git a/jena-arq/src/test/java/org/apache/jena/riot/rowset/TS_RowSetRIOT.java b/jena-arq/src/test/java/org/apache/jena/riot/rowset/TS_RowSetRIOT.java
index 2cd1f05b4b..d2d91e562d 100644
--- a/jena-arq/src/test/java/org/apache/jena/riot/rowset/TS_RowSetRIOT.java
+++ b/jena-arq/src/test/java/org/apache/jena/riot/rowset/TS_RowSetRIOT.java
@@ -18,15 +18,17 @@
 
 package org.apache.jena.riot.rowset;
 
+import org.apache.jena.riot.rowset.rw.TestRowSetReader;
 import org.apache.jena.riot.rowset.rw.TestRowSetWriterCSV;
 import org.apache.jena.riot.rowset.rw.TestRowSetWriterTSV;
-import org.junit.runner.RunWith;
-import org.junit.runners.Suite;
+import org.junit.platform.suite.api.SelectClasses;
+import org.junit.platform.suite.api.Suite;;
 
-@RunWith(Suite.class)
-@Suite.SuiteClasses( {
+@Suite
+@SelectClasses({
     TestRowSetWriterCSV.class,
-    TestRowSetWriterTSV.class
+    TestRowSetWriterTSV.class,
+    TestRowSetReader.class
 })
 
 public class TS_RowSetRIOT { }
diff --git a/jena-arq/src/test/java/org/apache/jena/riot/rowset/rw/TestRowSetReader.java b/jena-arq/src/test/java/org/apache/jena/riot/rowset/rw/TestRowSetReader.java
new file mode 100644
index 0000000000..c340a19023
--- /dev/null
+++ b/jena-arq/src/test/java/org/apache/jena/riot/rowset/rw/TestRowSetReader.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.riot.rowset.rw;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import org.apache.jena.atlas.io.IOX;
+import org.apache.jena.atlas.lib.Bytes;
+import org.apache.jena.query.ARQ;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.riot.resultset.ResultSetLang;
+import org.apache.jena.riot.rowset.RowSetReader;
+import org.apache.jena.sparql.exec.RowSet;
+import org.apache.jena.sparql.exec.RowSetOps;
+import org.apache.jena.sys.JenaSystem;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Addition tests for result readers.
+ * The SPARQl test suite cover most usage. This class adds tests
+ */
+public class TestRowSetReader {
+    static { JenaSystem.init(); }
+
+    // Check "abc"^^rdf:langString and "abc"^^rdf:dirLangString and "
+    @Test public void resultSet_json_1() {
+        String r = """
+                {
+                  "head": { "vars": [ "s" , "p" , "o" ] } ,
+                  "results": {
+                    "bindings": [
+                      {
+                        "s": { "type": "uri" , "value": "http://example/s" } ,
+                        "p": { "type": "uri" , "value": "http://example/p" } ,
+                        "o": { "type": "literal" , "datatype": "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" , "value": "abc" }
+                      } ,
+                      {
+                        "s": { "type": "uri" , "value": "http://example/s" } ,
+                        "p": { "type": "uri" , "value": "http://example/p" } ,
+                        "o": { "type": "literal" , "datatype": "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString" , "value": "abc" }
+                      }
+                    ]
+                }}
+                """;
+        RowSet rowset = read(r, ResultSetLang.RS_JSON);
+        assertNotNull(rowset);
+        assertEquals(2, RowSetOps.count(rowset));
+    }
+
+    @Test public void resultSet_xml_1() {
+        String r = """
+<?xml version="1.0"?>
+<sparql xmlns="http://www.w3.org/2005/sparql-results#">
+  <head>
+    <variable name="s"/>
+    <variable name="p"/>
+    <variable name="o"/>
+  </head>
+  <results>
+    <result>
+      <binding name="s">
+        <uri>http://example/s</uri>
+      </binding>
+      <binding name="p">
+        <uri>http://example/p</uri>
+      </binding>
+      <binding name="o">
+        <literal datatype="http://www.w3.org/1999/02/22-rdf-syntax-ns#langString">abc</literal>
+      </binding>
+    </result>
+        <result>
+      <binding name="s">
+        <uri>http://example/s</uri>
+      </binding>
+      <binding name="p">
+        <uri>http://example/p</uri>
+      </binding>
+      <binding name="o">
+        <literal datatype="http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString">abc</literal>
+      </binding>
+    </result>
+
+  </results>
+</sparql>
+                """;
+        RowSet rowset = read(r, ResultSetLang.RS_XML);
+        assertNotNull(rowset);
+        assertEquals(2, RowSetOps.count(rowset));
+    }
+
+    private static RowSet read(String x, Lang rsLang) {
+        try ( InputStream input = new ByteArrayInputStream(Bytes.string2bytes(x)) ) {
+            RowSet rowSet = RowSetReader.createReader(rsLang)
+                    .read(input, ARQ.getContext().copy())
+                    .materialize();
+            return rowSet;
+        } catch (IOException ex) { throw IOX.exception(ex); }
+    }
+}
diff --git a/jena-core/src/test/java/org/apache/jena/graph/test/TestNodeEdgeCases.java b/jena-core/src/test/java/org/apache/jena/graph/test/TestNodeEdgeCases.java
new file mode 100644
index 0000000000..43cd6f2813
--- /dev/null
+++ b/jena-core/src/test/java/org/apache/jena/graph/test/TestNodeEdgeCases.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.graph.test;
+
+import static org.junit.Assert.assertNotNull;
+
+import org.apache.jena.datatypes.xsd.impl.RDFDirLangString;
+import org.apache.jena.datatypes.xsd.impl.RDFLangString;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.junit.Test;
+
+public class TestNodeEdgeCases {
+
+    // Corner cases of NodeFactory
+    @Test public void term_create_01() {
+        // "abc"^^rdf:langString (no language)
+        Node node1 = NodeFactory.createLiteral("abc", null, (String)null, RDFLangString.rdfLangString);
+        assertNotNull(node1);
+        Node node2 = NodeFactory.createLiteralDT("abc", RDFLangString.rdfLangString);
+        assertNotNull(node2);
+    }
+
+    @Test public void term_create_02() {
+        // "abc"^rdf:dirLangString (no language, no initial text direction)
+        Node node1 = NodeFactory.createLiteral("abc", null, (String)null, RDFDirLangString.rdfDirLangString);
+        assertNotNull(node1);
+        Node node2 = NodeFactory.createLiteralDT("abc", RDFDirLangString.rdfDirLangString);
+        assertNotNull(node2);
+    }
+}
diff --git a/jena-core/src/test/java/org/apache/jena/graph/test/TestPackage_graph.java b/jena-core/src/test/java/org/apache/jena/graph/test/TestPackage_graph.java
index 5236ca13b5..ee2606b951 100644
--- a/jena-core/src/test/java/org/apache/jena/graph/test/TestPackage_graph.java
+++ b/jena-core/src/test/java/org/apache/jena/graph/test/TestPackage_graph.java
@@ -55,6 +55,7 @@ public class TestPackage_graph extends TestSuite {
         addTestSuite( TestGraphBaseToString.class );
         addTest( new JUnit4TestAdapter(TestNodeExtras.class) );
         addTest( new JUnit4TestAdapter(TestRDFStringLiterals.class) );
+        addTest( new JUnit4TestAdapter(TestNodeEdgeCases.class) );
 
         // Has to be in a different package.
         addTest( new JUnit4TestAdapter(TestGraphUtil.class) );
diff --git a/jena-ontapi/src/test/java/org/apache/jena/ontapi/OntModelsTest.java b/jena-ontapi/src/test/java/org/apache/jena/ontapi/OntModelsTest.java
new file mode 100644
index 0000000000..2ec8fddc83
--- /dev/null
+++ b/jena-ontapi/src/test/java/org/apache/jena/ontapi/OntModelsTest.java
@@ -0,0 +1,478 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jena.ontapi;
+
+import org.apache.jena.ontapi.model.OntClass;
+import org.apache.jena.ontapi.model.OntModel;
+import org.apache.jena.ontapi.testutils.RDFIOTestUtils;
+import org.apache.jena.ontapi.utils.OntModels;
+import org.apache.jena.rdf.model.Resource;
+import org.apache.jena.riot.Lang;
+import org.apache.jena.vocabulary.OWL;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+public class OntModelsTest {
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "OWL2_DL_MEM_RDFS_BUILTIN_INF",
+            "OWL2_DL_MEM",
+            "OWL2_DL_MEM_RDFS_INF",
+            "OWL2_MEM",
+            "OWL2_MEM_RDFS_INF",
+            "OWL2_MEM_MICRO_RULES_INF",
+            "OWL2_EL_MEM",
+            "OWL2_EL_MEM_RDFS_INF",
+            "OWL2_QL_MEM",
+            "OWL2_QL_MEM_RDFS_INF",
+            "OWL2_RL_MEM",
+            "OWL2_RL_MEM_RDFS_INF",
+            "OWL1_DL_MEM",
+            "OWL1_DL_MEM_RDFS_INF",
+            "OWL1_MEM",
+            "OWL1_MEM_RDFS_INF",
+            "OWL1_MEM_MICRO_RULES_INF",
+            "OWL1_LITE_MEM",
+            "OWL1_LITE_MEM_RDFS_INF",
+            "RDFS_MEM",
+            "RDFS_MEM_TRANS_INF",
+    })
+    public void testNamedHierarchyRoots1a(TestSpec spec) {
+        //     A
+        //   /  / \
+        //  /  B   C
+        //  | / \ / \
+        //  D   E   F
+        // / \
+        // G  H = K
+        //       / \
+        //      L   M
+        OntModel m = TestModelFactory.createClassesABCDEFGHKLM(OntModelFactory.createModel(spec.inst));
+        OntClass A = m.getOntClass(TestModelFactory.NS + "A");
+        Assertions.assertEquals(List.of(A), OntModels.namedHierarchyRoots(m).toList());
+    }
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "OWL2_DL_MEM_TRANS_INF",
+            "OWL2_DL_MEM_RULES_INF",
+            "OWL2_MEM_TRANS_INF",
+            "OWL2_MEM_RULES_INF",
+            "OWL2_MEM_MINI_RULES_INF",
+            "OWL2_EL_MEM_TRANS_INF",
+            "OWL2_EL_MEM_RULES_INF",
+            "OWL2_QL_MEM_TRANS_INF",
+            "OWL2_QL_MEM_RULES_INF",
+            "OWL2_RL_MEM_TRANS_INF",
+            "OWL2_RL_MEM_RULES_INF",
+            "OWL1_DL_MEM_TRANS_INF",
+            "OWL1_DL_MEM_RULES_INF",
+            "OWL1_MEM_TRANS_INF",
+            "OWL1_MEM_RULES_INF",
+            "OWL1_MEM_MINI_RULES_INF",
+            "OWL1_LITE_MEM_TRANS_INF",
+            "OWL1_LITE_MEM_RULES_INF",
+            "RDFS_MEM_RDFS_INF",
+    })
+    public void testNamedHierarchyRoots1b(TestSpec spec) {
+        //     A
+        //   /  / \
+        //  /  B   C
+        //  | / \ / \
+        //  D   E   F
+        // / \
+        // G  H = K
+        //       / \
+        //      L   M
+        OntModel m = TestModelFactory.createClassesABCDEFGHKLM(OntModelFactory.createModel(spec.inst));
+        Assertions.assertEquals(List.of(), OntModels.namedHierarchyRoots(m).toList());
+    }
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "OWL2_DL_MEM_RDFS_BUILTIN_INF",
+            "OWL2_DL_MEM",
+            "OWL2_DL_MEM_RDFS_INF",
+            "OWL2_MEM",
+            "OWL2_MEM_RDFS_INF",
+            "OWL2_EL_MEM",
+            "OWL2_EL_MEM_RDFS_INF",
+            "OWL2_QL_MEM",
+            "OWL2_QL_MEM_RDFS_INF",
+            "OWL2_RL_MEM",
+            "OWL2_RL_MEM_RDFS_INF",
+            "OWL1_DL_MEM",
+            "OWL1_DL_MEM_RDFS_INF",
+            "OWL1_MEM",
+            "OWL1_MEM_RDFS_INF",
+            "OWL1_LITE_MEM",
+            "OWL1_LITE_MEM_RDFS_INF",
+    })
+    public void testNamedHierarchyRoots2a(TestSpec spec) {
+        OntModel m = RDFIOTestUtils.readResourceToModel(OntModelFactory.createModel(spec.inst), "/pizza.ttl", Lang.TURTLE);
+        Set<String> actual = OntModels.namedHierarchyRoots(m).map(Resource::getLocalName).collect(Collectors.toSet());
+        Set<String> expected = Set.of(
+                "NonVegetarianPizza",
+                "VegetarianTopping",
+                "DomainConcept",
+                "SpicyPizza",
+                "VegetarianPizza",
+                "SpicyPizzaEquivalent",
+                "MeatyPizza",
+                "CheeseyPizza",
+                "VegetarianPizzaEquivalent2",
+                "Country",
+                "ThinAndCrispyPizza",
+                "SpicyTopping",
+                "ValuePartition",
+                "VegetarianPizzaEquivalent1",
+                "InterestingPizza",
+                "RealItalianPizza");
+        Assertions.assertEquals(expected, actual);
+    }
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "RDFS_MEM_RDFS_INF",
+    })
+    public void testNamedHierarchyRoots2b(TestSpec spec) {
+        OntModel m = RDFIOTestUtils.readResourceToModel(OntModelFactory.createModel(spec.inst), "/pizza.ttl", Lang.TURTLE);
+        Set<String> actual = OntModels.namedHierarchyRoots(m).map(Resource::getLocalName).collect(Collectors.toSet());
+
+        Set<String> expected = Set.of(
+                // reserved in OWL, but not in RDFS
+                "ObjectProperty",
+                "InverseFunctionalProperty",
+                "Ontology",
+                "TransitiveProperty",
+                "FunctionalProperty",
+                "Restriction",
+                "AllDifferent",
+                "Class",
+                "NamedIndividual",
+                "Thing",
+
+                "DomainConcept",
+                "Country",
+                "ValuePartition",
+                "RealItalianPizza");
+        Assertions.assertEquals(expected, actual);
+    }
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "OWL2_DL_MEM_TRANS_INF",
+            "OWL2_MEM_TRANS_INF",
+            "OWL2_EL_MEM_TRANS_INF",
+            "OWL2_QL_MEM_TRANS_INF",
+            "OWL2_RL_MEM_TRANS_INF",
+            "OWL1_DL_MEM_TRANS_INF",
+            "OWL1_MEM_TRANS_INF",
+            "OWL1_LITE_MEM_TRANS_INF",
+            "RDFS_MEM",
+            "RDFS_MEM_TRANS_INF",
+    })
+    public void testNamedHierarchyRoots2c(TestSpec spec) {
+        OntModel m = RDFIOTestUtils.readResourceToModel(OntModelFactory.createModel(spec.inst), "/pizza.ttl", Lang.TURTLE);
+        Set<String> actual = OntModels.namedHierarchyRoots(m).map(Resource::getLocalName).collect(Collectors.toSet());
+
+        Set<String> expected = Set.of();
+        Assertions.assertEquals(expected, actual);
+    }
+
+    @Test
+    public void testNamedHierarchyRoots3() {
+        OntModel m = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM_MICRO_RULES_INF);
+        OntClass A = m.createOntClass(TestModelFactory.NS + "A");
+        OntClass B = m.createOntClass(TestModelFactory.NS + "B");
+        OntClass C = m.createOntClass(TestModelFactory.NS + "C");
+        OntClass D = m.createOntClass(TestModelFactory.NS + "D");
+        OntClass E = m.createOntClass(TestModelFactory.NS + "E");
+        OntClass F = m.createOntClass(TestModelFactory.NS + "F");
+        OntClass G = m.createOntClass(TestModelFactory.NS + "G");
+
+        A.addSubClass(B);
+        B.addSubClass(C);
+        C.addSubClass(D);
+        E.addSubClass(E);
+        E.addSubClass(F);
+
+        List<OntClass.Named> nhr = OntModels.namedHierarchyRoots(m).toList();
+        Assertions.assertEquals(3, nhr.size());
+        Assertions.assertTrue(nhr.contains(A));
+        Assertions.assertTrue(nhr.contains(E));
+        Assertions.assertTrue(nhr.contains(G));
+    }
+
+    @Test
+    public void testNamedHierarchyRoots4() {
+        OntModel m = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM_MICRO_RULES_INF);
+        OntClass A = m.createOntClass(TestModelFactory.NS + "A");
+        OntClass B = m.createOntClass(TestModelFactory.NS + "B");
+        OntClass C = m.createOntClass(TestModelFactory.NS + "C");
+        OntClass D = m.createOntClass(TestModelFactory.NS + "D");
+        OntClass E = m.createOntClass(TestModelFactory.NS + "E");
+        OntClass F = m.createOntClass(TestModelFactory.NS + "F");
+        OntClass G = m.createOntClass(TestModelFactory.NS + "G");
+
+        A.addSubClass(B);
+        B.addSubClass(C);
+        C.addSubClass(D);
+        E.addSubClass(E);
+        E.addSubClass(F);
+
+        OntClass anon0 = m.createObjectUnionOf(A, F);
+        anon0.addSubClass(A);
+        anon0.addSubClass(E);
+
+        List<OntClass.Named> nhr = OntModels.namedHierarchyRoots(m).toList();
+        Assertions.assertEquals(3, nhr.size());
+        Assertions.assertTrue(nhr.contains(A));
+        Assertions.assertTrue(nhr.contains(E));
+        Assertions.assertTrue(nhr.contains(G));
+    }
+
+    @Test
+    public void testNamedHierarchyRoots5() {
+        OntModel m = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM_MICRO_RULES_INF);
+        OntClass A = m.createOntClass(TestModelFactory.NS + "A");
+        OntClass B = m.createOntClass(TestModelFactory.NS + "B");
+        OntClass C = m.createOntClass(TestModelFactory.NS + "C");
+        OntClass D = m.createOntClass(TestModelFactory.NS + "D");
+        OntClass E = m.createOntClass(TestModelFactory.NS + "E");
+        OntClass F = m.createOntClass(TestModelFactory.NS + "F");
+        OntClass G = m.createOntClass(TestModelFactory.NS + "G");
+
+        OntClass anon0 = m.createObjectUnionOf(A, B, G);
+        OntClass anon1 = m.createObjectUnionOf(C, D, anon0);
+        anon0.addSubClass(A);
+        anon0.addSubClass(E);
+        anon0.addSubClass(anon1);
+        anon1.addSubClass(G);
+
+        A.addSubClass(B);
+        B.addSubClass(C);
+        C.addSubClass(D);
+        E.addSubClass(E);
+        E.addSubClass(F);
+
+        List<OntClass.Named> nhr = OntModels.namedHierarchyRoots(m).toList();
+        Assertions.assertEquals(3, nhr.size());
+        Assertions.assertTrue(nhr.contains(A));
+        Assertions.assertTrue(nhr.contains(E));
+        Assertions.assertTrue(nhr.contains(G));
+    }
+
+    @Test
+    public void testNamedHierarchyRoots6() {
+        OntModel m = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM_MICRO_RULES_INF);
+        OntClass A = m.createOntClass(TestModelFactory.NS + "A");
+        OntClass B = m.createOntClass(TestModelFactory.NS + "B");
+        OntClass C = m.createOntClass(TestModelFactory.NS + "C");
+        OntClass D = m.createOntClass(TestModelFactory.NS + "D");
+        OntClass E = m.createOntClass(TestModelFactory.NS + "E");
+        OntClass F = m.createOntClass(TestModelFactory.NS + "F");
+        OntClass G = m.createOntClass(TestModelFactory.NS + "G");
+
+        OntClass anon0 = m.createObjectComplementOf(F);
+        OntClass anon1 = m.createObjectUnionOf(F);
+        anon0.addSubClass(A);
+        anon1.addSubClass(A);
+
+        // only A is root
+        A.addSubClass(B);
+        A.addSubClass(C);
+        A.addSubClass(D);
+        A.addSubClass(E);
+        A.addSubClass(F);
+        A.addSubClass(G);
+
+        List<OntClass.Named> nhr = OntModels.namedHierarchyRoots(m).toList();
+        Assertions.assertEquals(List.of(A), nhr);
+    }
+
+    @Test
+    public void testNamedHierarchyRoots7() {
+        OntModel m = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM_MICRO_RULES_INF);
+        OntClass A = m.createOntClass(TestModelFactory.NS + "A");
+        OntClass B = m.createOntClass(TestModelFactory.NS + "B");
+        OntClass C = m.createOntClass(TestModelFactory.NS + "C");
+        OntClass D = m.createOntClass(TestModelFactory.NS + "D");
+        OntClass E = m.createOntClass(TestModelFactory.NS + "E");
+        OntClass F = m.createOntClass(TestModelFactory.NS + "F");
+        OntClass G = m.createOntClass(TestModelFactory.NS + "G");
+
+        OntClass anon0 = m.createObjectUnionOf(A, B);
+        OntClass anon1 = m.createObjectUnionOf(C, D, anon0);
+        anon0.addSubClass(A);
+        anon1.addSubClass(B);
+
+        // only A is root, because B is a subclass of A
+        // even though B is a subclass of an anon root
+        A.addSubClass(B);
+        A.addSubClass(C);
+        A.addSubClass(D);
+        A.addSubClass(E);
+        A.addSubClass(F);
+        A.addSubClass(G);
+
+        List<OntClass.Named> nhr = OntModels.namedHierarchyRoots(m).toList();
+        Assertions.assertEquals(List.of(A), nhr);
+    }
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "OWL2_DL_MEM_RDFS_BUILTIN_INF",
+            "OWL2_DL_MEM",
+            "OWL2_DL_MEM_RDFS_INF",
+            "OWL2_MEM",
+            "OWL2_MEM_RDFS_INF",
+            "OWL1_DL_MEM",
+            "OWL1_DL_MEM_RDFS_INF",
+            "OWL1_MEM",
+            "OWL1_MEM_RDFS_INF",
+    })
+    public void testNamedHierarchyRoots9a(TestSpec spec) {
+        OntModel m = OntModelFactory.createModel(spec.inst);
+
+        OntClass c0 = m.createOntClass(":C0");
+        OntClass c1 = m.createOntClass(":C1");
+        OntClass c2 = m.createOntClass(":C2");
+        OntClass c3 = m.createOntClass(":C3");
+        OntClass c4 = m.createOntClass(":C4");
+        OntClass c5 = m.createOntClass(":C5");
+        OntClass c6 = m.createOntClass(":C6");
+        OntClass c7 = m.createOntClass(":C7");
+        OntClass c8 = m.createOntClass(":C8");
+        OntClass c9 = m.createDataSomeValuesFrom(m.createDataProperty(":p1"), m.createDataOneOf(m.createLiteral("42")));
+        OntClass c10 = m.createObjectOneOf(m.createIndividual(null, c0), m.createIndividual(null, c1));
+        OntClass c11 = m.createObjectComplementOf(c6);
+        OntClass c12 = OWL.Thing.inModel(m).as(OntClass.class);
+        OntClass c13 = OWL.Nothing.inModel(m).as(OntClass.class);
+
+        c1.addSuperClass(c2);
+        c2.addSuperClass(c3);
+        c3.addSuperClass(c4);
+        c5.addSuperClass(c6);
+        c6.addSuperClass(c13);
+        c8.addSuperClass(c11);
+        c9.addSuperClass(c10);
+        c10.addSuperClass(c5);
+        c10.addSuperClass(c7);
+        c11.addSuperClass(c12);
+
+        Set<OntClass> actual = OntModels.namedHierarchyRoots(m).collect(Collectors.toSet());
+        Set<Resource> expected = Set.of(c0, c4, c7, c8);
+
+        Assertions.assertEquals(expected, actual);
+    }
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "OWL2_DL_MEM_RULES_INF",
+            "OWL2_MEM_RULES_INF",
+            "OWL2_MEM_MINI_RULES_INF",
+            "OWL1_DL_MEM_RULES_INF",
+            "OWL1_MEM_RULES_INF",
+            "OWL1_MEM_MINI_RULES_INF",
+    })
+    public void testNamedHierarchyRoots9b(TestSpec spec) {
+        OntModel m = OntModelFactory.createModel(spec.inst);
+
+        OntClass c0 = m.createOntClass(":C0");
+        OntClass c1 = m.createOntClass(":C1");
+        OntClass c2 = m.createOntClass(":C2");
+        OntClass c3 = m.createOntClass(":C3");
+        OntClass c4 = m.createOntClass(":C4");
+        OntClass c5 = m.createOntClass(":C5");
+        OntClass c6 = m.createOntClass(":C6");
+        OntClass c7 = m.createOntClass(":C7");
+        OntClass c8 = m.createOntClass(":C8");
+        OntClass c9 = m.createDataSomeValuesFrom(m.createDataProperty(":p1"), m.createDataOneOf(m.createLiteral("42")));
+        OntClass c10 = m.createObjectOneOf(m.createIndividual(null, c0), m.createIndividual(null, c1));
+        OntClass c11 = m.createObjectComplementOf(c6);
+        OntClass c12 = OWL.Thing.inModel(m).as(OntClass.class);
+        OntClass c13 = OWL.Nothing.inModel(m).as(OntClass.class);
+
+        c1.addSuperClass(c2);
+        c2.addSuperClass(c3);
+        c3.addSuperClass(c4);
+        c5.addSuperClass(c6);
+        c6.addSuperClass(c13);
+        c8.addSuperClass(c11);
+        c9.addSuperClass(c10);
+        c10.addSuperClass(c5);
+        c10.addSuperClass(c7);
+        c11.addSuperClass(c12);
+
+        List<OntClass.Named> actual = OntModels.namedHierarchyRoots(m).toList();
+        Assertions.assertTrue(actual.isEmpty());
+    }
+
+    @ParameterizedTest
+    @EnumSource(names = {
+            "OWL2_DL_MEM_TRANS_INF",
+            "OWL2_MEM_TRANS_INF",
+            "OWL1_DL_MEM_TRANS_INF",
+            "OWL1_MEM_TRANS_INF",
+    })
+    public void testNamedHierarchyRoots9c(TestSpec spec) {
+        OntModel m = OntModelFactory.createModel(spec.inst);
+
+        OntClass c0 = m.createOntClass(":C0");
+        OntClass c1 = m.createOntClass(":C1");
+        OntClass c2 = m.createOntClass(":C2");
+        OntClass c3 = m.createOntClass(":C3");
+        OntClass c4 = m.createOntClass(":C4");
+        OntClass c5 = m.createOntClass(":C5");
+        OntClass c6 = m.createOntClass(":C6");
+        OntClass c7 = m.createOntClass(":C7");
+        OntClass c8 = m.createOntClass(":C8");
+        OntClass c9 = m.createDataSomeValuesFrom(m.createDataProperty(":p1"), m.createDataOneOf(m.createLiteral("42")));
+        OntClass c10 = m.createObjectOneOf(m.createIndividual(null, c0), m.createIndividual(null, c1));
+        OntClass c11 = m.createObjectComplementOf(c6);
+        OntClass c12 = OWL.Thing.inModel(m).as(OntClass.class);
+        OntClass c13 = OWL.Nothing.inModel(m).as(OntClass.class);
+
+        c1.addSuperClass(c2);
+        c2.addSuperClass(c3);
+        c3.addSuperClass(c4);
+        c5.addSuperClass(c6);
+        c6.addSuperClass(c13);
+        c8.addSuperClass(c11);
+        c9.addSuperClass(c10);
+        c10.addSuperClass(c5);
+        c10.addSuperClass(c7);
+        c11.addSuperClass(c12);
+
+        List<OntClass.Named> actual = OntModels.namedHierarchyRoots(m).toList();
+        Set<Resource> expected = Set.of(c12, c8);
+
+        Assertions.assertEquals(2, actual.size());
+        Assertions.assertEquals(expected, new HashSet<>(actual));
+    }
+}