Repository: apache/jena (java)

Issue:
Missing Value Node in `sh:uniqueLang` Constraint Validation Report
### Version

5.3.0

### What happened?

#### **Issue Description**
When the `sh:uniqueLang` constraint is violated, the validation report does not include the duplicated value node. This makes it difficult to identify which specific values caused the violation.

#### **Expected Behavior**
The validation report should provide the duplicated value node, allowing users to pinpoint the exact issue in their data.

#### **Steps to Reproduce**
1. Define a SHACL shape with `sh:uniqueLang true`.
2. Validate data that contains multiple values with the same language tag for a property.
3. Observe the validation report.

#### **Actual Behavior**
- The validation report states that the constraint was violated but does not indicate the specific duplicated value node.

#### **Expected Behavior**
- The validation report should include the exact value nodes that caused the violation.

#### **Example**
##### **SHACL Shape**
```turtle
ex:BuildingShape a sh:NodeShape ;
    sh:targetClass ex:Building ;
    sh:property [
        sh:path ex:label ;
        sh:datatype rdf:langString ;
        sh:uniqueLang true ;
        sh:message "Each building label should have only one value per language." ;
    ] .
```

##### **Invalid Data**
```turtle
ex:Building1 a ex:Building ;
    ex:label "Building"@en , "Edifice"@en , "Gebäude"@de .
```

### Relevant output and stacktrace

```shell

```

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

Yes

Pull request:
GH-3018: Report the offending object in UniqueLangConstraint
GitHub issue resolved #3018

Pull request Description:

Report the offending object in `UniqueLangConstraint`


----

 - [ ] Tests are included.
 - [ ] Documentation change and updates are provided for the [Apache Jena website](https://github.com/apache/jena-site/)
 - [ ] Commits have been squashed to remove intermediate development commit messages.
 - [ ] 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).

----

See the [Apache Jena "Contributing" guide](https://github.com/apache/jena/blob/main/CONTRIBUTING.md).


Patch:
diff --git a/jena-arq/src/main/java/org/apache/jena/sparql/core/assembler/AssemblerUtils.java b/jena-arq/src/main/java/org/apache/jena/sparql/core/assembler/AssemblerUtils.java
index c409626aa1..cf02882007 100644
--- a/jena-arq/src/main/java/org/apache/jena/sparql/core/assembler/AssemblerUtils.java
+++ b/jena-arq/src/main/java/org/apache/jena/sparql/core/assembler/AssemblerUtils.java
@@ -142,14 +142,14 @@ public class AssemblerUtils
     }
 
     public static Object build(Model assemblerModel, Resource type) {
-        Resource root = null ;
+        Resource root = null;
         try {
-            root = GraphUtils.findRootByType(assemblerModel, type) ;
+            root = GraphUtils.findRootByType(assemblerModel, type);
             if ( root == null )
-                throw new ARQException("No such type: <"+type+">");
-
-        } catch (TypeNotUniqueException ex)
-        { throw new ARQException("Multiple types for: "+tDataset) ; }
+                throw new ARQException("No such type: <" + type + ">");
+        } catch (TypeNotUniqueException ex) {
+            throw new ARQException("Multiple types for: " + tDataset);
+        }
         return Assembler.general.open(root) ;
     }
     /** Look for and build context declarations.
diff --git a/jena-arq/src/main/java/org/apache/jena/sparql/engine/main/OpExecutor.java b/jena-arq/src/main/java/org/apache/jena/sparql/engine/main/OpExecutor.java
index a659b6c4da..d5e7598ebe 100644
--- a/jena-arq/src/main/java/org/apache/jena/sparql/engine/main/OpExecutor.java
+++ b/jena-arq/src/main/java/org/apache/jena/sparql/engine/main/OpExecutor.java
@@ -308,7 +308,7 @@ public class OpExecutor {
     }
 
     protected QueryIterator execute(OpService opService, QueryIterator input) {
-        return ServiceExec.exec(input, opService, execCxt);
+        return ServiceExec.exec(opService, input, execCxt);
     }
 
     // Quad form, "GRAPH ?g {}" Flip back to OpGraph.
diff --git a/jena-arq/src/main/java/org/apache/jena/sparql/service/ServiceExec.java b/jena-arq/src/main/java/org/apache/jena/sparql/service/ServiceExec.java
index 69f2f30cad..16476db523 100644
--- a/jena-arq/src/main/java/org/apache/jena/sparql/service/ServiceExec.java
+++ b/jena-arq/src/main/java/org/apache/jena/sparql/service/ServiceExec.java
@@ -21,23 +21,63 @@ package org.apache.jena.sparql.service;
 import org.apache.jena.sparql.algebra.op.OpService;
 import org.apache.jena.sparql.engine.ExecutionContext;
 import org.apache.jena.sparql.engine.QueryIterator;
+import org.apache.jena.sparql.engine.binding.Binding;
 import org.apache.jena.sparql.service.bulk.ServiceExecutorBulk;
 import org.apache.jena.sparql.service.bulk.ServiceExecutorBulkOverRegistry;
+import org.apache.jena.sparql.service.single.ServiceExecutor;
+import org.apache.jena.sparql.service.single.ServiceExecutorOverRegistry;
 import org.apache.jena.sparql.util.Context;
 
 /**
  * Entry into the service executor from SPARQL queries.
  */
 public class ServiceExec {
+
+    /**
+     * Use {@link #exec(OpService, QueryIterator, ExecutionContext)} whose parameter order matches that of
+     * {@link ServiceExecutorBulk#createExecution(OpService, QueryIterator, ExecutionContext)}.
+     */
+    @Deprecated(forRemoval = true, since = "5.4.0")
+    public static QueryIterator exec(QueryIterator input, OpService opService, ExecutionContext execCxt) {
+        return exec(opService, input, execCxt);
+    }
+
     /**
      * Execute an OpService w.r.t. the execCxt's service executor registry.
      * This is the route from OpExecutor.
+     *
+     * This method can also be used to pass a modified request through the whole
+     * service executor chain, as exemplified below:
+     * <pre>{@code
+     * ServiceExecutorRegistry.get().addBulkLink((opService, input, execCxt, chain) -> {
+     *     if (canHandle(opService)) {
+     *         OpService modifiedOp = modifyOp(opService);
+     *         // Forward the request to the beginning of the chain.
+     *         return ServiceExec.exec(modifiedOp, input, execCxt);
+     *     } else {
+     *         // Forward the request to the remaining handlers in the chain.
+     *         return chain.createExecution(opService, input, execCxt);
+     *     }
+     * });
+     * }</pre>
      */
-    public static QueryIterator exec(QueryIterator input, OpService opService, ExecutionContext execCxt) {
+    public static QueryIterator exec(OpService opService, QueryIterator input, ExecutionContext execCxt) {
         Context cxt = execCxt.getContext();
         ServiceExecutorRegistry registry = ServiceExecutorRegistry.chooseRegistry(cxt);
         ServiceExecutorBulk serviceExecutor = new ServiceExecutorBulkOverRegistry(registry);
         QueryIterator qIter = serviceExecutor.createExecution(opService, input, execCxt);
         return qIter;
     }
+
+    /**
+     * Execute an OpService w.r.t. the execCxt's service executor registry -
+     * concretely its single chain which operates on a per-binding basis.
+     */
+    public static QueryIterator exec(OpService opExecute, OpService original, Binding binding, ExecutionContext execCxt) {
+        Context cxt = execCxt.getContext();
+        ServiceExecutorRegistry registry = ServiceExecutorRegistry.chooseRegistry(cxt);
+        ServiceExecutor serviceExecutor = new ServiceExecutorOverRegistry(registry);
+        QueryIterator qIter = serviceExecutor.createExecution(opExecute, original, binding, execCxt);
+        return qIter;
+    }
 }
diff --git a/jena-cmds/src/main/java/tdb/cmdline/ModTDBDataset.java b/jena-cmds/src/main/java/tdb/cmdline/ModTDBDataset.java
index d08eb337be..e756f712f6 100644
--- a/jena-cmds/src/main/java/tdb/cmdline/ModTDBDataset.java
+++ b/jena-cmds/src/main/java/tdb/cmdline/ModTDBDataset.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.List;
 
 import arq.cmdline.ModDataset;
+import org.apache.jena.assembler.Assembler;
 import org.apache.jena.atlas.logging.Log;
 import org.apache.jena.cmd.ArgDecl;
 import org.apache.jena.cmd.CmdArgModule;
@@ -29,10 +30,13 @@ import org.apache.jena.cmd.CmdException;
 import org.apache.jena.cmd.CmdGeneral;
 import org.apache.jena.query.*;
 import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.riot.RDFDataMgr;
 import org.apache.jena.shared.JenaException;
+import org.apache.jena.sparql.ARQException;
 import org.apache.jena.sparql.core.assembler.AssemblerUtils;
 import org.apache.jena.sparql.core.assembler.DatasetAssemblerVocab;
+import org.apache.jena.sparql.util.graph.GraphUtils;
 import org.apache.jena.tdb1.TDB1Factory;
 import org.apache.jena.tdb1.assembler.VocabTDB1;
 import org.apache.jena.tdb1.base.file.Location;
@@ -41,11 +45,11 @@ import org.apache.jena.tdb1.transaction.DatasetGraphTransaction;
 public class ModTDBDataset extends ModDataset {
     // Mixes assembler, location and "tdb"
     // Can make a single model or a dataset
-    
+
     private ArgDecl argMem                  = new ArgDecl(ArgDecl.HasValue, "mem", "data") ;
     private ModTDBAssembler modAssembler    = new ModTDBAssembler() ;
     private String inMemFile                = null ;
-    
+
     public ModTDBDataset() {}
 
     @Override
@@ -73,7 +77,7 @@ public class ModTDBDataset extends ModDataset {
             Dataset thing = null;
             // Two variants: plain dataset with a TDB graph or a TDB dataset.
             try {
-                thing = (Dataset)AssemblerUtils.build(modAssembler.getAssemblerFile(), VocabTDB1.tDatasetTDB);
+                thing = buildDataset(modAssembler);
                 if ( thing != null && !(thing.asDatasetGraph() instanceof DatasetGraphTransaction) )
                     Log.warn(this, "Unexpected: Not a TDB dataset for type DatasetTDB");
 
@@ -97,6 +101,19 @@ public class ModTDBDataset extends ModDataset {
         return ds;
     }
 
+    /** Build a dataset - several possible names (legacy) */
+    private static Dataset buildDataset(ModTDBAssembler modAssembler) {
+        Model spec = AssemblerUtils.readAssemblerFile(modAssembler.getAssemblerFile());
+        // throws ARQException("Multiple types for: " + tDataset);
+        Resource root = GraphUtils.findRootByType(spec, VocabTDB1.tDatasetTDB);
+        if ( root == null )
+            // Not found - try again.
+            root = GraphUtils.findRootByType(spec, VocabTDB1.tDatasetTDB1);
+        if ( root == null )
+            throw new ARQException("Not found: No such type: <" + VocabTDB1.tDatasetTDB + ">");
+        return (Dataset)Assembler.general.open(root) ;
+    }
+
     public Location getLocation() {
         List<String> x = locations();
         if ( x.size() == 0 )
diff --git a/jena-cmds/src/main/java/tdb2/cmdline/ModTDBDataset.java b/jena-cmds/src/main/java/tdb2/cmdline/ModTDBDataset.java
index 29765737ef..4bb79144e5 100644
--- a/jena-cmds/src/main/java/tdb2/cmdline/ModTDBDataset.java
+++ b/jena-cmds/src/main/java/tdb2/cmdline/ModTDBDataset.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.List;
 
 import arq.cmdline.ModDataset;
+import org.apache.jena.assembler.Assembler;
 import org.apache.jena.atlas.logging.Log;
 import org.apache.jena.cmd.ArgDecl;
 import org.apache.jena.cmd.CmdArgModule;
@@ -30,11 +31,14 @@ import org.apache.jena.cmd.CmdGeneral;
 import org.apache.jena.dboe.base.file.Location;
 import org.apache.jena.query.*;
 import org.apache.jena.rdf.model.Model;
+import org.apache.jena.rdf.model.Resource;
 import org.apache.jena.riot.RDFDataMgr;
 import org.apache.jena.shared.JenaException;
+import org.apache.jena.sparql.ARQException;
 import org.apache.jena.sparql.core.DatasetGraph;
 import org.apache.jena.sparql.core.assembler.AssemblerUtils;
 import org.apache.jena.sparql.core.assembler.DatasetAssemblerVocab;
+import org.apache.jena.sparql.util.graph.GraphUtils;
 import org.apache.jena.tdb2.TDB2Factory;
 import org.apache.jena.tdb2.assembler.VocabTDB2;
 import org.apache.jena.tdb2.store.DatasetGraphSwitchable;
@@ -43,11 +47,11 @@ import org.apache.jena.tdb2.store.DatasetGraphTDB;
 public class ModTDBDataset extends ModDataset {
     // Mixes assembler, location and "tdb"
     // Can make a single model or a dataset
-    
+
     private ArgDecl argMem                  = new ArgDecl(ArgDecl.HasValue, "mem", "data") ;
     private ModTDBAssembler modAssembler    = new ModTDBAssembler() ;
     private String inMemFile                = null ;
-    
+
     public ModTDBDataset() {}
 
     @Override
@@ -75,11 +79,11 @@ public class ModTDBDataset extends ModDataset {
             // Two variants: plain dataset with TDB2 dataset or plain building
             // (which may go wrong later if TDB2 directly is needed).
             try {
-                thing = (Dataset)AssemblerUtils.build(modAssembler.getAssemblerFile(), VocabTDB2.tDatasetTDB);
+                thing = buildDataset(modAssembler);
                 if ( thing != null ) {
                     DatasetGraph dsg = thing.asDatasetGraph();
                     if ( !(dsg instanceof DatasetGraphSwitchable) && !(dsg instanceof DatasetGraphTDB) )
-                        Log.warn(this, "Unexpected: Not a TDB2 dataset for type DatasetTDB2");
+                        Log.warn(this, "Unexpected: Not a TDB2 dataset");
                 }
                 if ( thing == null )
                     // Should use assembler inheritance but how do we assert
@@ -101,6 +105,19 @@ public class ModTDBDataset extends ModDataset {
         return ds;
     }
 
+    /** Build a dataset - several possible names (legacy) */
+    private static Dataset buildDataset(ModTDBAssembler modAssembler) {
+        Model spec = AssemblerUtils.readAssemblerFile(modAssembler.getAssemblerFile());
+        // throws ARQException("Multiple types for: " + tDataset);
+        Resource root = GraphUtils.findRootByType(spec, VocabTDB2.tDatasetTDB);
+        if ( root == null )
+            // Not found - try again.
+            root = GraphUtils.findRootByType(spec, VocabTDB2.tDatasetTDB2);
+        if ( root == null )
+            throw new ARQException("Not found: No such type: <" + VocabTDB2.tDatasetTDB + ">");
+        return (Dataset)Assembler.general.open(root) ;
+    }
+
     public Location getLocation() {
         List<String> x = locations();
         if ( x.size() == 0 )
diff --git a/jena-fuseki2/examples/config-inference-2.ttl b/jena-fuseki2/examples/config-inference-2.ttl
index e7436eb6be..4abf7f95bf 100644
--- a/jena-fuseki2/examples/config-inference-2.ttl
+++ b/jena-fuseki2/examples/config-inference-2.ttl
@@ -45,7 +45,7 @@ PREFIX tdb2:    <http://jena.apache.org/2016/tdb#>
     tdb2:dataset :tdbDataset .
 
 ## Base data in TDB.
-:tdbDataset rdf:type tdb2:DatasetTDB2 ;
+:tdbDataset rdf:type tdb2:DatasetTDB ;
     tdb2:location "DB" ;
     # If the unionDefaultGraph is used, then the "update" service should be removed.
     # tdb:unionDefaultGraph true ;
diff --git a/jena-fuseki2/examples/config-tdb2.ttl b/jena-fuseki2/examples/config-tdb2.ttl
index 999a10e0dd..801f73cbfa 100644
--- a/jena-fuseki2/examples/config-tdb2.ttl
+++ b/jena-fuseki2/examples/config-tdb2.ttl
@@ -46,7 +46,7 @@ PREFIX tdb2:    <http://jena.apache.org/2016/tdb#>
     fuseki:dataset :dataset_tdb2 ;
     .
 
-:dataset_tdb2 rdf:type  tdb2:DatasetTDB2 ;
+:dataset_tdb2 rdf:type  tdb2:DatasetTDB ;
     tdb2:location "DB2" ;
     ## Optional - with union default for query and update WHERE matching.
     ## tdb2:unionDefaultGraph true ;
diff --git a/jena-fuseki2/examples/config-text-tdb2.ttl b/jena-fuseki2/examples/config-text-tdb2.ttl
index c0ad87ab01..57257b8f49 100644
--- a/jena-fuseki2/examples/config-text-tdb2.ttl
+++ b/jena-fuseki2/examples/config-text-tdb2.ttl
@@ -57,7 +57,7 @@ PREFIX text:    <http://jena.apache.org/text#>
     text:index     :indexLucene ;
     .
 
-:base_dataset rdf:type      tdb2:DatasetTDB2 ;
+:base_dataset rdf:type      tdb2:DatasetTDB ;
     tdb2:location "DB2"
     .
 
diff --git a/jena-fuseki2/examples/tdb2-select-graphs.ttl b/jena-fuseki2/examples/tdb2-select-graphs.ttl
index e1f1a49f07..a3597baa97 100644
--- a/jena-fuseki2/examples/tdb2-select-graphs.ttl
+++ b/jena-fuseki2/examples/tdb2-select-graphs.ttl
@@ -74,6 +74,6 @@ PREFIX tdb2:    <http://jena.apache.org/2016/tdb#>
 
 ## The database
 
-:dataset_tdb2  rdf:type tdb2:DatasetTDB2 ;
+:dataset_tdb2  rdf:type tdb2:DatasetTDB ;
     tdb2:location "DB2" ;
     .
diff --git a/jena-shacl/src/main/java/org/apache/jena/shacl/engine/constraint/UniqueLangConstraint.java b/jena-shacl/src/main/java/org/apache/jena/shacl/engine/constraint/UniqueLangConstraint.java
index 4ccaed47c0..21e7267f76 100644
--- a/jena-shacl/src/main/java/org/apache/jena/shacl/engine/constraint/UniqueLangConstraint.java
+++ b/jena-shacl/src/main/java/org/apache/jena/shacl/engine/constraint/UniqueLangConstraint.java
@@ -34,6 +34,7 @@ import org.apache.jena.shacl.engine.ValidationContext;
 import org.apache.jena.shacl.parser.Constraint;
 import org.apache.jena.shacl.parser.ConstraintVisitor;
 import org.apache.jena.shacl.parser.Shape;
+import org.apache.jena.shacl.sys.ShaclSystem;
 import org.apache.jena.shacl.validation.event.ConstraintEvaluatedOnPathNodesEvent;
 import org.apache.jena.shacl.validation.event.ConstraintEvaluatedOnSinglePathNodeEvent;
 import org.apache.jena.shacl.vocabulary.SHACL;
@@ -78,7 +79,8 @@ public class UniqueLangConstraint implements Constraint {
                     String msg = toString()+" Duplicate langtag: "+obj.getLiteralLanguage();
                     vCxt.notifyValidationListener(() -> new ConstraintEvaluatedOnSinglePathNodeEvent(vCxt, shape,  focusNode, this, path, obj,
                                     false));
-                    vCxt.reportEntry(msg, shape, focusNode, path, null, this);
+                    Node valueNode = ShaclSystem.getMode() == ShaclSystem.Mode.TEST ? null : obj;
+                    vCxt.reportEntry(msg, shape, focusNode, path, valueNode, this);
                     results.add(tag);
                 }
                 seen.add(tag);
diff --git a/jena-shacl/src/main/java/org/apache/jena/shacl/sys/ShaclSystem.java b/jena-shacl/src/main/java/org/apache/jena/shacl/sys/ShaclSystem.java
index 507586a96f..527a4bf858 100644
--- a/jena-shacl/src/main/java/org/apache/jena/shacl/sys/ShaclSystem.java
+++ b/jena-shacl/src/main/java/org/apache/jena/shacl/sys/ShaclSystem.java
@@ -36,4 +36,16 @@ public class ShaclSystem {
 
     /** The current system-wide {@link ShaclValidator}. */
     public static ShaclValidator get() { return globalDefault; }
+
+    /** Mode of operation to indicate environment. */
+    public enum Mode { NORMAL, TEST }
+
+    /** Default mode of operation is NORMAL. */
+    private static Mode mode = Mode.NORMAL;
+
+    /** Set the mode of operation. */
+    public static void setMode(Mode mode) { ShaclSystem.mode = mode; }
+
+    /** Get the mode of operation. */
+    public static Mode getMode() { return mode; }
 }
diff --git a/jena-tdb2/src/main/java/org/apache/jena/tdb2/assembler/VocabTDB2.java b/jena-tdb2/src/main/java/org/apache/jena/tdb2/assembler/VocabTDB2.java
index 2f3faecaa0..21bbf41629 100644
--- a/jena-tdb2/src/main/java/org/apache/jena/tdb2/assembler/VocabTDB2.java
+++ b/jena-tdb2/src/main/java/org/apache/jena/tdb2/assembler/VocabTDB2.java
@@ -35,16 +35,20 @@ public class VocabTDB2
 
     // Types
 
+    // Preferred
     public static final Resource tDatasetTDB        = Vocab.type(NS, "DatasetTDB");
+    // Another accept name.
     public static final Resource tDatasetTDB2       = Vocab.type(NS, "DatasetTDB2");
+
     /** @deprecated Use {@link #tDatasetTDB} */
     @Deprecated(forRemoval = true)
     public static final Resource tDatasetTDB_alt    = Vocab.type(NS, "DatasetTDB");
 
     public static final Resource tGraphTDB          = Vocab.type(NS, "GraphTDB");
     public static final Resource tGraphTDB2         = Vocab.type(NS, "GraphTDB2");
+
     /** @deprecated Use {@link #tGraphTDB} */
-    @Deprecated
+    @Deprecated(forRemoval = true)
     public static final Resource tGraphTDB_alt      = Vocab.type(NS, "GraphTDB");
 
 //    public static final Resource tTupleIndex        = Vocab.type(NS, "TupleIndex");


Test patch:
diff --git a/jena-integration-tests/src/test/java/org/apache/jena/test/service/TestCustomServiceExecutor.java b/jena-integration-tests/src/test/java/org/apache/jena/test/service/TestCustomServiceExecutor.java
index 144547f6b9..c31e267ffb 100644
--- a/jena-integration-tests/src/test/java/org/apache/jena/test/service/TestCustomServiceExecutor.java
+++ b/jena-integration-tests/src/test/java/org/apache/jena/test/service/TestCustomServiceExecutor.java
@@ -25,14 +25,23 @@ import java.util.function.Consumer;
 
 import org.apache.jena.atlas.logging.LogCtl;
 import org.apache.jena.query.*;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
 import org.apache.jena.rdf.model.Model;
 import org.apache.jena.rdf.model.ModelFactory;
 import org.apache.jena.riot.ResultSetMgr;
 import org.apache.jena.riot.resultset.ResultSetLang;
+import org.apache.jena.sparql.ARQConstants;
 import org.apache.jena.sparql.algebra.Table;
+import org.apache.jena.sparql.algebra.op.OpService;
+import org.apache.jena.sparql.core.DatasetGraphFactory;
 import org.apache.jena.sparql.core.Var;
+import org.apache.jena.sparql.engine.ExecutionContext;
+import org.apache.jena.sparql.engine.QueryIterator;
 import org.apache.jena.sparql.engine.binding.Binding;
+import org.apache.jena.sparql.exec.QueryExec;
 import org.apache.jena.sparql.resultset.ResultSetCompare;
+import org.apache.jena.sparql.service.ServiceExec;
 import org.apache.jena.sparql.service.ServiceExecutorRegistry;
 import org.apache.jena.sparql.service.single.ServiceExecutor;
 import org.apache.jena.sparql.sse.SSE;
@@ -115,6 +124,104 @@ public class TestCustomServiceExecutor {
         }
     }
 
+    /**
+     * A test case where custom executors both forward requests down the chain as well
+     * as start the chain over using {@link ServiceExec#exec(QueryIterator, OpService, ExecutionContext)}.
+     *
+     * This test case tests the chain for bulk execution.
+     */
+    @Test
+    public void testRestartServiceChainBulk() {
+        Node a = NodeFactory.createURI("urn:a");
+        Node b = NodeFactory.createURI("urn:b");
+        Node c = NodeFactory.createURI("urn:c");
+
+        // The comments are numbered with the expected order of the execution flow.
+
+        ServiceExecutorRegistry reg = new ServiceExecutorRegistry();
+        reg.addBulkLink((opService, input, execCxt, chain) -> {
+            Node node = opService.getService();
+            if (node.equals(a)) {
+                // 2. Match 'a' and restart the chain with 'b'
+                return ServiceExec.exec(new OpService(b, opService.getSubOp(), false), input, execCxt);
+            } else if (node.equals(c)) {
+                // 4. Match 'c' and return the test table
+                return table.iterator(execCxt);
+            } else {
+                throw new RuntimeException("Unexpectedly got: " + node);
+            }
+        });
+
+        reg.addBulkLink((opService, input, execCxt, chain) -> {
+            Node node = opService.getService();
+            if (node.equals(a)) {
+                // 1. Match 'a' and forward 'a'
+                return chain.createExecution(opService, input, execCxt);
+            } else if (node.equals(b)) {
+                // 3. Match 'b' and forward 'c'
+                return chain.createExecution(new OpService(c, opService.getSubOp(), false), input, execCxt);
+            } else {
+                throw new RuntimeException("Unexpectedly got: " + node);
+            }
+        });
+
+        // 0. Start the test with 'a'
+        Table actualTable = QueryExec.dataset(DatasetGraphFactory.empty())
+            .query("SELECT ?s ?p ?o { SERVICE <urn:a> { } }")
+            .set(ARQConstants.registryServiceExecutors, reg)
+            .table();
+        Assert.assertEquals(table, actualTable);
+    }
+
+    /**
+     * A test case where custom executors both forward requests down the chain as well
+     * as start the chain over using {@link ServiceExec#exec(OpService, OpService, Binding, ExecutionContext)}.
+     *
+     * This test case tests the chain for single binding execution.
+     */
+    @Test
+    public void testRestartServiceChainSingle() {
+        Node a = NodeFactory.createURI("urn:a");
+        Node b = NodeFactory.createURI("urn:b");
+        Node c = NodeFactory.createURI("urn:c");
+
+        // The comments are numbered with the expected order of the execution flow.
+
+        ServiceExecutorRegistry reg = new ServiceExecutorRegistry();
+        reg.addSingleLink((opExecute, opOriginal, binding, execCxt, chain) -> {
+            Node node = opExecute.getService();
+            if (node.equals(a)) {
+                // 2. Match 'a' and restart the chain with 'b'
+                return ServiceExec.exec(new OpService(b, opExecute.getSubOp(), false), opOriginal, binding, execCxt);
+            } else if (node.equals(c)) {
+                // 4. Match 'c' and return the test table
+                return table.iterator(execCxt);
+            } else {
+                throw new RuntimeException("Unexpectedly got: " + node);
+            }
+        });
+
+        reg.addSingleLink((opExecute, opOriginal, binding, execCxt, chain) -> {
+            Node node = opExecute.getService();
+            if (node.equals(a)) {
+                // 1. Match 'a' and forward 'a'
+                return chain.createExecution(opExecute, opOriginal, binding, execCxt);
+            } else if (node.equals(b)) {
+                // 3. Match 'b' and forward 'c'
+                return chain.createExecution(new OpService(c, opExecute.getSubOp(), false), opOriginal, binding, execCxt);
+            } else {
+                throw new RuntimeException("Unexpectedly got: " + node);
+            }
+        });
+
+        // 0. Start the test with 'a'
+        Table actualTable = QueryExec.dataset(DatasetGraphFactory.empty())
+            .query("SELECT ?s ?p ?o { SERVICE <urn:a> { } }")
+            .set(ARQConstants.registryServiceExecutors, reg)
+            .table();
+        Assert.assertEquals(table, actualTable);
+    }
+
     // Check to rule out interference with conventional access to remote endpoints.
     // Uncommenting @Test will print out data from the remote endpoint
     //@Test
diff --git a/jena-shacl/src/test/java/org/apache/jena/shacl/tests/std/TestShaclCoreWG.java b/jena-shacl/src/test/java/org/apache/jena/shacl/tests/std/TestShaclCoreWG.java
index 089625728d..b78e26d91f 100644
--- a/jena-shacl/src/test/java/org/apache/jena/shacl/tests/std/TestShaclCoreWG.java
+++ b/jena-shacl/src/test/java/org/apache/jena/shacl/tests/std/TestShaclCoreWG.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
+import org.apache.jena.shacl.sys.ShaclSystem;
 import org.apache.jena.shacl.testing.ShaclTest;
 import org.apache.jena.shacl.testing.ShaclTestItem;
 import org.apache.jena.shacl.testing.ShaclTests;
@@ -43,6 +44,7 @@ public class TestShaclCoreWG {
     private ShaclTestItem test;
 
     public TestShaclCoreWG(String name,  ShaclTestItem test) {
+        ShaclSystem.setMode(ShaclSystem.Mode.TEST);
         this.test = test;
     }