rightIteration) {
- if (rightIteration instanceof EmptyIteration) {
+ if (leftIteration == EMPTY_STATEMENT_ITERATION) {
+ return rightIteration;
+ } else if (rightIteration == EMPTY_STATEMENT_ITERATION) {
+ return leftIteration;
+ } else if (rightIteration instanceof EmptyIteration) {
return leftIteration;
} else if (leftIteration instanceof EmptyIteration) {
return rightIteration;
diff --git a/core/common/order/pom.xml b/core/common/order/pom.xml
index 9fdbb4dac05..5228081fc0e 100644
--- a/core/common/order/pom.xml
+++ b/core/common/order/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-common
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-common-order
RDF4J: common order
diff --git a/core/common/pom.xml b/core/common/pom.xml
index 7c2d8995267..fc821867500 100644
--- a/core/common/pom.xml
+++ b/core/common/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-common
pom
diff --git a/core/common/text/pom.xml b/core/common/text/pom.xml
index 6787dc2930f..f26391a3d11 100644
--- a/core/common/text/pom.xml
+++ b/core/common/text/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-common
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-common-text
RDF4J: common text
diff --git a/core/common/transaction/pom.xml b/core/common/transaction/pom.xml
index 997f5e44f96..2a327eba54c 100644
--- a/core/common/transaction/pom.xml
+++ b/core/common/transaction/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-common
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-common-transaction
RDF4J: common transaction
diff --git a/core/common/xml/pom.xml b/core/common/xml/pom.xml
index 0b25c265142..76184d6e127 100644
--- a/core/common/xml/pom.xml
+++ b/core/common/xml/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-common
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-common-xml
RDF4J: common XML
diff --git a/core/http/client/pom.xml b/core/http/client/pom.xml
index 14eb63b82d9..8e26e6c8d3f 100644
--- a/core/http/client/pom.xml
+++ b/core/http/client/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-http
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-http-client
RDF4J: HTTP client
diff --git a/core/http/pom.xml b/core/http/pom.xml
index 2994077b6f1..ee3e0261ad0 100644
--- a/core/http/pom.xml
+++ b/core/http/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-http
pom
diff --git a/core/http/protocol/pom.xml b/core/http/protocol/pom.xml
index c42876debca..a5d66a693e0 100644
--- a/core/http/protocol/pom.xml
+++ b/core/http/protocol/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-http
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-http-protocol
RDF4J: HTTP protocol
diff --git a/core/http/protocol/src/main/java/org/eclipse/rdf4j/http/protocol/Protocol.java b/core/http/protocol/src/main/java/org/eclipse/rdf4j/http/protocol/Protocol.java
index 0eb50ddb466..7b1e391f3fe 100644
--- a/core/http/protocol/src/main/java/org/eclipse/rdf4j/http/protocol/Protocol.java
+++ b/core/http/protocol/src/main/java/org/eclipse/rdf4j/http/protocol/Protocol.java
@@ -166,6 +166,8 @@ public enum TIMEOUT {
public static final String OFFSET_PARAM_NAME = "offset";
+ public static final String EXPLAIN_PARAM_NAME = "explain";
+
/**
* Parameter name for the query language parameter.
*/
diff --git a/core/model-api/pom.xml b/core/model-api/pom.xml
index bb84bc98273..cf07e38798c 100644
--- a/core/model-api/pom.xml
+++ b/core/model-api/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-model-api
RDF4J: Model API
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/BNode.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/BNode.java
index e49019643f5..53a56e61575 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/BNode.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/BNode.java
@@ -27,6 +27,11 @@ default boolean isBNode() {
return true;
}
+ @Override
+ default Type getType() {
+ return Value.Type.BNode;
+ }
+
/**
* Retrieves this blank node's identifier.
*
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/IRI.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/IRI.java
index cb99a4d4e5b..2f4c641c436 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/IRI.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/IRI.java
@@ -38,6 +38,11 @@ default boolean isIRI() {
return true;
}
+ @Override
+ default Type getType() {
+ return Value.Type.IRI;
+ }
+
/**
* Gets the namespace part of this IRI.
*
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/Literal.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/Literal.java
index b261e76061c..551afa50afd 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/Literal.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/Literal.java
@@ -47,6 +47,11 @@ default boolean isLiteral() {
return true;
}
+ @Override
+ default Type getType() {
+ return Value.Type.Literal;
+ }
+
/**
* Gets the label (the lexical value) of this literal.
*
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/Triple.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/Triple.java
index 29ad625bae7..13ee787c130 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/Triple.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/Triple.java
@@ -33,6 +33,11 @@ default boolean isTriple() {
return true;
}
+ @Override
+ default Type getType() {
+ return Value.Type.Triple;
+ }
+
/**
* Gets the subject of this triple.
*
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/Value.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/Value.java
index d54f18e762d..1fbd933dbc4 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/Value.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/Value.java
@@ -17,6 +17,13 @@
*/
public interface Value extends Serializable {
+ enum Type {
+ IRI,
+ BNode,
+ Literal,
+ Triple
+ }
+
/**
* Check if the object is an instance of the given type. Typically 2x than using instanceof.
*
@@ -72,6 +79,21 @@ default boolean isTriple() {
return false;
}
+ default Type getType() {
+
+ if (isIRI()) {
+ return Type.IRI;
+ } else if (isBNode()) {
+ return Type.BNode;
+ } else if (isLiteral()) {
+ return Type.Literal;
+ } else if (isTriple()) {
+ return Type.Triple;
+ } else {
+ throw new IllegalStateException("Unknown value type");
+ }
+ }
+
/**
* Returns the String-value of a Value object. This returns either a {@link Literal}'s label, a
* {@link IRI}'s URI or a {@link BNode}'s ID.
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/ValueFactory.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/ValueFactory.java
index 00441731e57..058fe6dac08 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/ValueFactory.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/ValueFactory.java
@@ -155,6 +155,16 @@ public interface ValueFactory {
*/
Literal createLiteral(long value);
+ /**
+ * Creates a new typed numerical literal representing the specified value.
+ *
+ * @param value The value for the literal.
+ * @param xsd The XSD datatype to use.
+ */
+ default Literal createLiteral(long value, CoreDatatype.XSD xsd) {
+ return createLiteral(Long.toString(value), xsd);
+ }
+
/**
* Creates a new xsd:float-typed literal representing the specified value.
*
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractLiteral.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractLiteral.java
index 635c12a8847..dd412527a9d 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractLiteral.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractLiteral.java
@@ -402,6 +402,10 @@ private static String toString(double value) {
this(value, Long.toString(value), CoreDatatype.XSD.LONG);
}
+ NumberLiteral(long value, CoreDatatype.XSD datatype) {
+ this(value, Long.toString(value), datatype);
+ }
+
NumberLiteral(float value) {
this(value, toString(value), CoreDatatype.XSD.FLOAT);
}
diff --git a/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractValueFactory.java b/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractValueFactory.java
index e88070a5af3..5670ccb0da3 100644
--- a/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractValueFactory.java
+++ b/core/model-api/src/main/java/org/eclipse/rdf4j/model/base/AbstractValueFactory.java
@@ -128,9 +128,36 @@ public Literal createLiteral(String label, CoreDatatype datatype) {
throw new IllegalArgumentException("reserved datatype <" + datatype + ">");
}
+ if (datatype == CoreDatatype.XSD.INTEGER) {
+ if (label.length() <= 3 && !label.startsWith("+") && !label.startsWith("-")) {
+ try {
+ int v = Integer.parseInt(label);
+ if (v >= 0 && v <= 999 && label.equals(Integer.toString(v))) {
+ return smallIntLiterals[v];
+ }
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ }
+ } else if (datatype == CoreDatatype.XSD.BOOLEAN) {
+ if ("true".equals(label) || "1".equals(label)) {
+ return TRUE;
+ } else if ("false".equals(label) || "0".equals(label)) {
+ return FALSE;
+ }
+ }
+
return new TypedLiteral(label, datatype);
}
+ static TypedLiteral[] smallIntLiterals = new TypedLiteral[1000];
+
+ static {
+ for (int i = 0; i <= 999; i++) {
+ smallIntLiterals[i] = new TypedLiteral(i + "", CoreDatatype.XSD.INTEGER);
+ }
+ }
+
@Override
public Literal createLiteral(String label, IRI datatype, CoreDatatype coreDatatype) {
Objects.requireNonNull(label, "Label may not be null");
@@ -182,6 +209,11 @@ public Literal createLiteral(long value) {
return new NumberLiteral(value);
}
+ @Override
+ public Literal createLiteral(long value, CoreDatatype.XSD datatype) {
+ return new NumberLiteral(value, datatype);
+ }
+
@Override
public Literal createLiteral(float value) {
return new NumberLiteral(value);
diff --git a/core/model-vocabulary/pom.xml b/core/model-vocabulary/pom.xml
index efd41a48f6c..bd27791c1d8 100644
--- a/core/model-vocabulary/pom.xml
+++ b/core/model-vocabulary/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-model-vocabulary
RDF4J: RDF Vocabularies
diff --git a/core/model/pom.xml b/core/model/pom.xml
index 74cca9bb0c3..17a4deb868b 100644
--- a/core/model/pom.xml
+++ b/core/model/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-model
RDF4J: Model
diff --git a/core/model/src/main/java/org/eclipse/rdf4j/model/impl/SimpleValueFactory.java b/core/model/src/main/java/org/eclipse/rdf4j/model/impl/SimpleValueFactory.java
index b9b685b7fcd..6720d9d034e 100644
--- a/core/model/src/main/java/org/eclipse/rdf4j/model/impl/SimpleValueFactory.java
+++ b/core/model/src/main/java/org/eclipse/rdf4j/model/impl/SimpleValueFactory.java
@@ -165,6 +165,12 @@ public Literal createLiteral(long value) {
return createIntegerLiteral(value, CoreDatatype.XSD.LONG);
}
+ @Override
+ public Literal createLiteral(long value, CoreDatatype.XSD xsd) {
+ assert xsd.isIntegerDatatype();
+ return createIntegerLiteral(value, xsd);
+ }
+
/**
* Calls {@link #createNumericLiteral(Number, IRI)} with the supplied value and datatype as parameters.
*/
diff --git a/core/model/src/main/java/org/eclipse/rdf4j/model/impl/ValidatingValueFactory.java b/core/model/src/main/java/org/eclipse/rdf4j/model/impl/ValidatingValueFactory.java
index 0cfe2860938..62664cbfc5f 100644
--- a/core/model/src/main/java/org/eclipse/rdf4j/model/impl/ValidatingValueFactory.java
+++ b/core/model/src/main/java/org/eclipse/rdf4j/model/impl/ValidatingValueFactory.java
@@ -175,6 +175,11 @@ public Literal createLiteral(long value) {
return delegate.createLiteral(value);
}
+ @Override
+ public Literal createLiteral(long value, CoreDatatype.XSD xsd) {
+ return delegate.createLiteral(value, xsd);
+ }
+
@Override
public Literal createLiteral(float value) {
return delegate.createLiteral(value);
diff --git a/core/pom.xml b/core/pom.xml
index 262143c849c..ed94faedcde 100644
--- a/core/pom.xml
+++ b/core/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-core
pom
diff --git a/core/query/pom.xml b/core/query/pom.xml
index a82e0d8f8b5..bdc13ce11be 100644
--- a/core/query/pom.xml
+++ b/core/query/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-query
RDF4J: Query
@@ -41,5 +41,10 @@
junit-platform-suite-engine
test
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
diff --git a/core/query/src/main/java/org/eclipse/rdf4j/query/AbstractBindingSet.java b/core/query/src/main/java/org/eclipse/rdf4j/query/AbstractBindingSet.java
index dd5108c77cf..d5b34acda58 100644
--- a/core/query/src/main/java/org/eclipse/rdf4j/query/AbstractBindingSet.java
+++ b/core/query/src/main/java/org/eclipse/rdf4j/query/AbstractBindingSet.java
@@ -26,6 +26,9 @@ public abstract class AbstractBindingSet implements BindingSet {
@Override
public boolean equals(Object other) {
+ if (other == null) {
+ return false;
+ }
if (this == other) {
return true;
}
@@ -61,7 +64,7 @@ public boolean equals(Object other) {
}
@Override
- public final int hashCode() {
+ public int hashCode() {
int hashCode = 0;
for (Binding binding : this) {
diff --git a/core/query/src/main/java/org/eclipse/rdf4j/query/BindingSet.java b/core/query/src/main/java/org/eclipse/rdf4j/query/BindingSet.java
index 312932949ad..222146ac04a 100644
--- a/core/query/src/main/java/org/eclipse/rdf4j/query/BindingSet.java
+++ b/core/query/src/main/java/org/eclipse/rdf4j/query/BindingSet.java
@@ -101,4 +101,19 @@ public interface BindingSet extends Iterable, Serializable {
default boolean isEmpty() {
return size() == 0;
}
+
+ /**
+ * Check whether this BindingSet is compatible with another. Two binding sets are compatible if they have equal
+ * values for each variable that is bound in both binding sets. A variable that is unbound in either set is
+ * considered compatible.
+ *
+ *
+ * Default implementation mirrors {@link QueryResults#bindingSetsCompatible(BindingSet, BindingSet)}.
+ *
+ * @param other the other binding set to compare with
+ * @return true if compatible
+ */
+ default boolean isCompatible(BindingSet other) {
+ return QueryResults.bindingSetsCompatible(this, other);
+ }
}
diff --git a/core/query/src/main/java/org/eclipse/rdf4j/query/QueryResults.java b/core/query/src/main/java/org/eclipse/rdf4j/query/QueryResults.java
index 659d3e97d2f..73cf7be1bca 100644
--- a/core/query/src/main/java/org/eclipse/rdf4j/query/QueryResults.java
+++ b/core/query/src/main/java/org/eclipse/rdf4j/query/QueryResults.java
@@ -540,20 +540,19 @@ private static boolean bindingSetsMatch(BindingSet bs1, BindingSet bs2, Map bs1BindingNames = bs1.getBindingNames();
- if (bs1BindingNames.isEmpty()) {
+ if (bs1.isEmpty() || bs2.isEmpty()) {
return true;
}
- Set bs2BindingNames = bs2.getBindingNames();
-
for (Binding binding : bs1) {
- if (bs2BindingNames.contains(binding.getName())) {
+ Binding other = bs2.getBinding(binding.getName());
+
+ if (other != null) {
Value value1 = binding.getValue();
// if a variable is unbound in one set it is compatible
if (value1 != null) {
- Value value2 = bs2.getValue(binding.getName());
+ Value value2 = other.getValue();
// if a variable is unbound in one set it is compatible
if (value2 != null && !value1.equals(value2)) {
diff --git a/core/query/src/test/java/org/eclipse/rdf4j/query/BindingSetCompatibleTest.java b/core/query/src/test/java/org/eclipse/rdf4j/query/BindingSetCompatibleTest.java
new file mode 100644
index 00000000000..0d579fef3f6
--- /dev/null
+++ b/core/query/src/test/java/org/eclipse/rdf4j/query/BindingSetCompatibleTest.java
@@ -0,0 +1,201 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+
+package org.eclipse.rdf4j.query;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.stream.Stream;
+
+import org.eclipse.rdf4j.model.Value;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.impl.ListBindingSet;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests for BindingSet compatibility API. Verifies that BindingSet#isCompatible has identical semantics to
+ * QueryResults#bindingSetsCompatible across a variety of scenarios.
+ */
+public class BindingSetCompatibleTest {
+
+ private static final ValueFactory VF = SimpleValueFactory.getInstance();
+
+ @Test
+ public void isCompatible_exists_and_matches_QueryResults() throws Exception {
+ // Prefer the current API name; fallback to legacy name if present
+ Method m;
+ try {
+ m = BindingSet.class.getMethod("isCompatible", BindingSet.class);
+ } catch (NoSuchMethodException e1) {
+ try {
+ m = BindingSet.class.getMethod("bindingSetCompatible", BindingSet.class);
+ } catch (NoSuchMethodException e2) {
+ fail("BindingSet#isCompatible(BindingSet) method is missing");
+ return;
+ }
+ }
+
+ // Verify semantics align with QueryResults.bindingSetsCompatible on a few basic cases
+ List names = Arrays.asList("a", "b");
+
+ BindingSet s1 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
+ BindingSet s2 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(2));
+ boolean expected = QueryResults.bindingSetsCompatible(s1, s2);
+ boolean actual = (Boolean) m.invoke(s1, s2);
+ assertThat(actual).isEqualTo(expected);
+
+ BindingSet s3 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
+ BindingSet s4 = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
+ expected = QueryResults.bindingSetsCompatible(s3, s4);
+ actual = (Boolean) m.invoke(s3, s4);
+ assertThat(actual).isEqualTo(expected);
+
+ BindingSet s5 = new ListBindingSet(names, null, VF.createLiteral(1));
+ BindingSet s6 = new ListBindingSet(names, null, VF.createLiteral(2));
+ expected = QueryResults.bindingSetsCompatible(s5, s6);
+ actual = (Boolean) m.invoke(s5, s6);
+ assertThat(actual).isEqualTo(expected);
+ }
+
+ @Test
+ public void isCompatible_empty_sets_true() {
+ BindingSet empty1 = new ListBindingSet(List.of());
+ BindingSet empty2 = new ListBindingSet(List.of());
+ assertThat(empty1.isCompatible(empty2)).isTrue();
+ assertThat(empty2.isCompatible(empty1)).isTrue();
+ }
+
+ @Test
+ public void isCompatible_one_empty_true() {
+ List names = Arrays.asList("a", "b");
+ BindingSet nonEmpty = new ListBindingSet(names, VF.createIRI("urn:x"), VF.createLiteral(1));
+ BindingSet empty = new ListBindingSet(List.of());
+ assertThat(nonEmpty.isCompatible(empty)).isTrue();
+ assertThat(empty.isCompatible(nonEmpty)).isTrue();
+ }
+
+ @Test
+ public void isCompatible_disjoint_names_true() {
+ BindingSet aOnly = new ListBindingSet(List.of("a"), VF.createLiteral(1));
+ BindingSet bOnly = new ListBindingSet(List.of("b"), VF.createLiteral(2));
+ assertThat(aOnly.isCompatible(bOnly)).isTrue();
+ assertThat(bOnly.isCompatible(aOnly)).isTrue();
+ }
+
+ @Test
+ public void isCompatible_partial_overlap_true_when_equal() {
+ List namesAB = Arrays.asList("a", "b");
+ BindingSet ab = new ListBindingSet(namesAB, VF.createIRI("urn:x"), VF.createLiteral(1));
+ BindingSet b = new ListBindingSet(List.of("b"), VF.createLiteral(1));
+ assertThat(ab.isCompatible(b)).isTrue();
+ assertThat(b.isCompatible(ab)).isTrue();
+ }
+
+ @Test
+ public void isCompatible_partial_overlap_false_when_conflict() {
+ List namesAB = Arrays.asList("a", "b");
+ BindingSet ab = new ListBindingSet(namesAB, VF.createIRI("urn:x"), VF.createLiteral(1));
+ BindingSet bConflict = new ListBindingSet(List.of("b"), VF.createLiteral(2));
+ assertThat(ab.isCompatible(bConflict)).isFalse();
+ assertThat(bConflict.isCompatible(ab)).isFalse();
+ }
+
+ @Test
+ public void isCompatible_null_variable_ignored_when_overlap_equal() {
+ List namesAB = Arrays.asList("a", "b");
+ BindingSet s1 = new ListBindingSet(namesAB, null, VF.createLiteral(1));
+ BindingSet s2 = new ListBindingSet(namesAB, null, VF.createLiteral(1));
+ assertThat(s1.isCompatible(s2)).isTrue();
+ assertThat(s2.isCompatible(s1)).isTrue();
+ }
+
+ @ParameterizedTest(name = "fuzz case {index}")
+ @MethodSource("fuzzCases")
+ public void isCompatible_fuzz_parity_and_symmetry(BindingSet s1, BindingSet s2) {
+ boolean expected = QueryResults.bindingSetsCompatible(s1, s2);
+ assertThat(s1.isCompatible(s2)).isEqualTo(expected);
+ // symmetry
+ boolean expectedReverse = QueryResults.bindingSetsCompatible(s2, s1);
+ assertThat(s2.isCompatible(s1)).isEqualTo(expectedReverse);
+ }
+
+ static Stream fuzzCases() {
+ Random rnd = new Random(424242);
+ List universe = Arrays.asList("a", "b", "c", "d", "e", "x", "y", "z");
+ int cases = 128; // balanced coverage and speed
+ Stream.Builder b = Stream.builder();
+ for (int i = 0; i < cases; i++) {
+ BindingSet s1 = randomBindingSet(rnd, universe);
+ BindingSet s2 = randomBindingSet(rnd, universe);
+ b.add(Arguments.of(s1, s2));
+ }
+ return b.build();
+ }
+
+ private static BindingSet randomBindingSet(Random rnd, List universe) {
+ // Randomly decide how many variables to include (possibly zero)
+ int n = rnd.nextInt(universe.size() + 1); // 0..universe.size()
+ // Shuffle-like selection via random threshold
+ java.util.ArrayList selected = new java.util.ArrayList<>(universe.size());
+ for (String name : universe) {
+ if (selected.size() >= n) {
+ break;
+ }
+ // ~50% chance to include each name until we reach n
+ if (rnd.nextBoolean()) {
+ selected.add(name);
+ }
+ }
+ // If selection under-filled, top up from remaining deterministically
+ for (String name : universe) {
+ if (selected.size() >= n) {
+ break;
+ }
+ if (!selected.contains(name)) {
+ selected.add(name);
+ }
+ }
+
+ Value[] values = new Value[selected.size()];
+ for (int i = 0; i < selected.size(); i++) {
+ values[i] = randomValueOrNull(rnd, i);
+ }
+ return new ListBindingSet(selected, values);
+ }
+
+ private static org.eclipse.rdf4j.model.Value randomValueOrNull(Random rnd, int salt) {
+ int pick = rnd.nextInt(6);
+ switch (pick) {
+ case 0:
+ return null; // unbound
+ case 1:
+ return VF.createIRI("urn:res:" + rnd.nextInt(10));
+ case 2:
+ return VF.createLiteral(rnd.nextInt(5));
+ case 3:
+ return VF.createLiteral("s" + rnd.nextInt(5));
+ case 4:
+ return VF.createBNode("b" + rnd.nextInt(5));
+ default:
+ return VF.createIRI("urn:x:" + ((salt + rnd.nextInt(5)) % 7));
+ }
+ }
+
+}
diff --git a/core/queryalgebra/evaluation/pom.xml b/core/queryalgebra/evaluation/pom.xml
index 535581f475f..a29c5f1ab29 100644
--- a/core/queryalgebra/evaluation/pom.xml
+++ b/core/queryalgebra/evaluation/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryalgebra
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryalgebra-evaluation
RDF4J: Query algebra - evaluation
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/ArrayBindingSet.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/ArrayBindingSet.java
index 08b7960e499..9b0fa63b047 100644
--- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/ArrayBindingSet.java
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/ArrayBindingSet.java
@@ -17,6 +17,7 @@
import java.util.LinkedHashSet;
import java.util.List;
import java.util.NoSuchElementException;
+import java.util.Objects;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Function;
@@ -53,6 +54,7 @@ public class ArrayBindingSet extends AbstractBindingSet implements MutableBindin
private boolean empty;
private final Value[] values;
+ private int cachedHashCode;
/**
* Creates a new Array-based BindingSet for the supplied bindings names. The supplied list of binding names is
@@ -292,6 +294,62 @@ public int size() {
return size;
}
+ @Override
+ public boolean isCompatible(BindingSet other) {
+ if (isEmpty() || other.isEmpty()) {
+ return true;
+ }
+
+ if (other instanceof ArrayBindingSet) {
+ ArrayBindingSet o = (ArrayBindingSet) other;
+ // Fast path when we share the same bindingNames array (identity equality)
+
+ if (this.bindingNames == o.bindingNames) {
+ return fastIsCompatible(o);
+ }
+ }
+
+ return slowIsCompatible(other);
+ }
+
+ private boolean fastIsCompatible(ArrayBindingSet o) {
+ for (int i = 0; i < this.values.length; i++) {
+ Value v1 = this.values[i];
+ if (v1 != null && v1 != NULL_VALUE) {
+ Value v2 = o.values[i];
+ if (v2 != null && v2 != NULL_VALUE) {
+ if (v1.getType() != v2.getType()) {
+ return false;
+ }
+ if (!v1.equals(v2)) {
+ return false;
+ }
+ }
+ }
+ }
+ return true;
+ }
+
+ private boolean slowIsCompatible(BindingSet other) {
+ // General path: iterate our bound values and compare against the other's value by name
+ for (int i = 0; i < this.bindingNames.length; i++) {
+ Value v1 = this.values[i];
+ if (v1 != null && v1 != NULL_VALUE) {
+ Value v2 = other.getValue(this.bindingNames[i]);
+ if (v2 != null) {
+ if (v1.getType() != v2.getType()) {
+ return false;
+ }
+ if (!v1.equals(v2)) {
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
List sortedBindingNames = null;
public List getSortedBindingNames() {
@@ -379,6 +437,7 @@ public boolean isEmpty() {
private void clearCache() {
bindingNamesSetCache = null;
+ cachedHashCode = 0;
}
public void addAll(ArrayBindingSet other) {
@@ -403,6 +462,53 @@ public void addAll(ArrayBindingSet other) {
}
+ @Override
+ public int hashCode() {
+ if (cachedHashCode == 0) {
+ cachedHashCode = super.hashCode();
+ }
+ return cachedHashCode;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == null) {
+ return false;
+ }
+ if (other == this) {
+ return true;
+ }
+
+ if (other.getClass() != ArrayBindingSet.class) {
+ return super.equals(other);
+ }
+
+ ArrayBindingSet o = (ArrayBindingSet) other;
+ if (empty && o.empty) {
+ return true;
+ }
+ if (empty != o.empty) {
+ return false;
+ }
+ if (size() != o.size()) {
+ return false;
+ }
+
+ if (bindingNames == o.bindingNames) {
+ for (int i = 0; i < values.length; i++) {
+ if (values[i] != o.values[i]) {
+ if (!Objects.equals(values[i], o.values[i])) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ return super.equals(other);
+
+ }
+
private class ArrayBindingSetIterator implements Iterator {
private int index = 0;
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/DefaultEvaluationStrategy.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/DefaultEvaluationStrategy.java
index 901557a1b78..76714b12d75 100644
--- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/DefaultEvaluationStrategy.java
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/DefaultEvaluationStrategy.java
@@ -603,11 +603,31 @@ protected QueryEvaluationStep prepare(Slice node, QueryEvaluationContext context
protected QueryEvaluationStep prepare(Extension node, QueryEvaluationContext context)
throws QueryEvaluationException {
QueryEvaluationStep arg = precompile(node.getArg(), context);
+ boolean setNullOnError = !isWithinMinusRightArg(node);
Consumer consumer = ExtensionIterator.buildLambdaToEvaluateTheExpressions(node, this,
- context);
+ context, setNullOnError);
return new ExtensionQueryEvaluationStep(arg, consumer, context);
}
+ private static boolean isWithinMinusRightArg(QueryModelNode node) {
+ QueryModelNode child = node;
+ QueryModelNode parent = node.getParentNode();
+ while (parent != null) {
+ if (parent instanceof Difference) {
+ Difference diff = (Difference) parent;
+ if (diff.getRightArg() == child) {
+ return true;
+ }
+ if (diff.getLeftArg() == child) {
+ return false;
+ }
+ }
+ child = parent;
+ parent = parent.getParentNode();
+ }
+ return false;
+ }
+
protected QueryEvaluationStep prepare(Service service, QueryEvaluationContext context)
throws QueryEvaluationException {
Var serviceRef = service.getServiceRef();
@@ -1321,7 +1341,18 @@ protected QueryValueEvaluationStep prepare(CompareAll node, QueryEvaluationConte
protected QueryValueEvaluationStep prepare(Exists node, QueryEvaluationContext context)
throws QueryEvaluationException {
- QueryEvaluationStep subquery = precompile(node.getSubQuery(), context);
+ // Optimization/semantic shortcut: EXISTS { OPTIONAL { ... } } is always true.
+ // In algebra, OPTIONAL is a LeftJoin. With a SingletonSet left-arg, LeftJoin
+ // always yields at least the input binding set. Therefore EXISTS evaluates to TRUE.
+ TupleExpr subQuery = node.getSubQuery();
+ if (subQuery instanceof LeftJoin) {
+ LeftJoin leftJoin = (LeftJoin) subQuery;
+ if (leftJoin.getLeftArg() instanceof SingletonSet) {
+ return bindings -> BooleanLiteral.TRUE;
+ }
+ }
+
+ QueryEvaluationStep subquery = precompile(subQuery, context);
return new ExistsQueryValueEvaluationStep(subquery);
}
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementConvertorWithoutBindingChecks.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementConvertorWithoutBindingChecks.java
new file mode 100644
index 00000000000..bcc0b654798
--- /dev/null
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementConvertorWithoutBindingChecks.java
@@ -0,0 +1,158 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+
+package org.eclipse.rdf4j.query.algebra.evaluation.impl.evaluationsteps;
+
+import java.util.function.BiConsumer;
+
+import org.eclipse.rdf4j.model.Statement;
+import org.eclipse.rdf4j.model.Value;
+import org.eclipse.rdf4j.query.MutableBindingSet;
+import org.eclipse.rdf4j.query.algebra.Var;
+import org.eclipse.rdf4j.query.algebra.evaluation.impl.QueryEvaluationContext;
+
+class StatementConvertorWithoutBindingChecks {
+
+ private StatementConvertorWithoutBindingChecks() {
+ }
+
+ public static BiConsumer s(QueryEvaluationContext context, Var s) {
+ BiConsumer setS = context.addBinding(s.getName());
+ return (result, st) -> setS.accept(st.getSubject(), result);
+ }
+
+ public static BiConsumer p(QueryEvaluationContext context, Var p) {
+ BiConsumer setP = context.addBinding(p.getName());
+ return (result, st) -> setP.accept(st.getPredicate(), result);
+ }
+
+ public static BiConsumer o(QueryEvaluationContext context, Var o) {
+ BiConsumer setO = context.addBinding(o.getName());
+ return (result, st) -> setO.accept(st.getObject(), result);
+ }
+
+ public static BiConsumer c(QueryEvaluationContext context, Var c) {
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> setC.accept(st.getContext(), result);
+ }
+
+ public static BiConsumer sp(QueryEvaluationContext context, Var s, Var p) {
+ BiConsumer setS = context.addBinding(s.getName());
+ BiConsumer setP = context.addBinding(p.getName());
+ return (result, st) -> {
+ setS.accept(st.getSubject(), result);
+ setP.accept(st.getPredicate(), result);
+ };
+ }
+
+ public static BiConsumer so(QueryEvaluationContext context, Var s, Var o) {
+ BiConsumer setS = context.addBinding(s.getName());
+ BiConsumer setO = context.addBinding(o.getName());
+ return (result, st) -> {
+ setS.accept(st.getSubject(), result);
+ setO.accept(st.getObject(), result);
+ };
+ }
+
+ public static BiConsumer sc(QueryEvaluationContext context, Var s, Var c) {
+ BiConsumer setS = context.addBinding(s.getName());
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> {
+ setS.accept(st.getSubject(), result);
+ setC.accept(st.getContext(), result);
+ };
+ }
+
+ public static BiConsumer po(QueryEvaluationContext context, Var p, Var o) {
+ BiConsumer setP = context.addBinding(p.getName());
+ BiConsumer setO = context.addBinding(o.getName());
+ return (result, st) -> {
+ setP.accept(st.getPredicate(), result);
+ setO.accept(st.getObject(), result);
+ };
+ }
+
+ public static BiConsumer pc(QueryEvaluationContext context, Var p, Var c) {
+ BiConsumer setP = context.addBinding(p.getName());
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> {
+ setP.accept(st.getPredicate(), result);
+ setC.accept(st.getContext(), result);
+ };
+ }
+
+ public static BiConsumer oc(QueryEvaluationContext context, Var o, Var c) {
+ BiConsumer setO = context.addBinding(o.getName());
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> {
+ setO.accept(st.getObject(), result);
+ setC.accept(st.getContext(), result);
+ };
+ }
+
+ public static BiConsumer spo(QueryEvaluationContext context, Var s, Var p, Var o) {
+ BiConsumer setS = context.addBinding(s.getName());
+ BiConsumer setP = context.addBinding(p.getName());
+ BiConsumer setO = context.addBinding(o.getName());
+ return (result, st) -> {
+ setS.accept(st.getSubject(), result);
+ setP.accept(st.getPredicate(), result);
+ setO.accept(st.getObject(), result);
+ };
+ }
+
+ public static BiConsumer spc(QueryEvaluationContext context, Var s, Var p, Var c) {
+ BiConsumer setS = context.addBinding(s.getName());
+ BiConsumer setP = context.addBinding(p.getName());
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> {
+ setS.accept(st.getSubject(), result);
+ setP.accept(st.getPredicate(), result);
+ setC.accept(st.getContext(), result);
+ };
+ }
+
+ public static BiConsumer soc(QueryEvaluationContext context, Var s, Var o, Var c) {
+ BiConsumer setS = context.addBinding(s.getName());
+ BiConsumer setO = context.addBinding(o.getName());
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> {
+ setS.accept(st.getSubject(), result);
+ setO.accept(st.getObject(), result);
+ setC.accept(st.getContext(), result);
+ };
+ }
+
+ public static BiConsumer poc(QueryEvaluationContext context, Var p, Var o, Var c) {
+ BiConsumer setP = context.addBinding(p.getName());
+ BiConsumer setO = context.addBinding(o.getName());
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> {
+ setP.accept(st.getPredicate(), result);
+ setO.accept(st.getObject(), result);
+ setC.accept(st.getContext(), result);
+ };
+ }
+
+ public static BiConsumer spoc(QueryEvaluationContext context, Var s, Var p, Var o,
+ Var c) {
+ BiConsumer setS = context.addBinding(s.getName());
+ BiConsumer setP = context.addBinding(p.getName());
+ BiConsumer setO = context.addBinding(o.getName());
+ BiConsumer setC = context.addBinding(c.getName());
+ return (result, st) -> {
+ setS.accept(st.getSubject(), result);
+ setP.accept(st.getPredicate(), result);
+ setO.accept(st.getObject(), result);
+ setC.accept(st.getContext(), result);
+ };
+ }
+}
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java
index f816aea617b..c9e525bd172 100644
--- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStep.java
@@ -53,7 +53,8 @@ public class StatementPatternQueryEvaluationStep implements QueryEvaluationStep
private final TripleSource tripleSource;
private final boolean emptyGraph;
private final Function contextSup;
- private final BiConsumer converter;
+ private BiConsumer converter;
+ private BiConsumer convertStatementConverter;
private final QueryEvaluationContext context;
private final StatementOrder order;
@@ -64,6 +65,11 @@ public class StatementPatternQueryEvaluationStep implements QueryEvaluationStep
private final Function getPredicateVar;
private final Function getObjectVar;
+ private final Var normalizedSubjectVar;
+ private final Var normalizedPredicateVar;
+ private final Var normalizedObjectVar;
+ private final Var normalizedContextVar;
+
// We try to do as much work as possible in the constructor.
// With the aim of making the evaluate method as cheap as possible.
public StatementPatternQueryEvaluationStep(StatementPattern statementPattern, QueryEvaluationContext context,
@@ -137,9 +143,13 @@ public StatementPatternQueryEvaluationStep(StatementPattern statementPattern, Qu
}
}
- converter = makeConverter(context, subjVar, predVar, objVar, conVar);
+ normalizedSubjectVar = subjVar;
+ normalizedPredicateVar = predVar;
+ normalizedObjectVar = objVar;
+ normalizedContextVar = conVar;
- unboundTest = getUnboundTest(context, subjVar, predVar, objVar, conVar);
+ unboundTest = getUnboundTest(context, normalizedSubjectVar, normalizedPredicateVar, normalizedObjectVar,
+ normalizedContextVar);
}
@@ -276,7 +286,7 @@ private JoinStatementWithBindingSetIterator getIteration(BindingSet bindings) {
iteration = handleFilter(contexts, (Resource) subject, (IRI) predicate, object, iteration);
// Return an iterator that converts the statements to var bindings
- return new JoinStatementWithBindingSetIterator(iteration, converter, bindings, context);
+ return new JoinStatementWithBindingSetIterator(iteration, getConverter(), bindings, context);
} catch (Throwable t) {
if (iteration != null) {
iteration.close();
@@ -328,7 +338,7 @@ private ConvertStatementToBindingSetIterator getIteration() {
iteration = handleFilter(contexts, (Resource) subject, (IRI) predicate, object, iteration);
// Return an iterator that converts the statements to var bindings
- return new ConvertStatementToBindingSetIterator(iteration, converter, context);
+ return new ConvertStatementToBindingSetIterator(iteration, getConvertStatementConverter(), context);
} catch (Throwable t) {
if (iteration != null) {
iteration.close();
@@ -340,6 +350,36 @@ private ConvertStatementToBindingSetIterator getIteration() {
}
}
+ private BiConsumer getConverter() {
+ BiConsumer localConverter = converter;
+ if (localConverter == null) {
+ synchronized (this) {
+ localConverter = converter;
+ if (localConverter == null) {
+ localConverter = makeConverter(context, normalizedSubjectVar, normalizedPredicateVar,
+ normalizedObjectVar, normalizedContextVar);
+ converter = localConverter;
+ }
+ }
+ }
+ return localConverter;
+ }
+
+ private BiConsumer getConvertStatementConverter() {
+ BiConsumer localConverter = convertStatementConverter;
+ if (localConverter == null) {
+ synchronized (this) {
+ localConverter = convertStatementConverter;
+ if (localConverter == null) {
+ localConverter = makeConvertStatementConverter(context, normalizedSubjectVar,
+ normalizedPredicateVar, normalizedObjectVar, normalizedContextVar);
+ convertStatementConverter = localConverter;
+ }
+ }
+ }
+ return localConverter;
+ }
+
private CloseableIteration extends Statement> handleFilter(Resource[] contexts,
Resource subject, IRI predicate, Value object,
CloseableIteration extends Statement> iteration) {
@@ -526,23 +566,23 @@ private static Resource[] fillContextsFromDatasSetGraphs(Set graphs) {
private static final class ConvertStatementToBindingSetIterator
implements CloseableIteration {
- private final BiConsumer action;
+ private final BiConsumer converter;
private final QueryEvaluationContext context;
private final CloseableIteration extends Statement> iteration;
private boolean closed = false;
private ConvertStatementToBindingSetIterator(
CloseableIteration extends Statement> iteration,
- BiConsumer action, QueryEvaluationContext context) {
+ BiConsumer converter, QueryEvaluationContext context) {
assert iteration != null;
this.iteration = iteration;
- this.action = action;
+ this.converter = converter;
this.context = context;
}
private BindingSet convert(Statement st) {
MutableBindingSet made = context.createBindingSet();
- action.accept(made, st);
+ converter.accept(made, st);
return made;
}
@@ -573,7 +613,7 @@ public void close() throws QueryEvaluationException {
private static final class JoinStatementWithBindingSetIterator
implements CloseableIteration {
- private final BiConsumer action;
+ private final BiConsumer converter;
private final QueryEvaluationContext context;
private final BindingSet bindings;
private final CloseableIteration extends Statement> iteration;
@@ -581,11 +621,12 @@ private static final class JoinStatementWithBindingSetIterator
private JoinStatementWithBindingSetIterator(
CloseableIteration extends Statement> iteration,
- BiConsumer action, BindingSet bindings, QueryEvaluationContext context) {
+ BiConsumer converter, BindingSet bindings,
+ QueryEvaluationContext context) {
assert iteration != null;
this.iteration = iteration;
assert !bindings.isEmpty();
- this.action = action;
+ this.converter = converter;
this.context = context;
this.bindings = bindings;
@@ -593,7 +634,7 @@ private JoinStatementWithBindingSetIterator(
private BindingSet convert(Statement st) {
MutableBindingSet made = context.createBindingSet(bindings);
- action.accept(made, st);
+ converter.accept(made, st);
return made;
}
@@ -683,6 +724,60 @@ private static BiConsumer makeConverter(QueryEvalu
}
+ private static BiConsumer makeConvertStatementConverter(
+ QueryEvaluationContext context,
+ Var s, Var p, Var o, Var c) {
+
+ if (s != null && !s.isConstant()) {
+ if (p != null && !p.isConstant()) {
+ if (o != null && !o.isConstant()) {
+ if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.spoc(context, s, p, o, c);
+ } else {
+ return StatementConvertorWithoutBindingChecks.spo(context, s, p, o);
+ }
+ } else if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.spc(context, s, p, c);
+ } else {
+ return StatementConvertorWithoutBindingChecks.sp(context, s, p);
+ }
+ } else if (o != null && !o.isConstant()) {
+ if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.soc(context, s, o, c);
+ } else {
+ return StatementConvertorWithoutBindingChecks.so(context, s, o);
+ }
+ } else if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.sc(context, s, c);
+ } else {
+ return StatementConvertorWithoutBindingChecks.s(context, s);
+ }
+ } else if (p != null && !p.isConstant()) {
+ if (o != null && !o.isConstant()) {
+ if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.poc(context, p, o, c);
+ } else {
+ return StatementConvertorWithoutBindingChecks.po(context, p, o);
+ }
+ } else if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.pc(context, p, c);
+ } else {
+ return StatementConvertorWithoutBindingChecks.p(context, p);
+ }
+ } else if (o != null && !o.isConstant()) {
+ if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.oc(context, o, c);
+ } else {
+ return StatementConvertorWithoutBindingChecks.o(context, o);
+ }
+ } else if (c != null && !c.isConstant()) {
+ return StatementConvertorWithoutBindingChecks.c(context, c);
+ }
+
+ return (a, b) -> {
+ };
+ }
+
private static Predicate andThen(Predicate pred, Predicate and) {
if (pred == null) {
return and;
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/BadlyDesignedLeftJoinIterator.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/BadlyDesignedLeftJoinIterator.java
index a80e93c01b6..be0eae6c875 100644
--- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/BadlyDesignedLeftJoinIterator.java
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/BadlyDesignedLeftJoinIterator.java
@@ -15,13 +15,10 @@
import org.eclipse.rdf4j.query.BindingSet;
import org.eclipse.rdf4j.query.MutableBindingSet;
import org.eclipse.rdf4j.query.QueryEvaluationException;
-import org.eclipse.rdf4j.query.QueryResults;
import org.eclipse.rdf4j.query.algebra.LeftJoin;
import org.eclipse.rdf4j.query.algebra.evaluation.EvaluationStrategy;
import org.eclipse.rdf4j.query.algebra.evaluation.QueryBindingSet;
import org.eclipse.rdf4j.query.algebra.evaluation.QueryEvaluationStep;
-import org.eclipse.rdf4j.query.algebra.evaluation.QueryValueEvaluationStep;
-import org.eclipse.rdf4j.query.algebra.evaluation.impl.QueryEvaluationContext;
/**
* @author Arjohn Kampman
@@ -71,7 +68,7 @@ protected BindingSet getNextElement() throws QueryEvaluationException {
BindingSet result = super.getNextElement();
// Ignore all results that are not compatible with the input bindings
- while (result != null && !QueryResults.bindingSetsCompatible(inputBindings, result)) {
+ while (result != null && !inputBindings.isCompatible(result)) {
result = super.getNextElement();
}
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/ExtensionIterator.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/ExtensionIterator.java
index 8c6fbbd65ff..af2c049a773 100644
--- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/ExtensionIterator.java
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/ExtensionIterator.java
@@ -37,7 +37,7 @@ public ExtensionIterator(Extension extension, CloseableIteration ite
EvaluationStrategy strategy, QueryEvaluationContext context) throws QueryEvaluationException {
super(iter);
this.context = context;
- this.setter = buildLambdaToEvaluateTheExpressions(extension, strategy, context);
+ this.setter = buildLambdaToEvaluateTheExpressions(extension, strategy, context, true);
}
public ExtensionIterator(CloseableIteration iter,
@@ -48,14 +48,16 @@ public ExtensionIterator(CloseableIteration iter,
}
public static Consumer buildLambdaToEvaluateTheExpressions(Extension extension,
- EvaluationStrategy strategy, QueryEvaluationContext context) {
+ EvaluationStrategy strategy, QueryEvaluationContext context, boolean setNullOnError) {
Consumer consumer = null;
for (ExtensionElem extElem : extension.getElements()) {
ValueExpr expr = extElem.getExpr();
if (!(expr instanceof AggregateOperator)) {
QueryValueEvaluationStep prepared = strategy.precompile(extElem.getExpr(), context);
BiConsumer setBinding = context.setBinding(extElem.getName());
- consumer = andThen(consumer, targetBindings -> setValue(setBinding, prepared, targetBindings));
+ boolean setNullOnErrorLocal = setNullOnError;
+ consumer = andThen(consumer,
+ targetBindings -> setValue(setBinding, prepared, targetBindings, setNullOnErrorLocal));
}
}
if (consumer == null) {
@@ -67,7 +69,7 @@ public static Consumer buildLambdaToEvaluateTheExpressions(Ex
}
private static void setValue(BiConsumer setBinding, QueryValueEvaluationStep prepared,
- MutableBindingSet targetBindings) {
+ MutableBindingSet targetBindings, boolean setNullOnError) {
try {
// we evaluate each extension element over the targetbindings, so that bindings from
// a previous extension element in this same extension can be used by other extension elements.
@@ -79,11 +81,13 @@ private static void setValue(BiConsumer setBinding, Qu
setBinding.accept(targetValue, targetBindings);
}
} catch (ValueExprEvaluationException e) {
- // silently ignore type errors in extension arguments. They should not cause the
- // query to fail but result in no bindings for this solution
- // see https://www.w3.org/TR/sparql11-query/#assignment
- // use null as place holder for unbound variables that must remain so
- setBinding.accept(null, targetBindings);
+ // Silently ignore type errors in extension arguments. Whether to install a
+ // placeholder null-binding (variable must remain unbound) or leave it unset
+ // depends on context. By default we set a null-binding to enforce the SPARQL
+ // assignment rule; in special contexts (e.g., MINUS RHS), we may skip it.
+ if (setNullOnError) {
+ setBinding.accept(null, targetBindings);
+ }
}
}
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/GroupIterator.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/GroupIterator.java
index 9a362365fdb..bffb4e422fb 100644
--- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/GroupIterator.java
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/GroupIterator.java
@@ -505,7 +505,7 @@ public CountCollector(ValueFactory vf) {
@Override
public Value getFinalValue() {
- return vf.createLiteral(Long.toString(value), CoreDatatype.XSD.INTEGER);
+ return vf.createLiteral(value, CoreDatatype.XSD.INTEGER);
}
}
diff --git a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIteration.java b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIteration.java
index e0a8a429ec8..4e29b534b6a 100644
--- a/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIteration.java
+++ b/core/queryalgebra/evaluation/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIteration.java
@@ -10,16 +10,19 @@
*******************************************************************************/
package org.eclipse.rdf4j.query.algebra.evaluation.iterator;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
+import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.rdf4j.common.iteration.CloseableIteration;
import org.eclipse.rdf4j.common.iteration.FilterIteration;
import org.eclipse.rdf4j.common.iteration.Iterations;
+import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.query.BindingSet;
-import org.eclipse.rdf4j.query.QueryResults;
/**
* An Iteration that returns the results of an Iteration (the left argument) MINUS any results that are compatible with
@@ -42,6 +45,10 @@ public class SPARQLMinusIteration extends FilterIteration {
private Set excludeSet;
private Set excludeSetBindingNames;
private boolean excludeSetBindingNamesAreAllTheSame;
+ private BindingSet[] excludeSetList;
+
+ // Index of rightArg binding sets by (variable name, value) to quickly find candidates
+ private Map> rightIndex;
/*--------------*
* Constructors *
@@ -73,6 +80,7 @@ protected boolean accept(BindingSet bindingSet) {
if (!initialized) {
// Build set of elements-to-exclude from right argument
excludeSet = makeSet(getRightArg());
+ excludeSetList = excludeSet.toArray(new BindingSet[0]);
excludeSetBindingNames = excludeSet.stream()
.map(BindingSet::getBindingNames)
.flatMap(Set::stream)
@@ -85,50 +93,108 @@ protected boolean accept(BindingSet bindingSet) {
return false;
});
+ // Build right-side index by (name,value) -> list of rows
+ HashMap>> tmpIndex = new HashMap<>();
+ for (BindingSet bs : excludeSetList) {
+ for (String name : bs.getBindingNames()) {
+ Value v = bs.getValue(name);
+ if (v != null) {
+ tmpIndex
+ .computeIfAbsent(name, k -> new HashMap<>())
+ .computeIfAbsent(v, k -> new ArrayList<>())
+ .add(bs);
+ }
+ }
+ }
+ var built = new HashMap>(tmpIndex.size() * 2);
+ for (var e : tmpIndex.entrySet()) {
+ var inner = new HashMap(e.getValue().size() * 2);
+ for (var e2 : e.getValue().entrySet()) {
+ inner.put(e2.getKey(), e2.getValue().toArray(new BindingSet[0]));
+ }
+ built.put(e.getKey(), inner);
+ }
+ this.rightIndex = built;
+
initialized = true;
}
Set bindingNames = bindingSet.getBindingNames();
boolean hasSharedBindings = false;
- if (excludeSetBindingNamesAreAllTheSame) {
- for (String bindingName : excludeSetBindingNames) {
- if (bindingNames.contains(bindingName)) {
- hasSharedBindings = true;
- break;
+ // Fast union check: if no variable is shared with the union of right variables, accept immediately
+ if (!excludeSetBindingNames.isEmpty()) {
+ final Set left = bindingNames;
+ final Set rightUnion = excludeSetBindingNames;
+ if (left.size() <= rightUnion.size()) {
+ for (String name : left) {
+ if (rightUnion.contains(name)) {
+ hasSharedBindings = true;
+ break;
+ }
+ }
+ } else {
+ for (String name : rightUnion) {
+ if (left.contains(name)) {
+ hasSharedBindings = true;
+ break;
+ }
}
}
+ }
- if (!hasSharedBindings) {
- return true;
- }
+ if (!hasSharedBindings) {
+ return true;
}
- for (BindingSet excluded : excludeSet) {
+ // Use right-side index to find only candidates that match on at least one shared (name,value)
+ if (rightIndex != null && !rightIndex.isEmpty()) {
+ for (String name : bindingNames) {
+ Value leftVal = bindingSet.getValue(name);
+ if (leftVal == null) {
+ continue; // unbound on left does not participate in compatibility
+ }
+ Map byValue = rightIndex.get(name);
+ if (byValue == null) {
+ continue;
+ }
+ BindingSet[] candidates = byValue.get(leftVal);
+ if (candidates == null) {
+ continue;
+ }
+ for (int j = 0; j < candidates.length; j++) {
+ BindingSet excluded = candidates[j];
+ if (excluded.isCompatible(bindingSet)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+ // Fallback: scan all (should be rare or small)
+ for (BindingSet excluded : excludeSetList) {
if (!excludeSetBindingNamesAreAllTheSame) {
hasSharedBindings = false;
- for (String bindingName : excluded.getBindingNames()) {
- if (bindingNames.contains(bindingName)) {
- hasSharedBindings = true;
- break;
+ final Set excludedNames = excluded.getBindingNames();
+ if (bindingNames.size() <= excludedNames.size()) {
+ for (String name : bindingNames) {
+ if (excludedNames.contains(name)) {
+ hasSharedBindings = true;
+ break;
+ }
+ }
+ } else {
+ for (String name : excludedNames) {
+ if (bindingNames.contains(name)) {
+ hasSharedBindings = true;
+ break;
+ }
}
}
-
}
-
- // two bindingsets that share no variables are compatible by
- // definition, however, the formal
- // definition of SPARQL MINUS indicates that such disjoint sets should
- // be filtered out.
- // See http://www.w3.org/TR/sparql11-query/#sparqlAlgebra
- if (hasSharedBindings) {
- if (QueryResults.bindingSetsCompatible(excluded, bindingSet)) {
- // at least one compatible bindingset has been found in the
- // exclude set, therefore the object is compatible, therefore it
- // should not be accepted.
- return false;
- }
+ if (hasSharedBindings && excluded.isCompatible(bindingSet)) {
+ return false;
}
}
diff --git a/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStepTest.java b/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStepTest.java
new file mode 100644
index 00000000000..199cd1b5d68
--- /dev/null
+++ b/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/impl/evaluationsteps/StatementPatternQueryEvaluationStepTest.java
@@ -0,0 +1,133 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.query.algebra.evaluation.impl.evaluationsteps;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
+
+import org.eclipse.rdf4j.common.iteration.CloseableIteration;
+import org.eclipse.rdf4j.common.iteration.CloseableIteratorIteration;
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.Resource;
+import org.eclipse.rdf4j.model.Statement;
+import org.eclipse.rdf4j.model.Value;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.Dataset;
+import org.eclipse.rdf4j.query.MutableBindingSet;
+import org.eclipse.rdf4j.query.QueryEvaluationException;
+import org.eclipse.rdf4j.query.algebra.StatementPattern;
+import org.eclipse.rdf4j.query.algebra.Var;
+import org.eclipse.rdf4j.query.algebra.evaluation.TripleSource;
+import org.eclipse.rdf4j.query.algebra.evaluation.impl.QueryEvaluationContext;
+import org.eclipse.rdf4j.query.algebra.evaluation.impl.evaluationsteps.StatementPatternQueryEvaluationStep;
+import org.junit.jupiter.api.Test;
+
+class StatementPatternQueryEvaluationStepTest {
+
+ @Test
+ void convertIterationSkipsBindingChecks() {
+ InstrumentedQueryEvaluationContext context = new InstrumentedQueryEvaluationContext();
+ SingleStatementTripleSource tripleSource = new SingleStatementTripleSource();
+ StatementPattern statementPattern = new StatementPattern(new Var("s"), new Var("p"), new Var("o"));
+ StatementPatternQueryEvaluationStep evaluationStep = new StatementPatternQueryEvaluationStep(
+ statementPattern,
+ context,
+ tripleSource);
+
+ try (CloseableIteration iteration = evaluationStep.evaluate(context.createBindingSet())) {
+ assertThat(iteration.hasNext()).isTrue();
+ BindingSet converted = iteration.next();
+ assertThat(converted).isInstanceOf(InstrumentedBindingSet.class);
+ InstrumentedBindingSet bindingSet = (InstrumentedBindingSet) converted;
+ assertThat(bindingSet.wasIsEmptyInvoked()).isFalse();
+ assertThat(bindingSet.getBindingNames())
+ .containsExactlyInAnyOrder("s", "p", "o");
+ assertThat(bindingSet.getValue("s")).isEqualTo(tripleSource.statement.getSubject());
+ assertThat(bindingSet.getValue("p")).isEqualTo(tripleSource.statement.getPredicate());
+ assertThat(bindingSet.getValue("o")).isEqualTo(tripleSource.statement.getObject());
+ assertThat(iteration.hasNext()).isFalse();
+ }
+
+ assertThat(context.bindingChecks.get()).isZero();
+ }
+
+ private static final class InstrumentedQueryEvaluationContext implements QueryEvaluationContext {
+
+ private final AtomicInteger bindingChecks = new AtomicInteger();
+
+ @Override
+ public Predicate hasBinding(String variableName) {
+ return bindings -> {
+ bindingChecks.incrementAndGet();
+ return bindings.hasBinding(variableName);
+ };
+ }
+
+ @Override
+ public MutableBindingSet createBindingSet() {
+ return new InstrumentedBindingSet();
+ }
+
+ @Override
+ public Dataset getDataset() {
+ return null;
+ }
+
+ @Override
+ public org.eclipse.rdf4j.model.Literal getNow() {
+ return null;
+ }
+ }
+
+ private static final class InstrumentedBindingSet
+ extends org.eclipse.rdf4j.query.algebra.evaluation.QueryBindingSet {
+
+ private boolean isEmptyInvoked = false;
+
+ private InstrumentedBindingSet() {
+ }
+
+ @Override
+ public boolean isEmpty() {
+ isEmptyInvoked = true;
+ return super.isEmpty();
+ }
+
+ private boolean wasIsEmptyInvoked() {
+ return isEmptyInvoked;
+ }
+ }
+
+ private static final class SingleStatementTripleSource implements TripleSource {
+
+ private final ValueFactory valueFactory = SimpleValueFactory.getInstance();
+ private final Statement statement = valueFactory.createStatement(
+ valueFactory.createIRI("urn:subj"),
+ valueFactory.createIRI("urn:pred"),
+ valueFactory.createLiteral("obj"));
+
+ @Override
+ public CloseableIteration extends Statement> getStatements(Resource subj, IRI pred, Value obj,
+ Resource... contexts) throws QueryEvaluationException {
+ return new CloseableIteratorIteration<>(List.of(statement).iterator());
+ }
+
+ @Override
+ public ValueFactory getValueFactory() {
+ return valueFactory;
+ }
+ }
+}
diff --git a/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIterationFuzzTest.java b/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIterationFuzzTest.java
new file mode 100644
index 00000000000..4a79e7188a7
--- /dev/null
+++ b/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIterationFuzzTest.java
@@ -0,0 +1,218 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+
+package org.eclipse.rdf4j.query.algebra.evaluation.iterator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+
+import org.eclipse.rdf4j.common.iteration.CloseableIteration;
+import org.eclipse.rdf4j.common.iteration.CloseableIteratorIteration;
+import org.eclipse.rdf4j.model.BNode;
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.Literal;
+import org.eclipse.rdf4j.model.Value;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.Binding;
+import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.QueryResults;
+import org.eclipse.rdf4j.query.algebra.evaluation.QueryBindingSet;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Randomized fuzz tests validating SPARQLMinusIteration against a reference MINUS implementation.
+ */
+public class SPARQLMinusIterationFuzzTest {
+
+ private static final ValueFactory VF = SimpleValueFactory.getInstance();
+ private static final String[] UNIVERSE = new String[] { "a", "b", "c", "d", "e", "x", "y", "z", "id" };
+
+ @Test
+ public void randomizedMinusParity() {
+ long[] seeds = new long[] { 42L, 4242L, 424242L, 7L, 123456789L };
+ for (long seed : seeds) {
+ Random rnd = new Random(seed);
+ int leftSize = rnd.nextInt(15) + 5; // 5..19
+ int rightSize = rnd.nextInt(15) + 5; // 5..19
+
+ List left = new ArrayList<>(leftSize);
+ List right = new ArrayList<>(rightSize);
+
+ for (int i = 0; i < leftSize; i++) {
+ left.add(randomBindingSet(rnd, i));
+ }
+ for (int i = 0; i < rightSize; i++) {
+ right.add(randomBindingSet(rnd, i + 1000));
+ }
+
+ Set actual = collect(replay(new SPARQLMinusIteration(iter(left), iter(right))));
+ Set expected = collect(mapToKeys(referenceMinus(left, right)));
+
+ assertThat(actual).as("minus parity for seed=" + seed).isEqualTo(expected);
+ }
+ }
+
+ private static CloseableIteration iter(List list) {
+ return new CloseableIteratorIteration<>(list.iterator());
+ }
+
+ private static BindingSet randomBindingSet(Random rnd, int salt) {
+ // Random subset of variables
+ List vars = new ArrayList<>(UNIVERSE.length);
+ Collections.addAll(vars, UNIVERSE);
+ // shuffle-like selection
+ List selected = new ArrayList<>(UNIVERSE.length);
+ for (String v : vars) {
+ if (rnd.nextBoolean()) {
+ selected.add(v);
+ }
+ }
+ // ensure at least one var sometimes
+ if (selected.isEmpty() && rnd.nextBoolean()) {
+ selected.add(UNIVERSE[rnd.nextInt(UNIVERSE.length)]);
+ }
+
+ QueryBindingSet q = new QueryBindingSet();
+ for (String name : selected) {
+ // sometimes unbound (skip)
+ if (rnd.nextInt(5) == 0) {
+ continue;
+ }
+ q.setBinding(name, randomValueOrNull(rnd, salt + name.hashCode()));
+ }
+ // give some rows an explicit id for easier debugging
+ if (q.getValue("id") == null && rnd.nextInt(3) == 0) {
+ q.setBinding("id", VF.createIRI("urn:id:" + salt));
+ }
+ return q;
+ }
+
+ private static Value randomValueOrNull(Random rnd, int salt) {
+ switch (rnd.nextInt(7)) {
+ case 0:
+ return null; // unbound-like
+ case 1:
+ return VF.createIRI("urn:res:" + (salt % 13));
+ case 2:
+ return VF.createLiteral(rnd.nextInt(5));
+ case 3:
+ return VF.createLiteral("s" + rnd.nextInt(5));
+ case 4:
+ return VF.createBNode("b" + Math.abs(salt % 5));
+ case 5: {
+ // typed literal distinct from string
+ IRI dt = VF.createIRI("urn:dt:" + (salt % 3));
+ return VF.createLiteral("v" + (salt % 7), dt);
+ }
+ default: {
+ // language-tagged literal
+ String lang = (salt % 2 == 0) ? "en" : "NO";
+ return VF.createLiteral("l" + (salt % 7), lang);
+ }
+ }
+ }
+
+ private static List referenceMinus(List left, List right) {
+ List out = new ArrayList<>(left.size());
+ for (BindingSet L : left) {
+ boolean eliminate = false;
+ for (BindingSet R : right) {
+ if (hasSharedBoundVar(L, R) && QueryResults.bindingSetsCompatible(L, R)) {
+ eliminate = true;
+ break;
+ }
+ }
+ if (!eliminate) {
+ out.add(L);
+ }
+ }
+ return out;
+ }
+
+ private static boolean hasSharedBoundVar(BindingSet a, BindingSet b) {
+ // Iterate the smaller set of bindings for efficiency
+ int sizeA = sizeOfBound(a);
+ int sizeB = sizeOfBound(b);
+ BindingSet first = sizeA <= sizeB ? a : b;
+ BindingSet second = sizeA <= sizeB ? b : a;
+ for (Binding binding : first) {
+ if (binding.getValue() == null)
+ continue;
+ if (second.getBinding(binding.getName()) != null && second.getValue(binding.getName()) != null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static int sizeOfBound(BindingSet bs) {
+ int n = 0;
+ for (Binding ignored : bs) {
+ n++;
+ }
+ return n;
+ }
+
+ private static List replay(SPARQLMinusIteration it) {
+ List res = new ArrayList<>();
+ try {
+ while (it.hasNext()) {
+ res.add(toKey(it.next()));
+ }
+ } finally {
+ it.close();
+ }
+ return res;
+ }
+
+ private static Set collect(List rows) {
+ return new HashSet<>(rows);
+ }
+
+ private static List mapToKeys(List rows) {
+ List out = new ArrayList<>(rows.size());
+ for (BindingSet bs : rows) {
+ out.add(toKey(bs));
+ }
+ return out;
+ }
+
+ private static String toKey(BindingSet bs) {
+ // Build a canonical representation: sorted by variable name, include value type info
+ List parts = new ArrayList<>();
+ for (Binding b : bs) {
+ Value v = b.getValue();
+ String vs;
+ if (v instanceof Literal) {
+ Literal lit = (Literal) v;
+ String dt = lit.getDatatype() != null ? lit.getDatatype().stringValue() : "";
+ String lang = lit.getLanguage().orElse("");
+ vs = "L(" + lit.getLabel() + ")^" + dt + "@" + lang;
+ } else if (v instanceof IRI) {
+ vs = "I(" + v.stringValue() + ")";
+ } else if (v instanceof BNode) {
+ vs = "B(" + v.stringValue() + ")";
+ } else {
+ vs = v.toString();
+ }
+ parts.add(b.getName() + "=" + vs);
+ }
+ Collections.sort(parts);
+ return String.join("|", parts);
+ }
+}
diff --git a/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIterationTest.java b/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIterationTest.java
new file mode 100644
index 00000000000..3b2c887cfb7
--- /dev/null
+++ b/core/queryalgebra/evaluation/src/test/java/org/eclipse/rdf4j/query/algebra/evaluation/iterator/SPARQLMinusIterationTest.java
@@ -0,0 +1,199 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+
+package org.eclipse.rdf4j.query.algebra.evaluation.iterator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.eclipse.rdf4j.common.iteration.CloseableIteration;
+import org.eclipse.rdf4j.common.iteration.CloseableIteratorIteration;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.algebra.evaluation.ArrayBindingSet;
+import org.eclipse.rdf4j.query.algebra.evaluation.QueryBindingSet;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Behavioral tests for SPARQLMinusIteration to ensure semantics match SPARQL 1.1 MINUS.
+ */
+public class SPARQLMinusIterationTest {
+
+ private static final ValueFactory VF = SimpleValueFactory.getInstance();
+
+ private static CloseableIteration iter(List list) {
+ return new CloseableIteratorIteration<>(list.iterator());
+ }
+
+ private static QueryBindingSet qbs(Object... nv) {
+ QueryBindingSet q = new QueryBindingSet();
+ for (int i = 0; i + 1 < nv.length; i += 2) {
+ String name = (String) nv[i];
+ Object val = nv[i + 1];
+ if (val == null) {
+ q.setBinding(name, null);
+ } else if (val instanceof Integer) {
+ q.setBinding(name, VF.createLiteral((Integer) val));
+ } else if (val instanceof String && ((String) val).startsWith("urn:")) {
+ q.setBinding(name, VF.createIRI((String) val));
+ } else {
+ q.setBinding(name, VF.createLiteral(String.valueOf(val)));
+ }
+ }
+ return q;
+ }
+
+ private static ArrayBindingSet abs(String[] names, Object... nv) {
+ ArrayBindingSet a = new ArrayBindingSet(names);
+ for (int i = 0; i + 1 < nv.length; i += 2) {
+ String name = (String) nv[i];
+ Object val = nv[i + 1];
+ if (val == null) {
+ a.setBinding(name, null);
+ } else if (val instanceof Integer) {
+ a.setBinding(name, VF.createLiteral((Integer) val));
+ } else if (val instanceof String && ((String) val).startsWith("urn:")) {
+ a.setBinding(name, VF.createIRI((String) val));
+ } else {
+ a.setBinding(name, VF.createLiteral(String.valueOf(val)));
+ }
+ }
+ return a;
+ }
+
+ private static Set runAndCollectIds(SPARQLMinusIteration it, String idVar) {
+ Set ids = new HashSet<>();
+ while (it.hasNext()) {
+ BindingSet bs = it.next();
+ var v = bs.getValue(idVar);
+ ids.add(v == null ? "" : v.stringValue());
+ }
+ return ids;
+ }
+
+ @Test
+ public void emptyRight_acceptAllLeft() {
+ List left = Arrays.asList(
+ qbs("id", "urn:L1", "a", 1),
+ qbs("id", "urn:L2")
+ );
+ List right = List.of();
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactlyInAnyOrder("urn:L1", "urn:L2");
+ }
+
+ @Test
+ public void emptyLeft_yieldsEmpty() {
+ List left = List.of();
+ List right = Arrays.asList(qbs("x", 1));
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).isEmpty();
+ }
+
+ @Test
+ public void noSharedVariables_acceptAll() {
+ List left = Arrays.asList(
+ qbs("id", "urn:L1", "a", 1),
+ qbs("id", "urn:L2", "b", 2)
+ );
+ List right = Arrays.asList(
+ qbs("x", "urn:R1"),
+ qbs("y", 3)
+ );
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactlyInAnyOrder("urn:L1", "urn:L2");
+ }
+
+ @Test
+ public void sharedVar_conflictingValues_accept() {
+ List left = Arrays.asList(
+ qbs("id", "urn:L1", "a", 1),
+ qbs("id", "urn:L2", "a", 2)
+ );
+ List right = Arrays.asList(qbs("a", 3));
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactlyInAnyOrder("urn:L1", "urn:L2");
+ }
+
+ @Test
+ public void sharedVar_matchingValue_reject() {
+ List left = Arrays.asList(
+ qbs("id", "urn:L1", "a", 1),
+ qbs("id", "urn:L2", "a", 2)
+ );
+ List right = Arrays.asList(qbs("a", 2));
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactlyInAnyOrder("urn:L1");
+ }
+
+ @Test
+ public void multipleSharedVars_requireAllMatchToReject() {
+ List left = Arrays.asList(
+ qbs("id", "urn:L1", "a", 1, "b", 2),
+ qbs("id", "urn:L2", "a", 1, "b", 3)
+ );
+ List right = Arrays.asList(
+ qbs("a", 1, "b", 2), // should reject L1
+ qbs("a", 1, "b", 9) // does not reject L2 (b differs)
+ );
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactlyInAnyOrder("urn:L2");
+ }
+
+ @Test
+ public void leftNullValue_treatedAsUnbound_notRejected() {
+ List left = Arrays.asList(
+ qbs("id", "urn:L1", "a", null), // a is set but null
+ qbs("id", "urn:L2", "a", 2)
+ );
+ List right = Arrays.asList(qbs("a", 2));
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactlyInAnyOrder("urn:L1");
+ }
+
+ @Test
+ public void arrayBindingSet_compatibleAndDisjoint() {
+ String[] names = new String[] { "id", "a", "b" };
+ List left = Arrays.asList(
+ abs(names, "id", "urn:L1", "a", 1, "b", 2),
+ abs(names, "id", "urn:L2", "a", 5)
+ );
+ List right = Arrays.asList(
+ abs(names, "a", 1), // overlaps a=1 => reject L1
+ abs(names, "x", 9) // disjoint with both => should not reject
+ );
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactlyInAnyOrder("urn:L2");
+ }
+
+ @Test
+ public void unionNoOverlap_acceptFastPath() {
+ // Right union names = {a,b}; left rows only have {z}
+ List left = Arrays.asList(qbs("id", "urn:L1", "z", 1));
+ List right = Arrays.asList(qbs("a", 1), qbs("b", 2));
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactly("urn:L1");
+ }
+
+ @Test
+ public void duplicateRightRows_doNotChangeResult() {
+ List left = Arrays.asList(qbs("id", "urn:L1", "a", 1), qbs("id", "urn:L2", "a", 2));
+ List right = Arrays.asList(qbs("a", 2), qbs("a", 2));
+ SPARQLMinusIteration it = new SPARQLMinusIteration(iter(left), iter(right));
+ assertThat(runAndCollectIds(it, "id")).containsExactly("urn:L1");
+ }
+}
diff --git a/core/queryalgebra/geosparql/pom.xml b/core/queryalgebra/geosparql/pom.xml
index 57b5553cce1..10fcbc837ce 100644
--- a/core/queryalgebra/geosparql/pom.xml
+++ b/core/queryalgebra/geosparql/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryalgebra
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryalgebra-geosparql
RDF4J: Query algebra - GeoSPARQL
diff --git a/core/queryalgebra/geosparql/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/function/geosparql/SpatialSupport.java b/core/queryalgebra/geosparql/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/function/geosparql/SpatialSupport.java
index f3862b59a52..5d707476944 100644
--- a/core/queryalgebra/geosparql/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/function/geosparql/SpatialSupport.java
+++ b/core/queryalgebra/geosparql/src/main/java/org/eclipse/rdf4j/query/algebra/evaluation/function/geosparql/SpatialSupport.java
@@ -27,7 +27,7 @@
* a WktWriter that only supports points.
*
*/
-abstract class SpatialSupport {
+public abstract class SpatialSupport {
private static final SpatialContext spatialContext;
@@ -50,15 +50,15 @@ abstract class SpatialSupport {
wktWriter = support.createWktWriter();
}
- static SpatialContext getSpatialContext() {
+ public static SpatialContext getSpatialContext() {
return spatialContext;
}
- static SpatialAlgebra getSpatialAlgebra() {
+ public static SpatialAlgebra getSpatialAlgebra() {
return spatialAlgebra;
}
- static WktWriter getWktWriter() {
+ public static WktWriter getWktWriter() {
return wktWriter;
}
diff --git a/core/queryalgebra/model/pom.xml b/core/queryalgebra/model/pom.xml
index 70cc2d0b945..c561441a066 100644
--- a/core/queryalgebra/model/pom.xml
+++ b/core/queryalgebra/model/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryalgebra
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryalgebra-model
RDF4J: Query algebra - model
diff --git a/core/queryalgebra/pom.xml b/core/queryalgebra/pom.xml
index a873d735fd7..fb04d8339cd 100644
--- a/core/queryalgebra/pom.xml
+++ b/core/queryalgebra/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryalgebra
pom
diff --git a/core/queryparser/api/pom.xml b/core/queryparser/api/pom.xml
index e9038834d3f..6450c375f42 100644
--- a/core/queryparser/api/pom.xml
+++ b/core/queryparser/api/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryparser
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryparser-api
RDF4J: Query parser - API
diff --git a/core/queryparser/pom.xml b/core/queryparser/pom.xml
index b7371348ece..0dfcb8573d3 100644
--- a/core/queryparser/pom.xml
+++ b/core/queryparser/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryparser
pom
diff --git a/core/queryparser/sparql/pom.xml b/core/queryparser/sparql/pom.xml
index 7adf023b40e..d21ecf2b1b2 100644
--- a/core/queryparser/sparql/pom.xml
+++ b/core/queryparser/sparql/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryparser
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryparser-sparql
RDF4J: Query parser - SPARQL
diff --git a/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java b/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java
index 2fa952ee627..a52d72d8871 100644
--- a/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java
+++ b/core/queryparser/sparql/src/main/java/org/eclipse/rdf4j/query/parser/sparql/TupleExprBuilder.java
@@ -180,6 +180,7 @@
import org.eclipse.rdf4j.query.parser.sparql.ast.ASTPathAlternative;
import org.eclipse.rdf4j.query.parser.sparql.ast.ASTPathElt;
import org.eclipse.rdf4j.query.parser.sparql.ast.ASTPathMod;
+import org.eclipse.rdf4j.query.parser.sparql.ast.ASTPathNegatedPropertySet;
import org.eclipse.rdf4j.query.parser.sparql.ast.ASTPathOneInPropertySet;
import org.eclipse.rdf4j.query.parser.sparql.ast.ASTPathSequence;
import org.eclipse.rdf4j.query.parser.sparql.ast.ASTProjectionElem;
@@ -251,9 +252,6 @@ public class TupleExprBuilder extends AbstractASTVisitor {
GraphPattern graphPattern = new GraphPattern();
- // private Map mappedValueConstants = new
- // HashMap();
-
/*--------------*
* Constructors *
*--------------*/
@@ -820,6 +818,12 @@ public TupleExpr visit(ASTConstructQuery node, Object data) throws VisitorExcept
@Override
public TupleExpr visit(ASTConstruct node, Object data) throws VisitorException {
+
+ // check if the construct template contains any invalid nodes
+ if (isInvalidConstructQueryTemplate(node, true)) {
+ throw new MalformedQueryException("Invalid construct clause.");
+ }
+
TupleExpr result = (TupleExpr) data;
// Collect construct triples
@@ -872,7 +876,7 @@ public TupleExpr visit(ASTConstruct node, Object data) throws VisitorException {
// assign non-anonymous vars not present in where clause as
// extension elements. This is necessary to make external
// binding
- // assingnment possible (see SES-996)
+ // assignment possible (see SES-996)
extElemMap.put(var, new ExtensionElem(var.clone(), var.getName()));
}
}
@@ -910,6 +914,41 @@ public TupleExpr visit(ASTConstruct node, Object data) throws VisitorException {
return new Reduced(result);
}
+ /**
+ * Checks if the given node is invalid in a CONSTRUCT template.
+ *
+ * @param node The node to check.
+ * @param isInConstructTemplate Indicates if the check is being performed within a CONSTRUCT clause.
+ * @return true if the node is invalid, false otherwise.
+ */
+ private static boolean isInvalidConstructQueryTemplate(Node node, boolean isInConstructTemplate) {
+ if (isInConstructTemplate && isInvalidConstructNode(node)) {
+ return true;
+ }
+
+ // recursively check child nodes
+ for (int i = 0; i < node.jjtGetNumChildren(); i++) {
+ if (isInvalidConstructQueryTemplate(node.jjtGetChild(i), isInConstructTemplate)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if the given node is an invalid construct node.
+ *
+ * @param node The node to check.
+ * @return true if the node is invalid, false otherwise.
+ */
+ private static boolean isInvalidConstructNode(Node node) {
+ return node instanceof ASTPathMod
+ || node instanceof ASTPathNegatedPropertySet
+ || node instanceof ASTPathOneInPropertySet
+ || (node instanceof ASTPathAlternative && node.jjtGetNumChildren() > 1);
+ }
+
/**
* Gets the set of variables that are relevant for the constructor. This method accumulates all subject, predicate
* and object variables from the supplied statement patterns, but ignores any context variables.
@@ -1359,7 +1398,7 @@ public TupleExpr visit(ASTPathAlternative pathAltNode, Object data) throws Visit
}
}
- // when using union to execute path expressions, the scope does not not change
+ // when using union to execute path expressions, the scope does not change
union.setVariableScopeChange(false);
return union;
}
@@ -1548,7 +1587,7 @@ private TupleExpr createTupleExprForNegatedPropertySets(List np
TupleExpr patternMatchInverse = null;
- // build a inverse statement pattern if needed
+ // build an inverse statement pattern if needed
if (filterConditionInverse != null) {
patternMatchInverse = new StatementPattern(pathSequenceContext.scope, endVar.clone(), predVar.clone(),
subjVar.clone(),
@@ -2362,7 +2401,7 @@ public Object visit(ASTBind node, Object data) throws VisitorException {
ValueExpr ve = child0 instanceof ValueExpr ? (ValueExpr) child0
: (child0 instanceof TripleRef) ? ((TripleRef) child0).getExprVar() : null;
if (ve == null) {
- throw new IllegalArgumentException("Unexpected expressin on bind");
+ throw new IllegalArgumentException("Unexpected expression on bind");
}
// name to bind the expression outcome to
@@ -2381,7 +2420,7 @@ public Object visit(ASTBind node, Object data) throws VisitorException {
// check if alias is not previously used in the BGP
if (arg.getBindingNames().contains(alias)) {
- // SES-2314 we need to doublecheck that the reused varname is not just
+ // SES-2314 we need to double-check that the reused varname is not just
// for an anonymous var or a constant.
VarCollector collector = new VarCollector();
arg.visit(collector);
diff --git a/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/SPARQLParserTest.java b/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/SPARQLParserTest.java
index fa963633783..e52e1016776 100644
--- a/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/SPARQLParserTest.java
+++ b/core/queryparser/sparql/src/test/java/org/eclipse/rdf4j/query/parser/sparql/SPARQLParserTest.java
@@ -43,6 +43,7 @@
import org.eclipse.rdf4j.model.util.Values;
import org.eclipse.rdf4j.query.BindingSet;
import org.eclipse.rdf4j.query.MalformedQueryException;
+import org.eclipse.rdf4j.query.QueryLanguage;
import org.eclipse.rdf4j.query.algebra.AggregateFunctionCall;
import org.eclipse.rdf4j.query.algebra.ArbitraryLengthPath;
import org.eclipse.rdf4j.query.algebra.DeleteData;
@@ -70,6 +71,8 @@
import org.eclipse.rdf4j.query.parser.ParsedQuery;
import org.eclipse.rdf4j.query.parser.ParsedTupleQuery;
import org.eclipse.rdf4j.query.parser.ParsedUpdate;
+import org.eclipse.rdf4j.query.parser.QueryParserUtil;
+import org.eclipse.rdf4j.query.parser.sparql.SPARQLParser;
import org.eclipse.rdf4j.query.parser.sparql.aggregate.AggregateCollector;
import org.eclipse.rdf4j.query.parser.sparql.aggregate.AggregateFunction;
import org.eclipse.rdf4j.query.parser.sparql.aggregate.AggregateFunctionFactory;
@@ -1002,6 +1005,158 @@ public void testApostropheInsertData() {
parser.parseUpdate(query, null);
}
+ @Test
+ public void testInvalidConstructQueryWithPropertyPathInConstructClause() {
+ String invalidSparqlQuery = " CONSTRUCT {\n" +
+ " ?s (!a)* ?p .\n" +
+ " }\n" +
+ " WHERE {\n" +
+ " ?s (!a)* ?p .\n" +
+ " }";
+
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+
+ }
+
+ @Test
+ public void testValidConstructQueryWithPropertyPathInWhereClause() {
+ String validSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?person foaf:knows ?friend .\n" +
+ "}\n" +
+ "WHERE {\n" +
+ " ?person foaf:knows+ ?friend .\n" +
+ "}";
+
+ ParsedQuery parsedQuery = parser.parseQuery(validSparqlQuery, null);
+ assertThat(parsedQuery.getSourceString()).isEqualTo(validSparqlQuery);
+
+ }
+
+ @Test
+ public void testValidConstructQueryWithBlankNodeAsSubject() {
+ String validSparqlQuery = "PREFIX foaf: \n"
+ + "PREFIX site: \n"
+ + "\n"
+ + "CONSTRUCT { [] foaf:name ?name }\n"
+ + "WHERE\n"
+ + "{ [] foaf:name ?name ;\n"
+ + " site:hits ?hits .\n"
+ + "}\n"
+ + "ORDER BY desc(?hits)\n"
+ + "LIMIT 2";
+
+ ParsedQuery parsedQuery = parser.parseQuery(validSparqlQuery, null);
+ assertThat(parsedQuery.getSourceString()).isEqualTo(validSparqlQuery);
+
+ }
+
+ @Test
+ public void testInvalidConstructQueryWithPropertyPathInPredicatePosition() {
+ String invalidSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?person foaf:knows+ ?friend . " +
+ "}\n" +
+ "WHERE {\n" +
+ " ?person foaf:knows+ ?friend .\n" +
+ "}";
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+
+ }
+
+ @Test
+ public void testInvalidConstructQueryWithPropertyPathAlternationInPredicate() {
+ String invalidSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?x (foaf:knows|foaf:friendOf) ?y . " +
+ "}\n" +
+ "WHERE {\n" +
+ " ?x (foaf:knows|foaf:friendOf) ?y .\n" +
+ "} ";
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+
+ }
+
+ @Test
+ public void testInvalidConstructQueryWithPathSequenceInPredicate() {
+ String invalidSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?a foaf:knows/foaf:knows ?c . " +
+ "}\n" +
+ "WHERE {\n" +
+ " ?a foaf:knows/foaf:knows ?c . .\n" +
+ "} ";
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+
+ }
+
+ @Test
+ public void testInvalidConstructQueryWithNegatedPropertySet() {
+ String invalidSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?s !foaf:knows ?o . " +
+ "}\n" +
+ "WHERE {\n" +
+ " ?s !foaf:knows ?o .\n" +
+ "} ";
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+
+ }
+
+ @Test
+ public void testInvalidConstructQueryWithZeroOrMorePathInPredicate() {
+ String invalidSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?s foaf:knows* ?o . " +
+ "}\n" +
+ "WHERE {\n" +
+ " ?s foaf:knows* ?o .\n" +
+ "} ";
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+ }
+
+ @Test
+ public void testInvalidConstructQueryWithZeroOrMorePathInPredicates() {
+ String invalidSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?s foaf:knows ?o . " +
+ " ?s foaf:name+ ?o . " +
+ "}\n" +
+ "WHERE {\n" +
+ " ?s foaf:knows* ?o .\n" +
+ " ?s foaf:name ?o .\n" +
+ "} ";
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+ }
+
+ @Test
+ public void testInvalidConstructQueryWithMalformedTriple() {
+ String invalidSparqlQuery = "PREFIX foaf: " +
+ "CONSTRUCT {\n" +
+ " ?person foaf:name \n" +
+ "}\n" +
+ "WHERE {\n" +
+ " ?person foaf:name ?name .\n" +
+ "} ";
+ assertThrows(MalformedQueryException.class, () -> {
+ parser.parseQuery(invalidSparqlQuery, null);
+ });
+ }
+
private AggregateFunctionFactory buildDummyFactory() {
return new AggregateFunctionFactory() {
@Override
diff --git a/core/queryrender/pom.xml b/core/queryrender/pom.xml
index a765bfdeb90..8db12169a80 100644
--- a/core/queryrender/pom.xml
+++ b/core/queryrender/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryrender
RDF4J: Query Rendering
diff --git a/core/queryresultio/api/pom.xml b/core/queryresultio/api/pom.xml
index cd997e7eea0..273fe3b9801 100644
--- a/core/queryresultio/api/pom.xml
+++ b/core/queryresultio/api/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryresultio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryresultio-api
RDF4J: Query result IO - API
diff --git a/core/queryresultio/api/src/main/java/org/eclipse/rdf4j/query/resultio/TupleQueryResultFormat.java b/core/queryresultio/api/src/main/java/org/eclipse/rdf4j/query/resultio/TupleQueryResultFormat.java
index 1ef89ccee24..35ace310d02 100644
--- a/core/queryresultio/api/src/main/java/org/eclipse/rdf4j/query/resultio/TupleQueryResultFormat.java
+++ b/core/queryresultio/api/src/main/java/org/eclipse/rdf4j/query/resultio/TupleQueryResultFormat.java
@@ -98,6 +98,12 @@ public class TupleQueryResultFormat extends QueryResultFormat {
Arrays.asList("text/x-tab-separated-values-star", "application/x-sparqlstar-results+tsv"),
StandardCharsets.UTF_8, List.of("tsvs"), null, SUPPORTS_RDF_STAR);
+ public static final TupleQueryResultFormat XSLX = new TupleQueryResultFormat("SPARQL/XLSX",
+ "application/vnd.ms-excel", "xlsx");
+
+ public static final TupleQueryResultFormat ODS = new TupleQueryResultFormat("SPARQL/ODS",
+ "application/vnd.oasis.opendocument.spreadsheet", "ods");
+
/*-----------*
* Variables *
*-----------*/
diff --git a/core/queryresultio/binary/pom.xml b/core/queryresultio/binary/pom.xml
index 05777c3e2da..34df6c0b64f 100644
--- a/core/queryresultio/binary/pom.xml
+++ b/core/queryresultio/binary/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryresultio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryresultio-binary
RDF4J: Query result IO - binary
diff --git a/core/queryresultio/ods/pom.xml b/core/queryresultio/ods/pom.xml
new file mode 100644
index 00000000000..a9ec4418667
--- /dev/null
+++ b/core/queryresultio/ods/pom.xml
@@ -0,0 +1,40 @@
+
+
+ 4.0.0
+
+ org.eclipse.rdf4j
+ rdf4j-queryresultio
+ 5.1.0-SNAPSHOT
+
+ rdf4j-queryresultio-sparqlods
+ RDF4J: Query result IO - ODS
+ Query result parser and writer implementation for an non standardized SPARQL Query Results Open Document Format Spreadsheet.
+
+
+ ${project.groupId}
+ rdf4j-queryresultio-api
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-query
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-model
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-common-xml
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-queryresultio-testsuite
+ ${project.version}
+ test
+
+
+
diff --git a/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/SPARQLResultsODSWriter.java b/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/SPARQLResultsODSWriter.java
new file mode 100644
index 00000000000..c71afbd3e93
--- /dev/null
+++ b/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/SPARQLResultsODSWriter.java
@@ -0,0 +1,1014 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.query.resultio.sparqlods;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.eclipse.rdf4j.common.xml.XMLWriter;
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.Literal;
+import org.eclipse.rdf4j.model.Value;
+import org.eclipse.rdf4j.model.base.CoreDatatype;
+import org.eclipse.rdf4j.model.base.CoreDatatype.XSD;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.Binding;
+import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.QueryResultHandlerException;
+import org.eclipse.rdf4j.query.TupleQueryResultHandlerException;
+import org.eclipse.rdf4j.query.impl.MapBindingSet;
+import org.eclipse.rdf4j.query.resultio.QueryResultFormat;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriter;
+import org.eclipse.rdf4j.rio.RioSetting;
+import org.eclipse.rdf4j.rio.WriterConfig;
+
+// Assume TupleQueryResultFormat.ODS exists or is defined elsewhere
+// import static org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat.ODS;
+
+/**
+ * Render a SPARQL result set into an ODF spreadsheet file (.ods) by manually generating the XML content and ZIP
+ * structure.
+ *
+ * NOTE: This implementation manually creates XML and does not use any ODF library. It is more complex and potentially
+ * less robust than using a dedicated library. Auto-sizing columns is not implemented as it's typically handled by the
+ * viewing application.
+ *
+ * @author Adapted from SPARQLResultsXLSXWriter by Jerven Bolleman
+ */
+public class SPARQLResultsODSWriter implements TupleQueryResultWriter {
+
+ private final ZipOutputStream zos;
+ // Replace PrintWriter with XMLWriter
+ private XMLWriter contentXmlWriter;
+
+ private final Map columnIndexes = new HashMap<>();
+ private final Map prefixes = new HashMap<>();
+
+ private int columnCount = 0;
+ private boolean headerWritten = false;
+
+ // ODF requires specific date/time format
+ private static final DateTimeFormatter ODF_DATETIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
+ private static final DateTimeFormatter ODF_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
+
+ // Style names (must match definitions in styles.xml)
+ private static final String STYLE_DEFAULT = "DefaultStyle";
+ private static final String STYLE_HEADER = "HeaderStyle";
+ private static final String STYLE_IRI = "IriStyle";
+ private static final String STYLE_ANY_IRI = "AnyIriStyle"; // Differentiate if needed
+ private static final String STYLE_NUMERIC = "NumericStyle";
+ private static final String STYLE_DATE = "DateStyle";
+ private static final String STYLE_DATETIME = "DateTimeStyle";
+ private static final String STYLE_BOOLEAN = "BooleanStyle";
+
+ // ODS Namespaces
+ private static final String OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";
+ private static final String TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
+ private static final String TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
+ private static final String STYLE_NS = "urn:oasis:names:tc:opendocument:xmlns:style:1.0";
+ private static final String FO_NS = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0";
+ private static final String XLINK_NS = "http://www.w3.org/1999/xlink";
+ private static final String DC_NS = "http://purl.org/dc/elements/1.1/";
+ private static final String META_NS = "urn:oasis:names:tc:opendocument:xmlns:meta:1.0";
+ private static final String NUMBER_NS = "urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0";
+ private static final String SVG_NS = "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0";
+ private static final String MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
+
+ // ODS Prefixes
+ private static final String OFFICE_PRE = "office";
+ private static final String TABLE_PRE = "table";
+ private static final String TEXT_PRE = "text";
+ private static final String STYLE_PRE = "style";
+ private static final String FO_PRE = "fo";
+ private static final String XLINK_PRE = "xlink";
+ private static final String DC_PRE = "dc";
+ private static final String META_PRE = "meta";
+ private static final String NUMBER_PRE = "datastyle";
+ private static final String SVG_PRE = "svg";
+ private static final String MANIFEST_PRE = "manifest";
+
+ public SPARQLResultsODSWriter(OutputStream out) {
+ this.zos = new ZipOutputStream(out);
+ }
+
+ // --- Core TupleQueryResultWriter Methods ---
+
+ @Override
+ public void startDocument() throws QueryResultHandlerException {
+ try {
+ // 1. Write mimetype (must be first and uncompressed)
+ ZipEntry mimetypeEntry = new ZipEntry("mimetype");
+ mimetypeEntry.setMethod(ZipEntry.STORED);
+ byte[] mimetype = "application/vnd.oasis.opendocument.spreadsheet".getBytes(StandardCharsets.US_ASCII);
+ mimetypeEntry.setSize(mimetype.length); // Length of the mimetype string
+ mimetypeEntry.setCompressedSize(mimetype.length);
+// // CRC-32 for "application/vnd.oasis.opendocument.spreadsheet" is 0xadc46ac
+// mimetypeEntry.setCrc(0xadc46acL);
+ mimetypeEntry.setCrc(0x8a396c85L);
+ zos.putNextEntry(mimetypeEntry);
+ zos.write(mimetype);
+ zos.closeEntry();
+ zos.setMethod(ZipEntry.DEFLATED); // Use compression for subsequent entries
+
+ // 2. Prepare XMLWriters for main XML files
+
+ // styles.xml
+ zos.putNextEntry(new ZipEntry("styles.xml"));
+ // Instantiate XMLWriter
+ XMLWriter stylesXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
+ stylesXmlWriter.setPrettyPrint(true); // Enable indentation for readability
+ writeStylesXml(stylesXmlWriter); // Write boilerplate and style definitions using XMLWriter
+ stylesXmlWriter.endDocument();
+ // stylesXmlWriter.close(); // Don't close underlying stream
+ zos.closeEntry(); // Close the styles.xml entry
+
+ // --- Write meta.xml ---
+ zos.putNextEntry(new ZipEntry("meta.xml"));
+ // Use try-with-resources for the intermediate XMLWriter
+ XMLWriter metaXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
+ metaXmlWriter.setPrettyPrint(true);
+ writeMetaXml(metaXmlWriter); // Use XMLWriter methods
+ metaXmlWriter.endDocument();
+ // Don't close the underlying stream
+ zos.closeEntry(); // close meta
+
+ // --- Write META-INF/manifest.xml ---
+ zos.putNextEntry(new ZipEntry("META-INF/manifest.xml"));
+
+ XMLWriter manifestXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
+ manifestXmlWriter.setPrettyPrint(true);
+ writeManifestXml(manifestXmlWriter); // Use XMLWriter methods
+ manifestXmlWriter.endDocument();
+ // Don't close the underlying stream
+ zos.closeEntry();
+
+ // content.xml
+ zos.putNextEntry(new ZipEntry("content.xml"));
+ // Instantiate XMLWriter
+ contentXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
+ contentXmlWriter.setPrettyPrint(true); // Enable indentation
+ writeContentXmlStart(); // Write boilerplate up to using XMLWriter
+
+ } catch (IOException e) {
+ throw new QueryResultHandlerException("Failed to initialize ODF document structure", e);
+ }
+ }
+
+ @Override
+ public void handleNamespace(String prefix, String uri) throws QueryResultHandlerException {
+ prefixes.put(uri, prefix);
+ }
+
+ @Override
+ public void startQueryResult(List bindingNames) throws TupleQueryResultHandlerException {
+ this.columnCount = bindingNames.size();
+ int columnIndex = 0;
+ columnIndexes.clear(); // Reset for potential multiple results
+ for (String bindingName : bindingNames) {
+ columnIndexes.put(bindingName, columnIndex++);
+ }
+
+ if (contentXmlWriter == null) {
+ throw new TupleQueryResultHandlerException("startQueryResult called before startDocument");
+ }
+
+ // Write table structures only once
+ if (!headerWritten) {
+ try {
+ // Write Table Definitions in content.xml for "nice" sheet
+ // Note: Skipping the separate "raw" sheet for simplicity in this refactor
+ writeTableStart(contentXmlWriter, "QueryResult", columnCount); // Use a descriptive name
+ writeHeaderRow(contentXmlWriter, bindingNames, STYLE_HEADER);
+ // Keep the table open for data rows
+
+ headerWritten = true;
+ } catch (IOException e) {
+ throw new TupleQueryResultHandlerException("Failed to write table start/header", e);
+ }
+ } else {
+ throw new TupleQueryResultHandlerException(
+ "startQueryResult called more than once. ODF writer handles only one result set.");
+ }
+ }
+
+ @Override
+ public void handleSolution(BindingSet bindingSet) throws TupleQueryResultHandlerException {
+ if (!headerWritten || contentXmlWriter == null) {
+ throw new TupleQueryResultHandlerException(
+ "handleSolution called before startQueryResult or after endDocument");
+ }
+
+ try {
+ startTag(contentXmlWriter, TABLE_PRE, "table-row");
+ // contentXmlWriter.attribute(TABLE_NS, "style-name", "ro1"); // Optional:
+ // Assuming default row style
+
+ Value[] values = new Value[columnCount]; // To hold values in correct column order
+ for (Binding binding : bindingSet) {
+ int colIdx = columnIndexes.getOrDefault(binding.getName(), -1);
+ if (colIdx != -1) {
+ values[colIdx] = binding.getValue();
+ }
+ }
+
+ // Iterate through columns to ensure correct order and handle unbound variables
+ for (int i = 0; i < columnCount; i++) {
+ Value v = values[i];
+ if (v == null) {
+ // Write empty cell for unbound variable
+ emptyTag(contentXmlWriter, TABLE_PRE, "table-cell");
+ } else {
+ // Write formatted cell based on value type
+ writeCell(contentXmlWriter, v);
+ }
+ }
+ endTag(contentXmlWriter, TABLE_PRE, "table-row");
+ } catch (IOException e) {
+ throw new TupleQueryResultHandlerException("Failed to write data row", e);
+ }
+ }
+
+ private void emptyTag(XMLWriter writer, String prefix, String element) throws IOException {
+ writer.startTag(prefix + ':' + element);
+ writer.endTag(prefix + ':' + element);
+ }
+
+ @Override
+ public void endQueryResult() throws TupleQueryResultHandlerException {
+ if (contentXmlWriter == null)
+ return; // Nothing was started
+
+ // Close the table if it was opened
+ if (headerWritten) {
+ try {
+ writeTableEnd(contentXmlWriter); // Close the last opened table
+ } catch (IOException e) {
+ throw new TupleQueryResultHandlerException("Failed to write table end", e);
+ }
+ }
+ endDocument();
+ }
+
+ private void endDocument() throws QueryResultHandlerException {
+ try {
+ // --- Finish content.xml ---
+ if (contentXmlWriter != null) {
+ writeContentXmlEnd(contentXmlWriter); // Write closing tags
+ contentXmlWriter.endDocument(); // Finalize XML document
+ // XMLWriter wraps an OutputStreamWriter which shouldn't be closed directly
+ // here,
+ // as closing the ZipEntry handles the underlying stream flushing.
+ // contentXmlWriter.close(); // Don't close underlying stream
+ zos.closeEntry(); // Close the content.xml entry
+ contentXmlWriter = null; // Mark as finished
+ }
+
+ // --- Finish the ZIP archive ---
+ zos.finish();
+ zos.close(); // Close the main ZipOutputStream
+
+ } catch (IOException e) {
+ throw new QueryResultHandlerException("Failed to finalize or write ODF document components", e);
+ }
+ }
+
+ // --- Helper Methods for ODS XML Generation using XMLWriter ---
+
+ private void declareNamespaces(XMLWriter writer, String... prefixesAndUris) throws IOException {
+ for (int i = 0; i < prefixesAndUris.length; i += 2) {
+ writer.setAttribute("xmlns:" + prefixesAndUris[i], prefixesAndUris[i + 1]);
+ }
+ }
+
+ private void writeContentXmlStart() throws IOException {
+ contentXmlWriter.startDocument();
+ declareNamespaces(contentXmlWriter, OFFICE_PRE, OFFICE_NS, TABLE_PRE, TABLE_NS, TEXT_PRE, TEXT_NS, FO_PRE,
+ FO_NS, XLINK_PRE, XLINK_NS, DC_PRE, DC_NS, META_PRE, META_NS, NUMBER_PRE, NUMBER_NS, STYLE_PRE,
+ STYLE_NS, SVG_PRE, SVG_NS);
+ startTag(contentXmlWriter, OFFICE_PRE, "document-content");
+ setAttribute(contentXmlWriter, OFFICE_PRE, "version", "1.2");
+
+ emptyTag(contentXmlWriter, OFFICE_PRE, "scripts"); // Required
+
+ startTag(contentXmlWriter, OFFICE_PRE, "font-face-decls");
+ { // font
+ setAttribute(contentXmlWriter, STYLE_PRE, "name", "Liberation Sans");
+ setAttribute(contentXmlWriter, SVG_PRE, "font-family", "Liberation Sans");
+ setAttribute(contentXmlWriter, STYLE_PRE, "font-family-generic", "swiss");
+ setAttribute(contentXmlWriter, STYLE_PRE, "font-pitch", "variable");
+ emptyTag(contentXmlWriter, STYLE_PRE, "font-face");
+ }
+ endTag(contentXmlWriter, OFFICE_PRE, "font-face-decls"); // office:font-face-decls
+
+ startTag(contentXmlWriter, OFFICE_PRE, "automatic-styles");
+ {
+ // Define basic column style (co1) and row style (ro1)
+ setAttribute(contentXmlWriter, STYLE_PRE, "name", "co1");
+ setAttribute(contentXmlWriter, STYLE_PRE, "family", "table-column");
+ startTag(contentXmlWriter, STYLE_PRE, "style");
+ {
+
+ setAttribute(contentXmlWriter, FO_PRE, "break-before", "auto");
+ setAttribute(contentXmlWriter, STYLE_PRE, "column-width", "2.257cm"); // Default width
+ emptyTag(contentXmlWriter, STYLE_PRE, "table-column-properties");
+ }
+ endTag(contentXmlWriter, STYLE_PRE, "style"); // style:style co1
+
+ setAttribute(contentXmlWriter, STYLE_PRE, "name", "ro1");
+ setAttribute(contentXmlWriter, STYLE_PRE, "family", "table-row");
+ startTag(contentXmlWriter, STYLE_PRE, "style");
+ {
+ setAttribute(contentXmlWriter, STYLE_PRE, "row-height", "0.453cm");
+ setAttribute(contentXmlWriter, FO_PRE, "break-before", "auto");
+ setAttribute(contentXmlWriter, STYLE_PRE, "use-optimal-row-height", "true");
+ emptyTag(contentXmlWriter, STYLE_PRE, "table-row-properties");
+ }
+ endTag(contentXmlWriter, STYLE_PRE, "style"); // style:style ro1
+ // Add automatic data styles if needed (e.g., N1, N2 for specific number
+ // formats)
+ }
+ endTag(contentXmlWriter, OFFICE_PRE, "automatic-styles");
+
+ startTag(contentXmlWriter, OFFICE_PRE, "body");
+ startTag(contentXmlWriter, OFFICE_PRE, "spreadsheet");
+ // Tables will be added here by startQueryResult/handleSolution
+ }
+
+ private void emptyElement(XMLWriter contentXmlWriter, String prefix, String element, String content)
+ throws IOException {
+ contentXmlWriter.startTag(prefix + ':' + element);
+ contentXmlWriter.text(content);
+ contentXmlWriter.endTag(prefix + ':' + element);
+ }
+
+ private void startTag(XMLWriter writer, String prefix, String element) throws IOException {
+ writer.startTag(prefix + ':' + element);
+ }
+
+ private void writeContentXmlEnd(XMLWriter writer) throws IOException {
+ endTag(writer, OFFICE_PRE, "spreadsheet"); // office:spreadsheet
+ endTag(writer, OFFICE_PRE, "body"); // office:body
+ endTag(writer, OFFICE_PRE, "document-content"); // office:
+ }
+
+ private void endTag(XMLWriter writer, String officePre, String element) throws IOException {
+ writer.endTag(officePre + ":" + element);
+ }
+
+ private void writeStylesXml(XMLWriter stylesXmlWriter) throws IOException {
+ stylesXmlWriter.startDocument();
+ declareNamespaces(stylesXmlWriter, "office", OFFICE_NS, "style", STYLE_NS, "text", TEXT_NS, "table", TABLE_NS,
+ // "draw", "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
+ "fo", FO_NS, "xlink", XLINK_NS, "dc", DC_NS, "meta", META_NS, "number", NUMBER_NS, "svg", SVG_NS);
+ setAttribute(stylesXmlWriter, OFFICE_PRE, "version", "1.2");
+ startTag(stylesXmlWriter, OFFICE_PRE, "document-styles");
+ {
+ {
+ startTag(stylesXmlWriter, OFFICE_PRE, "font-face-decls");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Liberation Sans");
+ setAttribute(stylesXmlWriter, SVG_PRE, "font-family", "'Liberation Sans'");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "font-family-generic", "swiss");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "font-pitch", "variable");
+
+ emptyTag(stylesXmlWriter, STYLE_PRE, "font-face");
+
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Liberation Mono");
+ setAttribute(stylesXmlWriter, SVG_PRE, "font-family", "'Liberation Mono'");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "font-family-generic", "modern");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "font-pitch", "fixed");
+
+ endTag(stylesXmlWriter, OFFICE_PRE, "font-face-decls"); // office:font-face-decls
+ }
+ startTag(stylesXmlWriter, OFFICE_PRE, "styles");
+ {
+ // --- Default Cell Style ---
+
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_DEFAULT);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", "Default"); // Assumes "Default" is
+ // built-in
+ // or defined
+ startTag(stylesXmlWriter, STYLE_PRE, "style");
+ {
+ setAttribute(stylesXmlWriter, FO_PRE, "padding", "0.097cm");
+ setAttribute(stylesXmlWriter, FO_PRE, "border", "0.002cm solid #000000"); // Basic border
+ emptyTag(stylesXmlWriter, STYLE_PRE, "table-cell-properties");
+
+ setAttribute(stylesXmlWriter, STYLE_PRE, "font-name", "Liberation Sans");
+ setAttribute(stylesXmlWriter, FO_PRE, "font-size", "10pt");
+ emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
+ }
+ endTag(stylesXmlWriter, STYLE_PRE, "style");
+
+ // --- Header Style ---
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_HEADER);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
+ startTag(stylesXmlWriter, STYLE_PRE, "style");
+ {
+ setAttribute(stylesXmlWriter, FO_PRE, "background-color", "#cccccc");
+ setAttribute(stylesXmlWriter, FO_PRE, "text-align", "center");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "vertical-align", "middle");
+ setAttribute(stylesXmlWriter, FO_PRE, "border", "0.002cm solid #000000");
+
+ emptyTag(stylesXmlWriter, STYLE_PRE, "table-cell-properties");
+
+ setAttribute(stylesXmlWriter, FO_PRE, "font-weight", "bold");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "font-name", "Liberation Sans");
+ setAttribute(stylesXmlWriter, FO_PRE, "font-size", "10pt");
+
+ emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
+ }
+ endTag(stylesXmlWriter, STYLE_PRE, "style"); // style:style HeaderStyle
+
+ // --- IRI Hyperlink Style ---
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_IRI);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
+ startTag(stylesXmlWriter, STYLE_PRE, "style");
+ {
+ setAttribute(stylesXmlWriter, FO_PRE, "color", "#0000ff");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-style", "solid");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-width", "auto");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-color", "font-color"); // Blue, underlined
+ emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
+ }
+ endTag(stylesXmlWriter, STYLE_PRE, "style"); // style:style IriStyle
+
+ // --- Any IRI Hyperlink Style ---
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_ANY_IRI);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
+ startTag(stylesXmlWriter, STYLE_PRE, "style");
+ {
+ setAttribute(stylesXmlWriter, FO_PRE, "color", "#ff00ff"); // Magenta
+ setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-style", "solid");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-width", "auto");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-color", "font-color");
+ emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
+ }
+ endTag(stylesXmlWriter, STYLE_PRE, "style");
+
+ // --- Define Number/Date/Bool Data Styles First (referenced by cell styles) ---
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "N0");
+ startTag(stylesXmlWriter, NUMBER_PRE, "number-style");
+ {
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "min-integer-digits", "1");
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "decimal-places", "2");// 2 decimal places
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "grouping", "false");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "number");
+ }
+ endTag(stylesXmlWriter, NUMBER_PRE, "number-style");
+
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Ndate");
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "automatic-order", "true");
+ startTag(stylesXmlWriter, NUMBER_PRE, "date-style");
+ {
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "year");
+
+ emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
+
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "month");
+
+ emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
+
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "day");
+ }
+ endTag(stylesXmlWriter, NUMBER_PRE, "date-style");
+
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Ndatetime");
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "automatic-order", "true");
+ startTag(stylesXmlWriter, NUMBER_PRE, "date-style");
+ {
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "year");
+ emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
+
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "month");
+
+ emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "day");
+ emptyElement(stylesXmlWriter, NUMBER_PRE, "text", " "); // Separator
+
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "hours");
+
+ emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
+
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "minutes");
+
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
+
+ setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "seconds");
+ }
+ endTag(stylesXmlWriter, NUMBER_PRE, "date-style");
+
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Nbool");
+ emptyTag(stylesXmlWriter, NUMBER_PRE, "boolean-style"); // Displays TRUE/FALSE
+
+ // --- Cell styles referencing data styles ---
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_NUMERIC);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "N0");
+ // Reference N0 number format
+ emptyTag(stylesXmlWriter, STYLE_PRE, "style");
+
+ // Reference Ndate
+ // date
+ // format
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_DATE);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "Ndate");
+ emptyTag(stylesXmlWriter, STYLE_PRE, "style");
+
+ // Reference
+ // Ndatetime
+ // date
+ // format
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_DATETIME);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "Ndatetime");
+ emptyTag(stylesXmlWriter, STYLE_PRE, "style");
+
+ // Reference
+ // Nbool
+ // boolean
+ // format
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_BOOLEAN);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
+ setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "Nbool");
+ emptyTag(stylesXmlWriter, STYLE_PRE, "style");
+ }
+ endTag(stylesXmlWriter, OFFICE_PRE, "styles"); // office:styles
+
+ startTag(stylesXmlWriter, OFFICE_PRE, "automatic-styles");
+ // Could define column/row styles here too if needed (ta1 example)
+ {
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "ta1");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "master-page-name", "Default"); // Link to master page
+ startTag(stylesXmlWriter, STYLE_PRE, "style");
+ {
+ setAttribute(stylesXmlWriter, TABLE_PRE, "display", "true");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "writing-mode", "lr-tb");
+ emptyTag(stylesXmlWriter, STYLE_PRE, "table-properties");
+ }
+ endTag(stylesXmlWriter, STYLE_PRE, "style");
+ }
+ endTag(stylesXmlWriter, OFFICE_PRE, "automatic-styles");
+
+ startTag(stylesXmlWriter, OFFICE_PRE, "master-styles"); // Required structure
+ {
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Default");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "page-layout-name", "pm1"); // Needs corresponding page-layout
+ emptyTag(stylesXmlWriter, STYLE_PRE, "master-page");
+
+ // Define the page layout referenced above
+ setAttribute(stylesXmlWriter, STYLE_PRE, "name", "pm1");
+ startTag(stylesXmlWriter, STYLE_PRE, "page-layout");
+
+ setAttribute(stylesXmlWriter, FO_PRE, "margin", "0.7874in"); // Example margins
+ setAttribute(stylesXmlWriter, FO_PRE, "page-width", "8.5in");
+ setAttribute(stylesXmlWriter, FO_PRE, "page-height", "11in");
+ setAttribute(stylesXmlWriter, STYLE_PRE, "print-orientation", "portrait");
+ emptyTag(stylesXmlWriter, STYLE_PRE, "page-layout-properties");
+ // Header/Footer styles would go inside page-layout if used
+ endTag(stylesXmlWriter, STYLE_PRE, "page-layout");
+ }
+ endTag(stylesXmlWriter, OFFICE_PRE, "master-styles"); // Required structure
+ }
+ endTag(stylesXmlWriter, OFFICE_PRE, "document-styles"); // office:document-styles
+ }
+
+ private void writeMetaXml(XMLWriter writer) throws IOException {
+ setAttribute(writer, OFFICE_PRE, "version", "1.2");
+ writer.startDocument();
+ declareNamespaces(writer, "office", OFFICE_NS, "meta", META_NS, "dc", DC_NS, "xlink", XLINK_NS);
+ startTag(writer, OFFICE_PRE, "document-meta");
+ {
+ startTag(writer, OFFICE_PRE, "meta");
+ {
+ startTag(writer, META_PRE, "generator");
+ writer.text("Eclipse RDF4J SPARQLResultsODSWriter (ODSWriter)");
+ endTag(writer, META_PRE, "generator");
+ }
+ {
+ startTag(writer, META_PRE, "creation-date");
+ writer.text(ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
+ endTag(writer, META_PRE, "creation-date");
+ }
+ endTag(writer, OFFICE_PRE, "meta");
+ }
+ endTag(writer, OFFICE_PRE, "document-meta");
+ }
+
+ private void writeManifestXml(XMLWriter writer) throws IOException {
+ writer.startDocument();
+ writer.setAttribute("xmlns:manifest", MANIFEST_NS);
+ setAttribute(writer, MANIFEST_PRE, "version", "1.2");
+ startTag(writer, MANIFEST_PRE, "manifest");
+ {
+ setAttribute(writer, MANIFEST_PRE, "full-path", "/");
+ // Version of the ODF standard for this entry
+ setAttribute(writer, MANIFEST_PRE, "version", "1.2");
+ setAttribute(writer, MANIFEST_PRE, "media-type", "application/vnd.oasis.opendocument.spreadsheet");
+ emptyTag(writer, MANIFEST_PRE, "file-entry");
+
+ addFile(writer, "content.xml");
+ addFile(writer, "styles.xml");
+ addFile(writer, "meta.xml");
+ }
+ endTag(writer, MANIFEST_PRE, "manifest"); // manifest:manifest
+ }
+
+ private void addFile(XMLWriter writer, String f) throws IOException {
+ setAttribute(writer, MANIFEST_PRE, "full-path", f);
+ setAttribute(writer, MANIFEST_PRE, "media-type", "text/xml");
+ emptyTag(writer, MANIFEST_PRE, "file-entry");
+ }
+
+ private void writeTableStart(XMLWriter writer, String name, int columnCount) throws IOException {
+ setAttribute(writer, TABLE_PRE, "name", name); // Use name provided
+ setAttribute(writer, TABLE_PRE, "style-name", "ta1"); // Use defined table style
+ startTag(writer, TABLE_PRE, "table");
+
+ // Define columns - using the automatic style 'co1' defined earlier
+ for (int i = 0; i < columnCount; i++) {
+ setAttribute(writer, TABLE_PRE, "style-name", "co1");
+ emptyElement(writer, TABLE_PRE, "table-column", name);
+ }
+ }
+
+ private void writeTableEnd(XMLWriter writer) throws IOException {
+ endTag(writer, TABLE_PRE, "table"); // table:table
+ }
+
+ private void writeHeaderRow(XMLWriter writer, List bindingNames, String headerStyleName)
+ throws IOException {
+ startTag(writer, TABLE_PRE, "table-row");
+ {
+ // writer.attribute(TABLE_NS, "style-name", "ro1"); // Optional: Assume default
+ // row style
+
+ for (String name : bindingNames) {
+ setAttribute(writer, OFFICE_PRE, "value-type", "string");
+ setAttribute(writer, TABLE_PRE, "style-name", headerStyleName); // Apply header style
+ startTag(writer, TABLE_PRE, "table-cell");
+ {
+ startTag(writer, TEXT_PRE, "p");
+ writer.text(name); // XMLWriter handles escaping
+ endTag(writer, TEXT_PRE, "p");
+ }
+ endTag(writer, TABLE_PRE, "table-cell");
+ }
+ }
+ endTag(writer, TABLE_PRE, "table-row"); // table:table-row
+ }
+
+ private void setAttribute(XMLWriter writer, String prefix, String element, String value) {
+ writer.setAttribute(prefix + ":" + element, value);
+
+ }
+
+ // Main cell writing logic using XMLWriter
+ private void writeCell(XMLWriter writer, Value value) throws IOException {
+ if (value.isLiteral()) {
+ handleLiteralCell(writer, (Literal) value);
+ } else if (value.isIRI()) {
+ handleIriCell(writer, (IRI) value, STYLE_IRI); // Default IRI style
+ } else if (value.isBNode()) {
+ writeStringCell(writer, value.stringValue(), STYLE_DEFAULT);
+ } else if (value.isTriple()) {
+ writeStringCell(writer, value.stringValue(), STYLE_DEFAULT); // Or a dedicated style?
+ } else {
+ writeStringCell(writer, value.stringValue(), STYLE_DEFAULT);
+ }
+ }
+
+ // --- Cell Type Handling using XMLWriter ---
+
+ private void handleLiteralCell(XMLWriter writer, Literal l) throws IOException {
+ CoreDatatype cd = l.getCoreDatatype();
+ Optional lang = l.getLanguage();
+
+ if (cd != null && cd.isXSDDatatype()) {
+ handleXsdLiteral(writer, l, cd.asXSDDatatypeOrNull());
+ } else if (lang.isPresent()) {
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT); // Add lang info in comment? maybe later
+ } else if (cd != null && (cd.isRDFDatatype() || cd.isGEODatatype())) {
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
+ } else {
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
+ }
+ }
+
+ private void handleXsdLiteral(XMLWriter writer, Literal l, XSD xsdType) throws IOException {
+ if (xsdType == null) {
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
+ return;
+ }
+
+ try {
+ switch (xsdType) {
+ case BOOLEAN:
+ writeBooleanCell(writer, l.booleanValue(), STYLE_BOOLEAN);
+ break;
+
+ case DECIMAL:
+ case INTEGER:
+ case NEGATIVE_INTEGER:
+ case NON_NEGATIVE_INTEGER:
+ case POSITIVE_INTEGER:
+ case NON_POSITIVE_INTEGER:
+ case LONG:
+ case INT:
+ case SHORT:
+ case BYTE:
+ case UNSIGNED_LONG:
+ case UNSIGNED_INT:
+ case UNSIGNED_SHORT:
+ case UNSIGNED_BYTE:
+ try {
+ BigDecimal decVal = l.decimalValue();
+ // Pass original label for display, double value for storage
+ writeNumericCell(writer, decVal.doubleValue(), l.getLabel(), STYLE_NUMERIC);
+ } catch (NumberFormatException nfe) {
+ writeStringCell(writer, l.getLabel(), STYLE_NUMERIC); // Fallback
+ }
+ break;
+
+ case DOUBLE:
+ writeNumericCell(writer, l.doubleValue(), l.getLabel(), STYLE_NUMERIC);
+ break;
+ case FLOAT:
+ writeNumericCell(writer, l.floatValue(), l.getLabel(), STYLE_NUMERIC);
+ break;
+
+ case DATETIME:
+ case DATETIMESTAMP:
+ // Use LocalDateTime (no timezone info in ODF value attribute)
+ writeDateTimeCell(writer, l.calendarValue().toGregorianCalendar().toZonedDateTime().toLocalDateTime(),
+ STYLE_DATETIME);
+ break;
+ case DATE:
+ writeDateCell(writer, l.calendarValue().toGregorianCalendar().toZonedDateTime().toLocalDate(),
+ STYLE_DATE);
+ break;
+
+ case TIME:
+ case GYEAR:
+ case GMONTH:
+ case GDAY:
+ case GYEARMONTH:
+ case GMONTHDAY:
+ case DURATION:
+ case YEARMONTHDURATION:
+ case DAYTIMEDURATION:
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
+ break;
+
+ case ANYURI:
+ try {
+ handleIriCell(writer, SimpleValueFactory.getInstance().createIRI(l.getLabel()), STYLE_ANY_IRI);
+ } catch (IllegalArgumentException e) {
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
+ }
+ break;
+
+ default:
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
+ break;
+ }
+ } catch (Exception e) {
+ System.err.println("Warn: Error converting literal '" + l.stringValue() + "' type " + xsdType
+ + ". Writing as string. Error: " + e.getMessage());
+ writeStringCell(writer, l.getLabel(), STYLE_DEFAULT); // Fallback
+ }
+ }
+
+ private void handleIriCell(XMLWriter writer, IRI iri, String styleName) throws IOException {
+ String displayString = formatIri(iri);
+ String url = iri.stringValue();
+
+ setAttribute(writer, OFFICE_PRE, "value-type", "string"); // Hyperlinks are fundamentally text cells
+ setAttribute(writer, TABLE_PRE, "style-name", styleName);
+ startTag(writer, TABLE_PRE, "table-cell");
+ {
+ startTag(writer, TEXT_PRE, "p");
+ {
+ // Add hyperlink using text:a
+ setAttribute(writer, XLINK_PRE, "type", "simple");
+ setAttribute(writer, XLINK_PRE, "href", url); // XMLWriter handles attribute escaping
+ startTag(writer, TEXT_PRE, "a");
+ writer.text(displayString); // XMLWriter handles text escaping
+ endTag(writer, TEXT_PRE, "a");
+ }
+ endTag(writer, TEXT_PRE, "p");
+ }
+ endTag(writer, TABLE_PRE, "table-cell");
+ }
+
+ private void writeStringCell(XMLWriter writer, String value, String styleName) throws IOException {
+ setAttribute(writer, OFFICE_PRE, "value-type", "string");
+ setAttribute(writer, TABLE_PRE, "style-name", styleName);
+ startTag(writer, TABLE_PRE, "table-cell");
+
+ startTag(writer, TEXT_NS, "p");
+ writer.text(value); // XMLWriter handles escaping
+ endTag(writer, TEXT_PRE, "p");
+
+ endTag(writer, TABLE_PRE, "table-cell");
+ }
+
+ private void writeNumericCell(XMLWriter writer, double value, String displayValue, String styleName)
+ throws IOException {
+ setAttribute(writer, OFFICE_PRE, "value-type", "float"); // Use float for numbers
+ setAttribute(writer, OFFICE_PRE, "value", String.valueOf(value)); // ODF requires string representation of
+ // number
+ setAttribute(writer, TABLE_PRE, "style-name", styleName); // Style defines display format
+ startTag(writer, TABLE_PRE, "table-cell");
+ {
+ startTag(writer, TEXT_PRE, "p");
+ writer.text(displayValue); // Text content shows original or formatted string
+ endTag(writer, TEXT_PRE, "p");
+ }
+ endTag(writer, TABLE_PRE, "table-cell");
+ }
+
+ private void writeBooleanCell(XMLWriter writer, boolean value, String styleName) throws IOException {
+ setAttribute(writer, OFFICE_PRE, "value-type", "boolean");
+ setAttribute(writer, OFFICE_PRE, "boolean-value", String.valueOf(value)); // "true" or "false"
+ setAttribute(writer, TABLE_PRE, "style-name", styleName);
+ startTag(writer, TABLE_PRE, "table-cell");
+ {
+
+ startTag(writer, TEXT_PRE, "p");
+ writer.text(value ? "TRUE" : "FALSE"); // Text content for display
+ endTag(writer, TEXT_PRE, "p");
+ }
+ endTag(writer, TABLE_PRE, "table-cell");
+ }
+
+ private void writeDateTimeCell(XMLWriter writer, java.time.LocalDateTime dateTime, String styleName)
+ throws IOException {
+ // ODF requires ISO 8601 format YYYY-MM-DDTHH:MM:SS
+ String isoValue = dateTime.format(ODF_DATETIME_FORMATTER);
+
+ setAttribute(writer, OFFICE_PRE, "value-type", "date"); // Type is "date" for both date and datetime
+ setAttribute(writer, OFFICE_PRE, "date-value", isoValue); // Stores full date+time
+ setAttribute(writer, TABLE_PRE, "style-name", styleName); // Style controls display
+ startTag(writer, TABLE_PRE, "table-cell");
+
+ startTag(writer, TEXT_PRE, "p");
+ // Displayed text can be formatted differently by the style,
+ // but putting the ISO value here ensures something is shown if style fails.
+ writer.text(isoValue);
+ endTag(writer, TEXT_PRE, "p");
+
+ endTag(writer, TABLE_PRE, "table-cell");
+ }
+
+ private void writeDateCell(XMLWriter writer, java.time.LocalDate date, String styleName) throws IOException {
+ // ODF requires ISO 8601 format YYYY-MM-DD
+ String isoDateValue = date.format(ODF_DATE_FORMATTER);
+ // ODF stores dates internally as datetime at midnight
+ String isoDateTimeValue = date.atStartOfDay().format(ODF_DATETIME_FORMATTER);
+
+ startTag(writer, TABLE_PRE, "table-cell");
+ setAttribute(writer, OFFICE_PRE, "value-type", "date");
+ setAttribute(writer, OFFICE_PRE, "date-value", isoDateTimeValue); // Store as full date+time
+ setAttribute(writer, TABLE_PRE, "style-name", styleName); // Style controls display (shows date part)
+
+ startTag(writer, TEXT_PRE, "p");
+ writer.text(isoDateValue); // Display the date part
+ endTag(writer, TEXT_PRE, "p");
+
+ endTag(writer, TABLE_PRE, "table-cell");
+ }
+
+ // --- Formatting Helpers ---
+
+ private String formatIri(IRI iri) {
+ String iriStr = iri.stringValue();
+ String namespace = iri.getNamespace();
+ String localName = iri.getLocalName();
+
+ if (prefixes.containsKey(namespace)) {
+ String prefix = prefixes.get(namespace);
+ if (!localName.isEmpty() && iriStr.equals(namespace + localName)) { // Check for clean split
+ return prefix + ":" + localName;
+ }
+ }
+ // If no prefix or local name is weird, return full IRI (or just local name if
+ // sensible)
+ // Let's prefer the full IRI for clarity in the spreadsheet unless prefixed
+ // if (localName != null && !localName.isEmpty() && iriStr.endsWith(localName))
+ // {
+ // return localName; // Could use this, but full IRI might be less ambiguous
+ // }
+ return iriStr; // Fallback to full IRI string
+ }
+
+ // Remove escapeXml and escapeXmlAttribute methods as XMLWriter handles
+ // escaping.
+ // private String escapeXml(String s) { ... }
+ // private String escapeXmlAttribute(String s) { ... }
+
+ // --- Unimplemented/Simplified Methods from Interface ---
+
+ @Override
+ public void handleBoolean(boolean value) throws QueryResultHandlerException {
+ System.err.println("Warning: handleBoolean (SPARQL ASK result) not implemented for ODF writer.");
+ startDocument();
+ MapBindingSet result = new MapBindingSet();
+ startQueryResult(List.of("result"));
+ result.setBinding("result", SimpleValueFactory.getInstance().createLiteral(value));
+ handleSolution(result);
+ endQueryResult();
+ endDocument();
+ }
+
+ @Override
+ public void handleLinks(List linkUrls) throws QueryResultHandlerException {
+ // Could store these in meta.xml meta:user-defined fields if needed
+ System.err.println("Warning: handleLinks (document-level links) not implemented for ODF writer.");
+ }
+
+ @Override
+ public QueryResultFormat getQueryResultFormat() {
+ return TupleQueryResultFormat.ODS;
+ }
+
+ @Override
+ public TupleQueryResultFormat getTupleQueryResultFormat() {
+ return TupleQueryResultFormat.ODS;
+ }
+
+ // Keep other interface methods (setWriterConfig, getWriterConfig, etc.) as they
+ // were
+
+ @Override
+ public void setWriterConfig(WriterConfig config) {
+ // Configuration options could be added (e.g., date formats, default styles)
+ }
+
+ @Override
+ public WriterConfig getWriterConfig() {
+ return new WriterConfig(); // Return default/empty config
+ }
+
+ @Override
+ public Collection> getSupportedSettings() {
+ return Collections.emptyList(); // No specific settings supported yet
+ }
+
+ @Override
+ public void handleStylesheet(String stylesheetUrl) throws QueryResultHandlerException {
+ // Not applicable/supported for direct ODF generation
+ }
+
+ @Override
+ public void startHeader() throws QueryResultHandlerException {
+ // Handled within startQueryResult for ODF table structure
+ }
+
+ @Override
+ public void endHeader() throws QueryResultHandlerException {
+ // Handled within startQueryResult/endQueryResult
+ }
+
+}
diff --git a/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/SPARQLResultsODSWriterFactory.java b/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/SPARQLResultsODSWriterFactory.java
new file mode 100644
index 00000000000..4f72a02e730
--- /dev/null
+++ b/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/SPARQLResultsODSWriterFactory.java
@@ -0,0 +1,37 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.query.resultio.sparqlods;
+
+import java.io.OutputStream;
+
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriter;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriterFactory;
+
+public class SPARQLResultsODSWriterFactory implements TupleQueryResultWriterFactory {
+
+ public SPARQLResultsODSWriterFactory() {
+ super();
+ }
+
+ @Override
+ public TupleQueryResultFormat getTupleQueryResultFormat() {
+ return TupleQueryResultFormat.ODS;
+ }
+
+ /**
+ * Returns a new instance of SPARQLResultsODSWriter.
+ */
+ @Override
+ public TupleQueryResultWriter getWriter(OutputStream out) {
+ return new SPARQLResultsODSWriter(out);
+ }
+}
diff --git a/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/package.html b/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/package.html
new file mode 100644
index 00000000000..8e223d4fac3
--- /dev/null
+++ b/core/queryresultio/ods/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlods/package.html
@@ -0,0 +1,6 @@
+
+
+
+ Writers for custom ODS for SPARQL results
+
+
\ No newline at end of file
diff --git a/core/queryresultio/ods/src/test/java/org/eclipse/rdf4j/query/resultio/sparqlxml/SPARQLODSTupleTest.java b/core/queryresultio/ods/src/test/java/org/eclipse/rdf4j/query/resultio/sparqlxml/SPARQLODSTupleTest.java
new file mode 100644
index 00000000000..9bf748fdd8c
--- /dev/null
+++ b/core/queryresultio/ods/src/test/java/org/eclipse/rdf4j/query/resultio/sparqlxml/SPARQLODSTupleTest.java
@@ -0,0 +1,83 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.query.resultio.sparqlxml;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.impl.MapBindingSet;
+import org.eclipse.rdf4j.query.impl.TupleQueryResultBuilder;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriter;
+import org.eclipse.rdf4j.query.resultio.sparqlods.SPARQLResultsODSWriter;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * @author Jerven Bolleman
+ */
+public class SPARQLODSTupleTest {
+
+ @Test
+ void simpleCase(@TempDir Path dir) throws IOException {
+ Files.createDirectories(dir);
+
+ Path tf = dir.resolve("test.ods");
+// Path tf = java.nio.file.Paths.get(System.getProperty("user.home")+"/test.ods");
+ TupleQueryResultBuilder b = new TupleQueryResultBuilder();
+ b.startQueryResult(List.of("boolean", "iri", "int"));
+ MapBindingSet bs = new MapBindingSet();
+ bs.setBinding("boolean", SimpleValueFactory.getInstance().createLiteral(true));
+ bs.setBinding("iri", SimpleValueFactory.getInstance().createIRI("https://example.org/iri"));
+ bs.setBinding("int", SimpleValueFactory.getInstance().createLiteral(1));
+ b.handleSolution(bs);
+ MapBindingSet bs2 = new MapBindingSet();
+ bs2.setBinding("boolean", SimpleValueFactory.getInstance().createLiteral(false));
+ bs2.setBinding("iri", SimpleValueFactory.getInstance().createIRI("https://example.org/iri/test"));
+ bs2.setBinding("int", SimpleValueFactory.getInstance().createLiteral(-9));
+ b.handleSolution(bs2);
+
+ MapBindingSet bs3 = new MapBindingSet();
+ bs3.setBinding("iri", SimpleValueFactory.getInstance().createIRI("http://purl.uniprot.org/taxonomy/9606"));
+ bs3.setBinding("int", SimpleValueFactory.getInstance().createLiteral(-9));
+ b.handleSolution(bs3);
+ List links = List.of("http://example.org/link1");
+ b.handleLinks(links);
+ try (var out = Files.newOutputStream(tf)) {
+ TupleQueryResultWriter writer = new SPARQLResultsODSWriter(out);
+ writer.startDocument();
+ writer.handleNamespace("taxon", "http://purl.uniprot.org/taxonomy/");
+ writer.handleLinks(links);
+ writer.startQueryResult(new ArrayList<>(bs.getBindingNames()));
+ Iterator iterator = b.getQueryResult().iterator();
+ while (iterator.hasNext())
+ writer.handleSolution(iterator.next());
+ writer.endQueryResult();
+ }
+ assertTrue(Files.size(tf) > 0);
+ try (ZipFile zipFile = new ZipFile(tf.toFile())) {
+ Iterator extends ZipEntry> iter = zipFile.stream().iterator();
+ assertTrue(iter.hasNext());
+ assertNotNull(iter.next().getName());
+ }
+ }
+
+}
diff --git a/core/queryresultio/pom.xml b/core/queryresultio/pom.xml
index 452df71f2e0..6ddaaca2bab 100644
--- a/core/queryresultio/pom.xml
+++ b/core/queryresultio/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryresultio
pom
@@ -15,6 +15,8 @@
binary
sparqljson
sparqlxml
+ xlsx
+ ods
text
diff --git a/core/queryresultio/sparqljson/pom.xml b/core/queryresultio/sparqljson/pom.xml
index d2ee8b37011..c5f589b6457 100644
--- a/core/queryresultio/sparqljson/pom.xml
+++ b/core/queryresultio/sparqljson/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryresultio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryresultio-sparqljson
RDF4J: Query result IO - SPARQL/JSON
diff --git a/core/queryresultio/sparqlxml/pom.xml b/core/queryresultio/sparqlxml/pom.xml
index 3b033592eb8..8d7bee2745b 100644
--- a/core/queryresultio/sparqlxml/pom.xml
+++ b/core/queryresultio/sparqlxml/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryresultio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryresultio-sparqlxml
RDF4J: Query result IO - SPARQL/XML
diff --git a/core/queryresultio/text/pom.xml b/core/queryresultio/text/pom.xml
index 5415c638425..e5f67e0b32e 100644
--- a/core/queryresultio/text/pom.xml
+++ b/core/queryresultio/text/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-queryresultio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-queryresultio-text
RDF4J: Query result IO - plain text booleans
diff --git a/core/queryresultio/text/src/test/java/org/eclipse/rdf4j/query/resultio/text/tsv/SPARQLTSVCustomTest.java b/core/queryresultio/text/src/test/java/org/eclipse/rdf4j/query/resultio/text/tsv/SPARQLTSVCustomTest.java
index a79915dc81d..a24e07083fb 100644
--- a/core/queryresultio/text/src/test/java/org/eclipse/rdf4j/query/resultio/text/tsv/SPARQLTSVCustomTest.java
+++ b/core/queryresultio/text/src/test/java/org/eclipse/rdf4j/query/resultio/text/tsv/SPARQLTSVCustomTest.java
@@ -79,6 +79,17 @@ public void testQuotedXSDStringLiteral() throws Exception {
assertEquals("?test\n\"example\"\n", result);
}
+ @Test
+ public void testQuotedXSDStringLiteralLang() throws Exception {
+ List bindingNames = List.of("test");
+ TupleQueryResult tqr = new IteratingTupleQueryResult(bindingNames,
+ List.of(new ListBindingSet(bindingNames,
+ SimpleValueFactory.getInstance().createLiteral("example", "en"))));
+ String result = writeTupleResult(tqr);
+ assertEquals("?test\n" +
+ "\"example\"@en\n", result);
+ }
+
@Test
public void testQuotedXSDStringLiteralWithSpecialCharacters() throws Exception {
List bindingNames = List.of("test");
@@ -89,6 +100,17 @@ public void testQuotedXSDStringLiteralWithSpecialCharacters() throws Exception {
assertEquals("?test\n\"example\\twith\\nspecial\\\"characters\"\n", result);
}
+ @Test
+ public void testIRI() throws Exception {
+ List bindingNames = List.of("test");
+ TupleQueryResult tqr = new IteratingTupleQueryResult(bindingNames,
+ List.of(new ListBindingSet(bindingNames,
+ SimpleValueFactory.getInstance().createIRI("http://example.org/1"))));
+ String result = writeTupleResult(tqr);
+ assertEquals("?test\n" +
+ "\n", result);
+ }
+
private String writeTupleResult(TupleQueryResult tqr)
throws IOException, TupleQueryResultHandlerException, QueryEvaluationException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
diff --git a/core/queryresultio/xlsx/pom.xml b/core/queryresultio/xlsx/pom.xml
new file mode 100644
index 00000000000..65c3bd02a17
--- /dev/null
+++ b/core/queryresultio/xlsx/pom.xml
@@ -0,0 +1,50 @@
+
+
+ 4.0.0
+
+ org.eclipse.rdf4j
+ rdf4j-queryresultio
+ 5.1.0-SNAPSHOT
+
+ rdf4j-queryresultio-sparqlxlsx
+ RDF4J: Query result IO - XSLX
+ Query result parser and writer implementation for an non standardized SPARQL Query Results XSLX Format.
+
+
+ ${project.groupId}
+ rdf4j-queryresultio-api
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-query
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-model
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-common-xml
+ ${project.version}
+
+
+ ${project.groupId}
+ rdf4j-queryresultio-testsuite
+ ${project.version}
+ test
+
+
+ org.apache.poi
+ poi
+ 5.4.1
+
+
+ org.apache.poi
+ poi-ooxml
+ 5.4.1
+
+
+
diff --git a/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/SPARQLResultsXLSXWriter.java b/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/SPARQLResultsXLSXWriter.java
new file mode 100644
index 00000000000..f55cf1b9495
--- /dev/null
+++ b/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/SPARQLResultsXLSXWriter.java
@@ -0,0 +1,415 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.query.resultio.sparqlxslx;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.poi.common.usermodel.HyperlinkType;
+import org.apache.poi.ooxml.POIXMLProperties;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.Font;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.xssf.usermodel.IndexedColorMap;
+import org.apache.poi.xssf.usermodel.XSSFCell;
+import org.apache.poi.xssf.usermodel.XSSFCellStyle;
+import org.apache.poi.xssf.usermodel.XSSFColor;
+import org.apache.poi.xssf.usermodel.XSSFHyperlink;
+import org.apache.poi.xssf.usermodel.XSSFRichTextString;
+import org.apache.poi.xssf.usermodel.XSSFRow;
+import org.apache.poi.xssf.usermodel.XSSFSheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbookFactory;
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.Literal;
+import org.eclipse.rdf4j.model.Triple;
+import org.eclipse.rdf4j.model.Value;
+import org.eclipse.rdf4j.model.base.CoreDatatype;
+import org.eclipse.rdf4j.model.base.CoreDatatype.XSD;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.Binding;
+import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.QueryResultHandlerException;
+import org.eclipse.rdf4j.query.TupleQueryResultHandlerException;
+import org.eclipse.rdf4j.query.resultio.QueryResultFormat;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriter;
+import org.eclipse.rdf4j.rio.RioSetting;
+import org.eclipse.rdf4j.rio.WriterConfig;
+
+/**
+ * Render a SPARQL result set into an ooxml file.
+ *
+ * @author Jerven Bolleman
+ */
+public class SPARQLResultsXLSXWriter implements TupleQueryResultWriter {
+
+ private OutputStream out;
+ private XSSFWorkbook wb;
+ private XSSFSheet nice;
+ private XSSFSheet raw;
+ private int rawRowIndex;
+ private int niceRowIndex;
+ private final Map columnIndexes = new HashMap<>();
+ private XSSFCellStyle headerCellStyle;
+ private XSSFCellStyle iriCellStyle;
+ private XSSFCellStyle anyiriCellStyle;
+ private final Map prefixes = new HashMap<>();
+
+ public SPARQLResultsXLSXWriter(OutputStream out) {
+ this.out = out;
+ wb = new XSSFWorkbookFactory().create();
+ nice = wb.createSheet("nice");
+ raw = wb.createSheet("raw");
+ headerCellStyle = wb.createCellStyle();
+ IndexedColorMap colorMap = wb.getStylesSource().getIndexedColors();
+ XSSFColor lightGray = new XSSFColor(Color.LIGHT_GRAY, colorMap);
+ XSSFColor blue = new XSSFColor(Color.BLUE, colorMap);
+ XSSFColor magenta = new XSSFColor(Color.MAGENTA, colorMap);
+ headerCellStyle.setFillForegroundColor(lightGray);
+ headerCellStyle.setAlignment(HorizontalAlignment.RIGHT);
+ headerCellStyle.setBorderBottom(BorderStyle.MEDIUM);
+ headerCellStyle.setBorderLeft(BorderStyle.MEDIUM);
+ headerCellStyle.setBorderRight(BorderStyle.MEDIUM);
+ headerCellStyle.setBorderTop(BorderStyle.MEDIUM);
+
+ iriCellStyle = wb.createCellStyle();
+ Font hlinkFont = wb.createFont();
+ hlinkFont.setUnderline(Font.U_SINGLE);
+ hlinkFont.setColor(blue.getIndex());
+ iriCellStyle.setFont(hlinkFont);
+
+ anyiriCellStyle = wb.createCellStyle();
+ Font anyhlinkFont = wb.createFont();
+ anyhlinkFont.setUnderline(Font.U_SINGLE);
+ anyhlinkFont.setColor(magenta.getIndex());
+ anyiriCellStyle.setFont(hlinkFont);
+
+ }
+
+ @Override
+ public void handleBoolean(boolean value) throws QueryResultHandlerException {
+ raw.createRow(0).createCell(0).setCellValue(value);
+ }
+
+ @Override
+ public void handleLinks(List linkUrls) throws QueryResultHandlerException {
+ POIXMLProperties properties = wb.getProperties();
+ properties.getCustomProperties().addProperty("links", linkUrls.stream().collect(Collectors.joining(", ")));
+ }
+
+ @Override
+ public void startQueryResult(List bindingNames) throws TupleQueryResultHandlerException {
+ XSSFRow rawRow = raw.createRow(rawRowIndex++);
+ XSSFRow niceRow = nice.createRow(niceRowIndex++);
+ int columnIndex = 0;
+ for (String bindingName : bindingNames) {
+ columnIndexes.put(bindingName, columnIndex);
+ XSSFCell rawHeader = rawRow.createCell(columnIndex);
+ rawHeader.setCellValue(bindingName);
+ rawHeader.setCellStyle(headerCellStyle);
+ XSSFCell niceHeader = niceRow.createCell(columnIndex);
+ niceHeader.setCellValue(bindingName);
+ niceHeader.setCellStyle(headerCellStyle);
+
+ columnIndex++;
+ }
+ }
+
+ @Override
+ public void endQueryResult() throws TupleQueryResultHandlerException {
+ try {
+ for (int c = 0; c < columnIndexes.size(); c++) {
+ nice.autoSizeColumn(c);
+ raw.autoSizeColumn(c);
+ }
+ wb.write(out);
+ wb.close();
+ } catch (IOException e) {
+ throw new TupleQueryResultHandlerException(e);
+ }
+ }
+
+ @Override
+ public void handleSolution(BindingSet bindingSet) throws TupleQueryResultHandlerException {
+ XSSFRow rawRow = raw.createRow(rawRowIndex++);
+ XSSFRow niceRow = nice.createRow(niceRowIndex++);
+ for (Binding b : bindingSet) {
+ int ci = columnIndexes.get(b.getName());
+ rawRow.createCell(ci).setCellValue(b.getValue().stringValue());
+ XSSFCell nc = niceRow.createCell(ci);
+ Value v = b.getValue();
+ if (v.isLiteral()) {
+ handleLiteral(nc, v);
+ } else if (v instanceof IRI) {
+ handleIri(nc, v);
+ } else if (v.isBNode()) {
+ handleIri(nc, v);
+ } else if (v instanceof Triple) {
+ handleTriple(nc, v);
+ }
+ }
+ }
+
+ private void handleLiteral(XSSFCell nc, Value v) {
+ Literal l = (Literal) v;
+ CoreDatatype cd = l.getCoreDatatype();
+ if (cd != null) {
+ if (cd.isXSDDatatype()) {
+ handeXSDDatatype(nc, l);
+ } else if (cd.isGEODatatype()) {
+ handeGeoDatatype(nc, l);
+ } else if (cd.isRDFDatatype()) {
+ handeRDFDatatype(nc, l);
+ }
+ } else if (l.getLanguage().isPresent()) {
+ handleLanguageString(nc, l);
+ } else {
+ nc.setCellValue(v.stringValue());
+ }
+ }
+
+ private void handeRDFDatatype(XSSFCell nc, Literal l) {
+ defaultFormat(nc, l);
+ }
+
+ private void handeGeoDatatype(XSSFCell nc, Literal l) {
+ defaultFormat(nc, l);
+ }
+
+ private void handeXSDDatatype(XSSFCell nc, Literal l) {
+ XSD as = l.getCoreDatatype().asXSDDatatypeOrNull();
+ if (as == null) {
+ nc.setCellValue(l.stringValue());
+ } else {
+ switch (as) {
+ case ANYURI: {
+ handleIri(nc, SimpleValueFactory.getInstance().createIRI(l.stringValue()));
+ nc.setCellStyle(anyiriCellStyle);
+ break;
+ }
+ case BOOLEAN: {
+ nc.setCellValue(l.booleanValue());
+ break;
+ }
+ case BYTE: {
+ nc.setCellValue(l.byteValue());
+ break;
+ }
+ case DATE: {
+ nc.setCellValue(l.calendarValue().toGregorianCalendar());
+ break;
+ }
+ case DATETIME: {
+ nc.setCellValue(l.calendarValue().toGregorianCalendar());
+ break;
+ }
+ case DATETIMESTAMP: {
+ nc.setCellValue(l.calendarValue().toGregorianCalendar());
+ break;
+ }
+ case DAYTIMEDURATION: {
+ formatAsDate(nc, l);
+ break;
+ }
+ case DECIMAL: {
+ BigDecimal dv = l.decimalValue();
+ nc.setCellValue(dv.toPlainString());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ }
+ case DOUBLE: {
+ nc.setCellValue(l.doubleValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ }
+
+ case FLOAT: {
+ nc.setCellValue(l.floatValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ }
+ case GDAY:
+ formatAsDate(nc, l);
+ break;
+ case GMONTH:
+ formatAsDate(nc, l);
+ break;
+ case GYEAR:
+ formatAsDate(nc, l);
+ break;
+ case GMONTHDAY:
+ formatAsDate(nc, l);
+ break;
+ case GYEARMONTH:
+ formatAsDate(nc, l);
+ break;
+ case DURATION:
+ formatAsDate(nc, l);
+ break;
+ case INT:
+ nc.setCellValue(l.intValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ case INTEGER:
+ nc.setCellValue(l.integerValue().doubleValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ case LONG: {
+ nc.setCellValue(l.longValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ }
+ case NEGATIVE_INTEGER:
+ nc.setCellValue(l.integerValue().doubleValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ case NON_NEGATIVE_INTEGER:
+ nc.setCellValue(l.integerValue().doubleValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ case NON_POSITIVE_INTEGER:
+ nc.setCellValue(l.integerValue().doubleValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ case SHORT:
+ nc.setCellValue(l.shortValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ case UNSIGNED_BYTE:
+ case UNSIGNED_INT:
+ case UNSIGNED_LONG:
+ case UNSIGNED_SHORT:
+ nc.setCellValue(l.longValue());
+ nc.setCellType(CellType.NUMERIC);
+ break;
+ case YEARMONTHDURATION:
+ formatAsDate(nc, l);
+ break;
+ default:
+ defaultFormat(nc, l);
+ break;
+ }
+ }
+ }
+
+ private void defaultFormat(XSSFCell nc, Literal l) {
+ nc.setCellValue(l.stringValue());
+
+ }
+
+ private void formatAsDate(XSSFCell nc, Literal l) {
+ nc.setCellValue(l.stringValue());
+
+ }
+
+ private void handleTriple(XSSFCell nc, Value v) {
+ Triple t = (Triple) v;
+ XSSFRichTextString r = new XSSFRichTextString();
+ r.setString(
+ t.getSubject().stringValue() + " " + formatIri(t.getPredicate()) + " " + t.getObject().stringValue());
+ nc.setCellValue(r);
+ }
+
+ private void handleIri(XSSFCell nc, Value v) {
+ IRI i = (IRI) v;
+ XSSFHyperlink link = wb.getCreationHelper().createHyperlink(HyperlinkType.URL);
+ link.setAddress(v.stringValue());
+ nc.setHyperlink(link);
+
+ String ns = formatIri(i);
+ nc.setCellValue(ns);
+ }
+
+ private String formatIri(IRI i) {
+ String ns;
+ String localName = i.getLocalName();
+ String namespace = i.getNamespace();
+ if (prefixes.containsKey(namespace)) {
+ return prefixes.get(namespace) + ":" + i.stringValue().substring(namespace.length());
+ } else if (localName == null || localName.isEmpty()) {
+ ns = i.stringValue();
+ } else {
+ ns = localName;
+ }
+ return ns;
+ }
+
+ private void handleLanguageString(XSSFCell nc, Literal l) {
+ // TODO indicate language maybe with a comment? or a color
+ nc.setCellValue(l.stringValue());
+ }
+
+ @Override
+ public QueryResultFormat getQueryResultFormat() {
+ return TupleQueryResultFormat.XSLX;
+ }
+
+ @Override
+ public void handleNamespace(String prefix, String uri) throws QueryResultHandlerException {
+ prefixes.put(uri, prefix);
+ }
+
+ @Override
+ public void startDocument() throws QueryResultHandlerException {
+
+ }
+
+ @Override
+ public void setWriterConfig(WriterConfig config) {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public WriterConfig getWriterConfig() {
+ // TODO Auto-generated method stub
+ return null;
+ }
+
+ @Override
+ public Collection> getSupportedSettings() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public TupleQueryResultFormat getTupleQueryResultFormat() {
+ return TupleQueryResultFormat.XSLX;
+ }
+
+ @Override
+ public void handleStylesheet(String stylesheetUrl) throws QueryResultHandlerException {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void startHeader() throws QueryResultHandlerException {
+ // TODO Auto-generated method stub
+
+ }
+
+ @Override
+ public void endHeader() throws QueryResultHandlerException {
+ // TODO Auto-generated method stub
+
+ }
+
+}
diff --git a/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/SPARQLResultsXLSXWriterFactory.java b/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/SPARQLResultsXLSXWriterFactory.java
new file mode 100644
index 00000000000..01430ec9019
--- /dev/null
+++ b/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/SPARQLResultsXLSXWriterFactory.java
@@ -0,0 +1,37 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.query.resultio.sparqlxslx;
+
+import java.io.OutputStream;
+
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriter;
+import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriterFactory;
+
+public class SPARQLResultsXLSXWriterFactory implements TupleQueryResultWriterFactory {
+
+ public SPARQLResultsXLSXWriterFactory() {
+ super();
+ }
+
+ @Override
+ public TupleQueryResultFormat getTupleQueryResultFormat() {
+ return TupleQueryResultFormat.XSLX;
+ }
+
+ /**
+ * Returns a new instance of SPARQLResultsXLSXWriter.
+ */
+ @Override
+ public TupleQueryResultWriter getWriter(OutputStream out) {
+ return new SPARQLResultsXLSXWriter(out);
+ }
+}
diff --git a/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/package.html b/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/package.html
new file mode 100644
index 00000000000..343a35045a7
--- /dev/null
+++ b/core/queryresultio/xlsx/src/main/java/org/eclipse/rdf4j/query/resultio/sparqlxslx/package.html
@@ -0,0 +1,6 @@
+
+
+
+ writers for custom XLSX for SPARQL results
+
+
\ No newline at end of file
diff --git a/core/queryresultio/xlsx/src/main/resources/META-INF/services/org.eclipse.rdf4j.query.resultio.TupleQueryResultWriterFactory b/core/queryresultio/xlsx/src/main/resources/META-INF/services/org.eclipse.rdf4j.query.resultio.TupleQueryResultWriterFactory
new file mode 100644
index 00000000000..e2c32a427b4
--- /dev/null
+++ b/core/queryresultio/xlsx/src/main/resources/META-INF/services/org.eclipse.rdf4j.query.resultio.TupleQueryResultWriterFactory
@@ -0,0 +1 @@
+org.eclipse.rdf4j.query.resultio.sparqlxslx.SPARQLResultsXLSXWriterFactory
diff --git a/core/queryresultio/xlsx/src/test/java/org/eclipse/rdf4j/query/resultio/sparqlxml/SPARQLXLSXTupleTest.java b/core/queryresultio/xlsx/src/test/java/org/eclipse/rdf4j/query/resultio/sparqlxml/SPARQLXLSXTupleTest.java
new file mode 100644
index 00000000000..5039885c354
--- /dev/null
+++ b/core/queryresultio/xlsx/src/test/java/org/eclipse/rdf4j/query/resultio/sparqlxml/SPARQLXLSXTupleTest.java
@@ -0,0 +1,89 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.query.resultio.sparqlxml;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import org.apache.poi.xssf.usermodel.XSSFRow;
+import org.apache.poi.xssf.usermodel.XSSFSheet;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
+import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.impl.MapBindingSet;
+import org.eclipse.rdf4j.query.impl.TupleQueryResultBuilder;
+import org.eclipse.rdf4j.query.resultio.sparqlxslx.SPARQLResultsXLSXWriter;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * @author Jerven Bolleman
+ */
+public class SPARQLXLSXTupleTest {
+
+ @Test
+ void simpleCase(@TempDir Path dir) throws IOException {
+ Files.createDirectories(dir);
+
+ Path tf = dir.resolve("test.xlsx");
+// Path tf = Paths.get("/home/jbollema/test.xlsx");
+ TupleQueryResultBuilder b = new TupleQueryResultBuilder();
+ b.startQueryResult(List.of("boolean", "iri", "int"));
+ MapBindingSet bs = new MapBindingSet();
+ bs.setBinding("boolean", SimpleValueFactory.getInstance().createLiteral(true));
+ bs.setBinding("iri", SimpleValueFactory.getInstance().createIRI("https://example.org/iri"));
+ bs.setBinding("int", SimpleValueFactory.getInstance().createLiteral(1));
+ b.handleSolution(bs);
+ MapBindingSet bs2 = new MapBindingSet();
+ bs2.setBinding("boolean", SimpleValueFactory.getInstance().createLiteral(false));
+ bs2.setBinding("iri", SimpleValueFactory.getInstance().createIRI("https://example.org/iri/test"));
+ bs2.setBinding("int", SimpleValueFactory.getInstance().createLiteral(-9));
+ b.handleSolution(bs2);
+
+ MapBindingSet bs3 = new MapBindingSet();
+ bs3.setBinding("iri", SimpleValueFactory.getInstance().createIRI("http://purl.uniprot.org/taxonomy/9606"));
+ bs3.setBinding("int", SimpleValueFactory.getInstance().createLiteral(-9));
+ b.handleSolution(bs3);
+ List links = List.of("http://example.org/link1");
+ b.handleLinks(links);
+ try (var out = Files.newOutputStream(tf)) {
+ SPARQLResultsXLSXWriter writer = new SPARQLResultsXLSXWriter(out);
+ writer.handleNamespace("taxon", "http://purl.uniprot.org/taxonomy/");
+ writer.handleLinks(links);
+ writer.startQueryResult(new ArrayList<>(bs.getBindingNames()));
+ Iterator iterator = b.getQueryResult().iterator();
+ while (iterator.hasNext())
+ writer.handleSolution(iterator.next());
+ writer.endQueryResult();
+ }
+ assertTrue(Files.size(tf) > 0);
+
+ try (XSSFWorkbook wb = new XSSFWorkbook(Files.newInputStream(tf))) {
+ XSSFSheet raw = wb.getSheet("raw");
+ assertNotNull(raw);
+ assertNotNull(wb.getSheet("nice"));
+
+ XSSFRow headerRow = raw.getRow(0);
+ assertEquals("boolean", headerRow.getCell(0).getStringCellValue());
+ assertEquals("iri", headerRow.getCell(1).getStringCellValue());
+ assertEquals("int", headerRow.getCell(2).getStringCellValue());
+ }
+ }
+
+}
diff --git a/core/repository/api/pom.xml b/core/repository/api/pom.xml
index 5263b5485fc..48fdeecfad7 100644
--- a/core/repository/api/pom.xml
+++ b/core/repository/api/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-api
RDF4J: Repository - API
diff --git a/core/repository/contextaware/pom.xml b/core/repository/contextaware/pom.xml
index d88f99b4ca9..39bcbf0668c 100644
--- a/core/repository/contextaware/pom.xml
+++ b/core/repository/contextaware/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-contextaware
RDF4J: Repository - context aware (wrapper)
diff --git a/core/repository/dataset/pom.xml b/core/repository/dataset/pom.xml
index 03805296331..c843dccda42 100644
--- a/core/repository/dataset/pom.xml
+++ b/core/repository/dataset/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-dataset
RDF4J: DatasetRepository (wrapper)
diff --git a/core/repository/event/pom.xml b/core/repository/event/pom.xml
index b33b51bca3c..3a3109cf967 100644
--- a/core/repository/event/pom.xml
+++ b/core/repository/event/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-event
RDF4J: Repository - event (wrapper)
diff --git a/core/repository/http/pom.xml b/core/repository/http/pom.xml
index 8098449942f..4fc07dc06e9 100644
--- a/core/repository/http/pom.xml
+++ b/core/repository/http/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-http
RDF4J: HTTPRepository
diff --git a/core/repository/manager/pom.xml b/core/repository/manager/pom.xml
index 84acb9a8545..bad53d7101e 100644
--- a/core/repository/manager/pom.xml
+++ b/core/repository/manager/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-manager
RDF4J: Repository manager
diff --git a/core/repository/pom.xml b/core/repository/pom.xml
index 203db3590d5..873757a9580 100644
--- a/core/repository/pom.xml
+++ b/core/repository/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository
pom
diff --git a/core/repository/sail/pom.xml b/core/repository/sail/pom.xml
index df84b7c318e..fb8fc483b2b 100644
--- a/core/repository/sail/pom.xml
+++ b/core/repository/sail/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-sail
RDF4J: SailRepository
diff --git a/core/repository/sparql/pom.xml b/core/repository/sparql/pom.xml
index 6ec77048e76..66d5ee263ce 100644
--- a/core/repository/sparql/pom.xml
+++ b/core/repository/sparql/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-repository
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-repository-sparql
RDF4J: SPARQL Repository
diff --git a/core/rio/api/pom.xml b/core/rio/api/pom.xml
index 699962c59e8..3733b074423 100644
--- a/core/rio/api/pom.xml
+++ b/core/rio/api/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-api
RDF4J: Rio - API
diff --git a/core/rio/binary/pom.xml b/core/rio/binary/pom.xml
index 2fbeaf163b0..4ff5469eddf 100644
--- a/core/rio/binary/pom.xml
+++ b/core/rio/binary/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-binary
RDF4J: Rio - Binary
diff --git a/core/rio/datatypes/pom.xml b/core/rio/datatypes/pom.xml
index 75453263294..b9d9dcadcfb 100644
--- a/core/rio/datatypes/pom.xml
+++ b/core/rio/datatypes/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-datatypes
RDF4J: Rio - Datatypes
diff --git a/core/rio/hdt/pom.xml b/core/rio/hdt/pom.xml
index ecc1fe4e84d..f3647663a65 100644
--- a/core/rio/hdt/pom.xml
+++ b/core/rio/hdt/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-hdt
jar
diff --git a/core/rio/jsonld-legacy/pom.xml b/core/rio/jsonld-legacy/pom.xml
index e4e24776a49..7509ef4b834 100644
--- a/core/rio/jsonld-legacy/pom.xml
+++ b/core/rio/jsonld-legacy/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-jsonld-legacy
RDF4J: Rio - JSON-LD 1.0 (legacy)
diff --git a/core/rio/jsonld/pom.xml b/core/rio/jsonld/pom.xml
index fc9a36629e6..998013a10ec 100644
--- a/core/rio/jsonld/pom.xml
+++ b/core/rio/jsonld/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-jsonld
RDF4J: Rio - JSON-LD
diff --git a/core/rio/languages/pom.xml b/core/rio/languages/pom.xml
index 86c0a2365f4..b35931702bb 100644
--- a/core/rio/languages/pom.xml
+++ b/core/rio/languages/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-languages
RDF4J: Rio - Languages
diff --git a/core/rio/n3/pom.xml b/core/rio/n3/pom.xml
index d2a38c44896..54ae5fe573c 100644
--- a/core/rio/n3/pom.xml
+++ b/core/rio/n3/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-n3
RDF4J: Rio - N3 (writer-only)
diff --git a/core/rio/nquads/pom.xml b/core/rio/nquads/pom.xml
index 5082d6bac1b..044216524fb 100644
--- a/core/rio/nquads/pom.xml
+++ b/core/rio/nquads/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-nquads
RDF4J: Rio - N-Quads
diff --git a/core/rio/ntriples/pom.xml b/core/rio/ntriples/pom.xml
index fddd42e5463..37553504ed5 100644
--- a/core/rio/ntriples/pom.xml
+++ b/core/rio/ntriples/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-ntriples
RDF4J: Rio - N-Triples
diff --git a/core/rio/pom.xml b/core/rio/pom.xml
index c2d0ce909cf..77fc1cee292 100644
--- a/core/rio/pom.xml
+++ b/core/rio/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-core
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio
pom
diff --git a/core/rio/rdfjson/pom.xml b/core/rio/rdfjson/pom.xml
index c8800e3d20f..55a3e3ad374 100644
--- a/core/rio/rdfjson/pom.xml
+++ b/core/rio/rdfjson/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-rdfjson
RDF4J: Rio - RDF/JSON
diff --git a/core/rio/rdfxml/pom.xml b/core/rio/rdfxml/pom.xml
index d8ce5a3ca9c..08a1e0f6eb3 100644
--- a/core/rio/rdfxml/pom.xml
+++ b/core/rio/rdfxml/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-rdfxml
RDF4J: Rio - RDF/XML
diff --git a/core/rio/trig/pom.xml b/core/rio/trig/pom.xml
index 529637be92c..2736b12a916 100644
--- a/core/rio/trig/pom.xml
+++ b/core/rio/trig/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-trig
RDF4J: Rio - TriG
diff --git a/core/rio/trix/pom.xml b/core/rio/trix/pom.xml
index 6de5577c41a..a8b99df7634 100644
--- a/core/rio/trix/pom.xml
+++ b/core/rio/trix/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-trix
RDF4J: Rio - TriX
diff --git a/core/rio/turtle/pom.xml b/core/rio/turtle/pom.xml
index 39fe9c9db21..087f4221601 100644
--- a/core/rio/turtle/pom.xml
+++ b/core/rio/turtle/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-rio
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-rio-turtle
RDF4J: Rio - Turtle
diff --git a/core/sail/api/pom.xml b/core/sail/api/pom.xml
index be83b7d755e..94ba13deba0 100644
--- a/core/sail/api/pom.xml
+++ b/core/sail/api/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-sail
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-sail-api
RDF4J: Sail API
diff --git a/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/SailConnectionListener.java b/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/SailConnectionListener.java
index 360a7e6df83..e0f02b44a03 100644
--- a/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/SailConnectionListener.java
+++ b/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/SailConnectionListener.java
@@ -13,12 +13,32 @@
import org.eclipse.rdf4j.model.Statement;
public interface SailConnectionListener {
+ /**
+ * Notifies the listener that a statement has been added in a transaction that it has registered itself with.
+ *
+ * @param st The statement that was added.
+ * @param inferred The flag that indicates whether the statement is inferred or explicit.
+ */
+ default void statementAdded(Statement st, boolean inferred) {
+ statementAdded(st);
+ }
+
+ /**
+ * Notifies the listener that a statement has been removed in a transaction that it has registered itself with.
+ *
+ * @param st The statement that was removed.
+ * @param inferred The flag that indicates whether the statement was inferred or explicit.
+ */
+ default void statementRemoved(Statement st, boolean inferred) {
+ statementRemoved(st);
+ }
/**
* Notifies the listener that a statement has been added in a transaction that it has registered itself with.
*
* @param st The statement that was added.
*/
+ @Deprecated(since = "5.2.0", forRemoval = true)
void statementAdded(Statement st);
/**
@@ -26,5 +46,6 @@ public interface SailConnectionListener {
*
* @param st The statement that was removed.
*/
+ @Deprecated(since = "5.2.0", forRemoval = true)
void statementRemoved(Statement st);
}
diff --git a/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractNotifyingSailConnection.java b/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractNotifyingSailConnection.java
index fe21c3c3dea..7c7b40d1d9f 100644
--- a/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractNotifyingSailConnection.java
+++ b/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractNotifyingSailConnection.java
@@ -48,16 +48,26 @@ protected boolean hasConnectionListeners() {
return !listeners.isEmpty();
}
+ @Deprecated(since = "5.2.0", forRemoval = true)
protected void notifyStatementAdded(Statement st) {
+ notifyStatementAdded(st, false);
+ }
+
+ protected void notifyStatementAdded(Statement st, boolean inferred) {
for (SailConnectionListener listener : listeners) {
- listener.statementAdded(st);
+ listener.statementAdded(st, inferred);
}
}
+ @Deprecated(since = "5.2.0", forRemoval = true)
protected void notifyStatementRemoved(Statement st) {
+ notifyStatementRemoved(st, false);
+ }
+
+ protected void notifyStatementRemoved(Statement st, boolean inferred) {
for (SailConnectionListener listener : listeners) {
- listener.statementRemoved(st);
+ listener.statementRemoved(st, inferred);
}
}
}
diff --git a/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractSailConnection.java b/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractSailConnection.java
index f964487e3c3..81cbad1b1aa 100644
--- a/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractSailConnection.java
+++ b/core/sail/api/src/main/java/org/eclipse/rdf4j/sail/helpers/AbstractSailConnection.java
@@ -23,6 +23,7 @@
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.LockSupport;
+import org.eclipse.rdf4j.common.annotation.Experimental;
import org.eclipse.rdf4j.common.annotation.InternalUseOnly;
import org.eclipse.rdf4j.common.concurrent.locks.ExclusiveReentrantLockManager;
import org.eclipse.rdf4j.common.concurrent.locks.Lock;
@@ -775,10 +776,8 @@ protected void endUpdateInternal(UpdateContext op) throws SailException {
synchronized (added) {
model = added.remove(op);
}
- if (model != null) {
- for (Statement st : model) {
- addStatementInternal(st.getSubject(), st.getPredicate(), st.getObject(), st.getContext());
- }
+ if (model != null && !model.isEmpty()) {
+ bulkAddStatementsInternal(model);
}
}
@@ -1031,6 +1030,14 @@ protected void prepareInternal() throws SailException {
protected abstract void addStatementInternal(Resource subj, IRI pred, Value obj, Resource... contexts)
throws SailException;
+ @Experimental
+ protected void bulkAddStatementsInternal(final Collection extends Statement> statements)
+ throws SailException {
+ for (final Statement st : statements) {
+ addStatementInternal(st.getSubject(), st.getPredicate(), st.getObject(), st.getContext());
+ }
+ }
+
protected abstract void removeStatementsInternal(Resource subj, IRI pred, Value obj, Resource... contexts)
throws SailException;
diff --git a/core/sail/base/pom.xml b/core/sail/base/pom.xml
index 13a81530f9c..37f440d24a5 100644
--- a/core/sail/base/pom.xml
+++ b/core/sail/base/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-sail
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-sail-base
RDF4J: Sail base implementations
diff --git a/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailDatasetImpl.java b/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailDatasetImpl.java
index b90a9008657..5f6fd74407d 100644
--- a/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailDatasetImpl.java
+++ b/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailDatasetImpl.java
@@ -46,7 +46,6 @@ class SailDatasetImpl implements SailDataset {
private static final EmptyIteration TRIPLE_EMPTY_ITERATION = new EmptyIteration<>();
private static final EmptyIteration NAMESPACES_EMPTY_ITERATION = new EmptyIteration<>();
- private static final EmptyIteration STATEMENT_EMPTY_ITERATION = new EmptyIteration<>();
/**
* {@link SailDataset} of the backing {@link SailSource}.
@@ -286,7 +285,7 @@ public CloseableIteration extends Statement> getStatements(Resource subj, IRI
} else if (iter != null) {
return iter;
} else {
- return STATEMENT_EMPTY_ITERATION;
+ return CloseableIteration.EMPTY_STATEMENT_ITERATION;
}
}
diff --git a/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailSourceConnection.java b/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailSourceConnection.java
index 7942984593a..a32f6ba1cb9 100644
--- a/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailSourceConnection.java
+++ b/core/sail/base/src/main/java/org/eclipse/rdf4j/sail/base/SailSourceConnection.java
@@ -632,7 +632,7 @@ public void removeStatement(UpdateContext op, Resource subj, IRI pred, Value obj
explicitSinks.put(null, source.sink(getIsolationLevel()));
}
assert explicitSinks.containsKey(op);
- remove(subj, pred, obj, datasets.get(op), explicitSinks.get(op), contexts);
+ remove(subj, pred, obj, false, datasets.get(op), explicitSinks.get(op), contexts);
}
removeStatementsInternal(subj, pred, obj, contexts);
}
@@ -723,7 +723,7 @@ public boolean addInferredStatement(Resource subj, IRI pred, Value obj, Resource
// only report inferred statements that don't already
// exist
addStatementInternal(subj, pred, obj, contexts);
- notifyStatementAdded(vf.createStatement(subj, pred, obj));
+ notifyStatementAdded(vf.createStatement(subj, pred, obj), true);
setStatementsAdded();
modified = true;
}
@@ -746,7 +746,7 @@ public boolean addInferredStatement(Resource subj, IRI pred, Value obj, Resource
// only report inferred statements that don't
// already exist
addStatementInternal(subj, pred, obj, ctx);
- notifyStatementAdded(vf.createStatement(subj, pred, obj, ctx));
+ notifyStatementAdded(vf.createStatement(subj, pred, obj, ctx), true);
setStatementsAdded();
modified = true;
}
@@ -762,9 +762,9 @@ private void add(Resource subj, IRI pred, Value obj, SailDataset dataset, SailSi
if (contexts.length == 0 || (contexts.length == 1 && contexts[0] == null)) {
if (hasConnectionListeners()) {
if (!hasStatement(dataset, subj, pred, obj, NULL_CTX)) {
- notifyStatementAdded(vf.createStatement(subj, pred, obj));
+ notifyStatementAdded(vf.createStatement(subj, pred, obj), false);
} else if (sink instanceof Changeset && ((Changeset) sink).hasDeprecated(subj, pred, obj, NULL_CTX)) {
- notifyStatementAdded(vf.createStatement(subj, pred, obj));
+ notifyStatementAdded(vf.createStatement(subj, pred, obj), false);
}
// always approve the statement, even if it already exists
@@ -788,10 +788,10 @@ private void add(Resource subj, IRI pred, Value obj, SailDataset dataset, SailSi
if (hasConnectionListeners()) {
if (!hasStatement(dataset, subj, pred, obj, contextsToCheck)) {
- notifyStatementAdded(vf.createStatement(subj, pred, obj, ctx));
+ notifyStatementAdded(vf.createStatement(subj, pred, obj, ctx), false);
} else if (sink instanceof Changeset
&& ((Changeset) sink).hasDeprecated(subj, pred, obj, contextsToCheck)) {
- notifyStatementAdded(vf.createStatement(subj, pred, obj));
+ notifyStatementAdded(vf.createStatement(subj, pred, obj), false);
}
sink.approve(subj, pred, obj, ctx);
} else {
@@ -815,7 +815,7 @@ public boolean removeInferredStatement(Resource subj, IRI pred, Value obj, Resou
explicitOnlyDataset = branch(IncludeInferred.explicitOnly).dataset(level);
}
removeStatementsInternal(subj, pred, obj, contexts);
- boolean removed = remove(subj, pred, obj, inferredOnlyDataset, inferredOnlySink, contexts);
+ boolean removed = remove(subj, pred, obj, true, inferredOnlyDataset, inferredOnlySink, contexts);
if (removed) {
setStatementsRemoved();
}
@@ -823,7 +823,8 @@ public boolean removeInferredStatement(Resource subj, IRI pred, Value obj, Resou
}
}
- private boolean remove(Resource subj, IRI pred, Value obj, SailDataset dataset, SailSink sink, Resource... contexts)
+ private boolean remove(Resource subj, IRI pred, Value obj, boolean inferred, SailDataset dataset, SailSink sink,
+ Resource... contexts)
throws SailException {
// Use deprecateByQuery if we don't need to notify anyone of which statements have been deleted.
@@ -839,7 +840,7 @@ private boolean remove(Resource subj, IRI pred, Value obj, SailDataset dataset,
Statement st = iter.next();
sink.deprecate(st);
statementsRemoved = true;
- notifyStatementRemoved(st);
+ notifyStatementRemoved(st, inferred);
}
}
return statementsRemoved;
@@ -857,7 +858,7 @@ protected void clearInternal(Resource... contexts) throws SailException {
}
assert explicitSinks.containsKey(null);
if (this.hasConnectionListeners()) {
- remove(null, null, null, datasets.get(null), explicitSinks.get(null), contexts);
+ remove(null, null, null, false, datasets.get(null), explicitSinks.get(null), contexts);
}
explicitSinks.get(null).clear(contexts);
}
@@ -876,7 +877,7 @@ public void clearInferred(Resource... contexts) throws SailException {
explicitOnlyDataset = branch(IncludeInferred.explicitOnly).dataset(level);
}
if (this.hasConnectionListeners()) {
- remove(null, null, null, inferredOnlyDataset, inferredOnlySink, contexts);
+ remove(null, null, null, true, inferredOnlyDataset, inferredOnlySink, contexts);
}
inferredOnlySink.clear(contexts);
setStatementsRemoved();
diff --git a/core/sail/elasticsearch-store/pom.xml b/core/sail/elasticsearch-store/pom.xml
index 16cfec25798..8542f4a1aaa 100644
--- a/core/sail/elasticsearch-store/pom.xml
+++ b/core/sail/elasticsearch-store/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-sail
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-sail-elasticsearch-store
RDF4J: Elasticsearch Store
diff --git a/core/sail/elasticsearch-store/src/test/java/org/eclipse/rdf4j/sail/elasticsearchstore/compliance/ElasticsearchStoreRepositoryIT.java b/core/sail/elasticsearch-store/src/test/java/org/eclipse/rdf4j/sail/elasticsearchstore/compliance/ElasticsearchStoreRepositoryIT.java
index 899df2fc981..a7542ab304c 100644
--- a/core/sail/elasticsearch-store/src/test/java/org/eclipse/rdf4j/sail/elasticsearchstore/compliance/ElasticsearchStoreRepositoryIT.java
+++ b/core/sail/elasticsearch-store/src/test/java/org/eclipse/rdf4j/sail/elasticsearchstore/compliance/ElasticsearchStoreRepositoryIT.java
@@ -48,4 +48,5 @@ protected Repository createRepository() {
public void testShutdownFollowedByInit() {
// ignore test
}
+
}
diff --git a/core/sail/elasticsearch/pom.xml b/core/sail/elasticsearch/pom.xml
index efaaeef072e..022319c4697 100644
--- a/core/sail/elasticsearch/pom.xml
+++ b/core/sail/elasticsearch/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-sail
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-sail-elasticsearch
RDF4J: Elastic Search Sail Index
diff --git a/core/sail/extensible-store/pom.xml b/core/sail/extensible-store/pom.xml
index ba19e28d29a..e8d6a1af491 100644
--- a/core/sail/extensible-store/pom.xml
+++ b/core/sail/extensible-store/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-sail
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-sail-extensible-store
RDF4J: Extensible Store
diff --git a/core/sail/inferencer/pom.xml b/core/sail/inferencer/pom.xml
index 7040c569ace..45babf4a3e2 100644
--- a/core/sail/inferencer/pom.xml
+++ b/core/sail/inferencer/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-sail
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-sail-inferencer
RDF4J: Inferencer Sails
diff --git a/core/sail/lmdb/pom.xml b/core/sail/lmdb/pom.xml
index e529a1744ae..787fb19c4d4 100644
--- a/core/sail/lmdb/pom.xml
+++ b/core/sail/lmdb/pom.xml
@@ -4,7 +4,7 @@
org.eclipse.rdf4j
rdf4j-sail
- 5.1.7-SNAPSHOT
+ 5.1.0-SNAPSHOT
rdf4j-sail-lmdb
RDF4J: LmdbStore
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java
index 197c68deb5f..68c5352a73b 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbRecordIterator.java
@@ -29,7 +29,7 @@
import org.eclipse.rdf4j.sail.SailException;
import org.eclipse.rdf4j.sail.lmdb.TripleStore.TripleIndex;
import org.eclipse.rdf4j.sail.lmdb.TxnManager.Txn;
-import org.eclipse.rdf4j.sail.lmdb.Varint.GroupMatcher;
+import org.eclipse.rdf4j.sail.lmdb.util.GroupMatcher;
import org.lwjgl.PointerBuffer;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.util.lmdb.MDBVal;
@@ -45,11 +45,17 @@ class LmdbRecordIterator implements RecordIterator {
private final TripleIndex index;
+ private final long subj;
+ private final long pred;
+ private final long obj;
+ private final long context;
+
private final long cursor;
private final MDBVal maxKey;
- private final GroupMatcher groupMatcher;
+ private final boolean matchValues;
+ private GroupMatcher groupMatcher;
private final Txn txnRef;
@@ -71,7 +77,8 @@ class LmdbRecordIterator implements RecordIterator {
private int lastResult;
- private final long[] quad = new long[4];
+ private final long[] quad;
+ private final long[] originalQuad;
private boolean fetchNext = false;
@@ -81,6 +88,12 @@ class LmdbRecordIterator implements RecordIterator {
LmdbRecordIterator(TripleIndex index, boolean rangeSearch, long subj, long pred, long obj,
long context, boolean explicit, Txn txnRef) throws IOException {
+ this.subj = subj;
+ this.pred = pred;
+ this.obj = obj;
+ this.context = context;
+ this.originalQuad = new long[] { subj, pred, obj, context };
+ this.quad = new long[] { subj, pred, obj, context };
this.pool = Pool.get();
this.keyData = pool.getVal();
this.valueData = pool.getVal();
@@ -100,12 +113,8 @@ class LmdbRecordIterator implements RecordIterator {
this.maxKey = null;
}
- boolean matchValues = subj > 0 || pred > 0 || obj > 0 || context >= 0;
- if (matchValues) {
- this.groupMatcher = index.createMatcher(subj, pred, obj, context);
- } else {
- this.groupMatcher = null;
- }
+ this.matchValues = subj > 0 || pred > 0 || obj > 0 || context >= 0;
+
this.dbi = index.getDB(explicit);
this.txnRef = txnRef;
this.txnLockManager = txnRef.lockManager();
@@ -145,6 +154,7 @@ public long[] next() {
}
if (txnRefVersion != txnRef.version()) {
+ // TODO: None of the tests in the LMDB Store cover this case!
// cursor must be renewed
mdb_cursor_renew(txn, cursor);
if (fetchNext) {
@@ -188,12 +198,12 @@ public long[] next() {
// if (maxKey != null && TripleStore.COMPARATOR.compare(keyData.mv_data(), maxKey.mv_data()) > 0) {
if (maxKey != null && mdb_cmp(txn, dbi, keyData, maxKey) > 0) {
lastResult = MDB_NOTFOUND;
- } else if (groupMatcher != null && !groupMatcher.matches(keyData.mv_data())) {
+ } else if (matches()) {
// value doesn't match search key/mask, fetch next value
lastResult = mdb_cursor_get(cursor, keyData, valueData, MDB_NEXT);
} else {
// Matching value found
- index.keyToQuad(keyData.mv_data(), quad);
+ index.keyToQuad(keyData.mv_data(), originalQuad, quad);
// fetch next value
fetchNext = true;
return quad;
@@ -206,6 +216,18 @@ public long[] next() {
}
}
+ private boolean matches() {
+
+ if (groupMatcher != null) {
+ return !this.groupMatcher.matches(keyData.mv_data());
+ } else if (matchValues) {
+ this.groupMatcher = index.createMatcher(subj, pred, obj, context);
+ return !this.groupMatcher.matches(keyData.mv_data());
+ } else {
+ return false;
+ }
+ }
+
private void closeInternal(boolean maybeCalledAsync) {
if (!closed) {
long writeStamp = 0L;
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStore.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStore.java
index 90212ad598b..6d87d4bc33e 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStore.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStore.java
@@ -29,7 +29,6 @@
import org.eclipse.rdf4j.common.iteration.CloseableIteration;
import org.eclipse.rdf4j.common.iteration.CloseableIteratorIteration;
import org.eclipse.rdf4j.common.iteration.ConvertingIteration;
-import org.eclipse.rdf4j.common.iteration.EmptyIteration;
import org.eclipse.rdf4j.common.iteration.FilterIteration;
import org.eclipse.rdf4j.common.iteration.UnionIteration;
import org.eclipse.rdf4j.common.order.StatementOrder;
@@ -72,6 +71,7 @@ class LmdbSailStore implements SailStore {
private boolean multiThreadingActive;
private volatile boolean asyncTransactionFinished;
private volatile boolean nextTransactionAsync;
+ private volatile boolean mayHaveInferred;
boolean enableMultiThreading = true;
@@ -143,6 +143,9 @@ class AddQuadOperation implements Operation {
@Override
public void execute() throws IOException {
+ if (!explicit) {
+ mayHaveInferred = true;
+ }
if (!unusedIds.isEmpty()) {
// these ids are used again
unusedIds.remove(s);
@@ -191,8 +194,10 @@ public LmdbSailStore(File dataDir, LmdbStoreConfig config) throws IOException, S
boolean initialized = false;
try {
namespaceStore = new NamespaceStore(dataDir);
- valueStore = new ValueStore(new File(dataDir, "values"), config);
- tripleStore = new TripleStore(new File(dataDir, "triples"), config);
+ var valueStore = new ValueStore(new File(dataDir, "values"), config);
+ this.valueStore = valueStore;
+ tripleStore = new TripleStore(new File(dataDir, "triples"), config, valueStore);
+ mayHaveInferred = tripleStore.hasTriples(false);
initialized = true;
} finally {
if (!initialized) {
@@ -348,11 +353,15 @@ protected void handleClose() throws SailException {
*/
CloseableIteration extends Statement> createStatementIterator(
Txn txn, Resource subj, IRI pred, Value obj, boolean explicit, Resource... contexts) throws IOException {
+ if (!explicit && !mayHaveInferred) {
+ // there are no inferred statements and the iterator should only return inferred statements
+ return CloseableIteration.EMPTY_STATEMENT_ITERATION;
+ }
long subjID = LmdbValue.UNKNOWN_ID;
if (subj != null) {
subjID = valueStore.getId(subj);
if (subjID == LmdbValue.UNKNOWN_ID) {
- return new EmptyIteration<>();
+ return CloseableIteration.EMPTY_STATEMENT_ITERATION;
}
}
@@ -360,7 +369,7 @@ CloseableIteration extends Statement> createStatementIterator(
if (pred != null) {
predID = valueStore.getId(pred);
if (predID == LmdbValue.UNKNOWN_ID) {
- return new EmptyIteration<>();
+ return CloseableIteration.EMPTY_STATEMENT_ITERATION;
}
}
@@ -369,7 +378,7 @@ CloseableIteration extends Statement> createStatementIterator(
objID = valueStore.getId(obj);
if (objID == LmdbValue.UNKNOWN_ID) {
- return new EmptyIteration<>();
+ return CloseableIteration.EMPTY_STATEMENT_ITERATION;
}
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java
index 5fccf7113a1..e4b6429afa8 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/LmdbStatementIterator.java
@@ -11,8 +11,9 @@
package org.eclipse.rdf4j.sail.lmdb;
import java.io.IOException;
+import java.util.NoSuchElementException;
-import org.eclipse.rdf4j.common.iteration.LookAheadIteration;
+import org.eclipse.rdf4j.common.iteration.AbstractCloseableIteration;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Resource;
import org.eclipse.rdf4j.model.Statement;
@@ -23,7 +24,7 @@
* A statement iterator that wraps a RecordIterator containing statement records and translates these records to
* {@link Statement} objects.
*/
-class LmdbStatementIterator extends LookAheadIteration {
+class LmdbStatementIterator extends AbstractCloseableIteration {
/*-----------*
* Variables *
@@ -32,6 +33,7 @@ class LmdbStatementIterator extends LookAheadIteration {
private final RecordIterator recordIt;
private final ValueStore valueStore;
+ private Statement nextElement;
/*--------------*
* Constructors *
@@ -49,7 +51,6 @@ public LmdbStatementIterator(RecordIterator recordIt, ValueStore valueStore) {
* Methods *
*---------*/
- @Override
public Statement getNextElement() throws SailException {
try {
long[] quad = recordIt.next();
@@ -86,4 +87,52 @@ protected void handleClose() throws SailException {
private SailException causeIOException(IOException e) {
return new SailException(e);
}
+
+ @Override
+ public final boolean hasNext() {
+ if (isClosed()) {
+ return false;
+ }
+
+ return lookAhead() != null;
+ }
+
+ @Override
+ public final Statement next() {
+ if (isClosed()) {
+ throw new NoSuchElementException("The iteration has been closed.");
+ }
+ Statement result = lookAhead();
+
+ if (result != null) {
+ nextElement = null;
+ return result;
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ /**
+ * Fetches the next element if it hasn't been fetched yet and stores it in {@link #nextElement}.
+ *
+ * @return The next element, or null if there are no more results.
+ */
+ private Statement lookAhead() {
+ if (nextElement == null) {
+ nextElement = getNextElement();
+
+ if (nextElement == null) {
+ close();
+ }
+ }
+ return nextElement;
+ }
+
+ /**
+ * Throws an {@link UnsupportedOperationException}.
+ */
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/TripleStore.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/TripleStore.java
index a39762135f1..059bb51e666 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/TripleStore.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/TripleStore.java
@@ -14,8 +14,7 @@
import static org.eclipse.rdf4j.sail.lmdb.LmdbUtil.openDatabase;
import static org.eclipse.rdf4j.sail.lmdb.LmdbUtil.readTransaction;
import static org.eclipse.rdf4j.sail.lmdb.LmdbUtil.transaction;
-import static org.eclipse.rdf4j.sail.lmdb.Varint.readListUnsigned;
-import static org.eclipse.rdf4j.sail.lmdb.Varint.writeUnsigned;
+import static org.eclipse.rdf4j.sail.lmdb.Varint.readQuadUnsigned;
import static org.lwjgl.system.MemoryStack.stackPush;
import static org.lwjgl.system.MemoryUtil.NULL;
import static org.lwjgl.util.lmdb.LMDB.MDB_CREATE;
@@ -75,7 +74,8 @@
import java.util.Properties;
import java.util.Set;
import java.util.StringTokenizer;
-import java.util.concurrent.locks.StampedLock;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.LongAdder;
import java.util.function.Consumer;
import org.eclipse.rdf4j.common.concurrent.locks.StampedLongAdderLockManager;
@@ -84,8 +84,9 @@
import org.eclipse.rdf4j.sail.lmdb.TxnManager.Txn;
import org.eclipse.rdf4j.sail.lmdb.TxnRecordCache.Record;
import org.eclipse.rdf4j.sail.lmdb.TxnRecordCache.RecordCacheIterator;
-import org.eclipse.rdf4j.sail.lmdb.Varint.GroupMatcher;
import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig;
+import org.eclipse.rdf4j.sail.lmdb.util.GroupMatcher;
+import org.eclipse.rdf4j.sail.lmdb.util.IndexKeyWriters;
import org.lwjgl.PointerBuffer;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.util.lmdb.MDBEnvInfo;
@@ -103,6 +104,11 @@
@SuppressWarnings("deprecation")
class TripleStore implements Closeable {
+ static ConcurrentHashMap stats = new ConcurrentHashMap<>();
+ static long hit = 0;
+ static long fullHit = 0;
+ static long miss = 0;
+
/*-----------*
* Constants *
*-----------*/
@@ -154,6 +160,7 @@ class TripleStore implements Closeable {
* The list of triple indexes that are used to store and retrieve triples.
*/
private final List indexes = new ArrayList<>();
+ private final ValueStore valueStore;
private long env;
private int contextsDbi;
@@ -187,10 +194,11 @@ public int compareRegion(ByteBuffer array1, int startIdx1, ByteBuffer array2, in
}
};
- TripleStore(File dir, LmdbStoreConfig config) throws IOException, SailException {
+ TripleStore(File dir, LmdbStoreConfig config, ValueStore valueStore) throws IOException, SailException {
this.dir = dir;
this.forceSync = config.getForceSync();
this.autoGrow = config.getAutoGrow();
+ this.valueStore = valueStore;
// create directory if it not exists
this.dir.mkdirs();
@@ -508,6 +516,15 @@ public RecordIterator getTriples(Txn txn, long subj, long pred, long obj, long c
return getTriplesUsingIndex(txn, subj, pred, obj, context, explicit, index, doRangeSearch);
}
+ boolean hasTriples(boolean explicit) throws IOException {
+ TripleIndex mainIndex = indexes.get(0);
+ return txnManager.doWith((stack, txn) -> {
+ MDBStat stat = MDBStat.mallocStack(stack);
+ mdb_stat(txn, mainIndex.getDB(explicit), stat);
+ return stat.ms_entries() > 0;
+ });
+ }
+
private RecordIterator getTriplesUsingIndex(Txn txn, long subj, long pred, long obj, long context,
boolean explicit, TripleIndex index, boolean rangeSearch) throws IOException {
return new LmdbRecordIterator(index, rangeSearch, subj, pred, obj, context, explicit, txn);
@@ -1163,11 +1180,15 @@ private void storeProperties(File propFile) throws IOException {
class TripleIndex {
private final char[] fieldSeq;
+ private final IndexKeyWriters.KeyWriter keyWriter;
+ private final IndexKeyWriters.MatcherFactory matcherFactory;
private final int dbiExplicit, dbiInferred;
private final int[] indexMap;
public TripleIndex(String fieldSeq) throws IOException {
this.fieldSeq = fieldSeq.toCharArray();
+ this.keyWriter = IndexKeyWriters.forFieldSeq(fieldSeq);
+ this.matcherFactory = IndexKeyWriters.matcherFactory(fieldSeq);
this.indexMap = getIndexes(this.fieldSeq);
// open database and use native sort order without comparator
dbiExplicit = openDatabase(env, fieldSeq, MDB_CREATE, null);
@@ -1273,52 +1294,131 @@ void getMaxKey(ByteBuffer bb, long subj, long pred, long obj, long context) {
}
GroupMatcher createMatcher(long subj, long pred, long obj, long context) {
- ByteBuffer bb = ByteBuffer.allocate(TripleStore.MAX_KEY_LENGTH);
+ int length = getLength(subj, pred, obj, context);
+
+ ByteBuffer bb = ByteBuffer.allocate(length);
toKey(bb, subj == -1 ? 0 : subj, pred == -1 ? 0 : pred, obj == -1 ? 0 : obj, context == -1 ? 0 : context);
bb.flip();
- boolean[] shouldMatch = new boolean[4];
- for (int i = 0; i < fieldSeq.length; i++) {
- switch (fieldSeq[i]) {
- case 's':
- shouldMatch[i] = subj > 0;
- break;
- case 'p':
- shouldMatch[i] = pred > 0;
- break;
- case 'o':
- shouldMatch[i] = obj > 0;
- break;
- case 'c':
- shouldMatch[i] = context >= 0;
- break;
+ return new GroupMatcher(bb.array(), matcherFactory.create(subj, pred, obj, context));
+ }
+
+ private int getLength(long subj, long pred, long obj, long context) {
+ int length = 4;
+ if (subj > 240) {
+ length += 8;
+ }
+ if (pred > 240) {
+ length += 8;
+
+ }
+ if (obj > 240) {
+ length += 8;
+
+ }
+ if (context > 240) {
+ length += 8;
+
+ }
+ return length;
+ }
+
+ class KeyStats {
+ long subj;
+ long pred;
+ long obj;
+ long context;
+ public LongAdder count = new LongAdder();
+
+ public KeyStats(long subj, long pred, long obj, long context) {
+ this.subj = subj;
+ this.pred = pred;
+ this.obj = obj;
+ this.context = context;
+ }
+
+ @Override
+ public final boolean equals(Object o) {
+ if (!(o instanceof KeyStats)) {
+ return false;
}
+
+ KeyStats keyStats = (KeyStats) o;
+ return subj == keyStats.subj && pred == keyStats.pred && obj == keyStats.obj
+ && context == keyStats.context;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = Long.hashCode(subj);
+ result = 31 * result + Long.hashCode(pred);
+ result = 31 * result + Long.hashCode(obj);
+ result = 31 * result + Long.hashCode(context);
+ return result;
+ }
+
+ public void print() {
+ if (count.sum() % 1000000 == 0) {
+
+ try {
+ System.out.println("Key " + new String(getFieldSeq()) + " "
+ + Arrays.asList(valueStore.getValue(subj), valueStore.getValue(pred),
+ valueStore.getValue(obj), valueStore.getValue(context))
+ + " count: " + count.sum());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
}
- return new GroupMatcher(bb, shouldMatch);
}
void toKey(ByteBuffer bb, long subj, long pred, long obj, long context) {
- for (int i = 0; i < fieldSeq.length; i++) {
- switch (fieldSeq[i]) {
- case 's':
- writeUnsigned(bb, subj);
- break;
- case 'p':
- writeUnsigned(bb, pred);
- break;
- case 'o':
- writeUnsigned(bb, obj);
- break;
- case 'c':
- writeUnsigned(bb, context);
- break;
+
+ boolean shouldCache = threeOfFourAreZeroOrMax(subj, pred, obj, context);
+ if (shouldCache) {
+ long sum = subj + pred + obj + context;
+ if (sum == 0 && subj == pred && obj == context) {
+ bb.put(Varint.ALL_ZERO_QUAD);
+ return;
+ }
+
+ if (sum < 241) { // keys with sum < 241 only need 4 bytes to write and don't need caching
+ shouldCache = false;
}
+
}
+
+ // Pass through to the keyWriter with caching hint
+ keyWriter.write(bb, subj, pred, obj, context, shouldCache);
}
void keyToQuad(ByteBuffer key, long[] quad) {
+ readQuadUnsigned(key, indexMap, quad);
+ }
+
+ void keyToQuad(ByteBuffer key, long[] originalQuad, long[] quad) {
// directly use index map to read values in to correct positions
- readListUnsigned(key, indexMap, quad);
+ if (originalQuad[indexMap[0]] != -1) {
+ Varint.skipUnsigned(key);
+ } else {
+ quad[indexMap[0]] = Varint.readUnsigned(key);
+ }
+ if (originalQuad[indexMap[1]] != -1) {
+ Varint.skipUnsigned(key);
+ } else {
+ quad[indexMap[1]] = Varint.readUnsigned(key);
+ }
+ if (originalQuad[indexMap[2]] != -1) {
+ Varint.skipUnsigned(key);
+ } else {
+ quad[indexMap[2]] = Varint.readUnsigned(key);
+ }
+ if (originalQuad[indexMap[3]] != -1) {
+ Varint.skipUnsigned(key);
+ } else {
+ quad[indexMap[3]] = Varint.readUnsigned(key);
+ }
}
@Override
@@ -1341,4 +1441,20 @@ void destroy(long txn) {
mdb_drop(txn, dbiInferred, true);
}
}
+
+ static boolean threeOfFourAreZeroOrMax(long subj, long pred, long obj, long context) {
+ // Precompute the 8 equalities once (cheapest operations here)
+ boolean zS = subj == 0L, zP = pred == 0L, zO = obj == 0L, zC = context == 0L;
+ boolean mS = subj == Long.MAX_VALUE, mP = pred == Long.MAX_VALUE, mO = obj == Long.MAX_VALUE,
+ mC = context == Long.MAX_VALUE;
+
+ // ≥3-of-4 ≡ ab(c∨d) ∨ cd(a∨b). Apply once for zeros and once for maxes.
+ // Using '&' and '|' (not &&/||) keeps it branchless and predictable.
+
+ return (((zS & zP & (zO | zC)) | (zO & zC & (zS | zP)))// ≥3 zeros
+ | ((mS & mP & (mO | mC)) | (mO & mC & (mS | mP))));// ≥3 Long.MAX_VALUE
+// & !(zS & zP & zO & zC) // not all zeros
+// & !(mS & mP & mO & mC); // not all max
+ }
+
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/ValueStore.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/ValueStore.java
index 4cfc59a5be0..c1469254f4f 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/ValueStore.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/ValueStore.java
@@ -22,6 +22,7 @@
import static org.lwjgl.util.lmdb.LMDB.MDB_NOSYNC;
import static org.lwjgl.util.lmdb.LMDB.MDB_NOTLS;
import static org.lwjgl.util.lmdb.LMDB.MDB_PREV;
+import static org.lwjgl.util.lmdb.LMDB.MDB_RDONLY;
import static org.lwjgl.util.lmdb.LMDB.MDB_RESERVE;
import static org.lwjgl.util.lmdb.LMDB.MDB_SET_RANGE;
import static org.lwjgl.util.lmdb.LMDB.MDB_SUCCESS;
@@ -48,6 +49,9 @@
import java.io.File;
import java.io.IOException;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
+import java.lang.ref.Cleaner;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -56,8 +60,8 @@
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
+import java.util.WeakHashMap;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.atomic.AtomicReferenceArray;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.StampedLock;
@@ -73,6 +77,7 @@
import org.eclipse.rdf4j.model.base.AbstractValueFactory;
import org.eclipse.rdf4j.model.base.CoreDatatype;
import org.eclipse.rdf4j.model.util.Literals;
+import org.eclipse.rdf4j.sail.SailException;
import org.eclipse.rdf4j.sail.lmdb.LmdbUtil.Transaction;
import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig;
import org.eclipse.rdf4j.sail.lmdb.model.LmdbBNode;
@@ -95,8 +100,6 @@ class ValueStore extends AbstractValueFactory {
private final static Logger logger = LoggerFactory.getLogger(ValueStore.class);
- private static final long VALUE_EVICTION_INTERVAL = 60000; // 60 seconds
-
private static final byte URI_VALUE = 0x0; // 00
private static final byte LITERAL_VALUE = 0x1; // 01
@@ -115,6 +118,18 @@ class ValueStore extends AbstractValueFactory {
* Maximum size of keys before hashing is used (size of two long values)
*/
private static final int MAX_KEY_SIZE = 16;
+
+ private static final VarHandle PREVIOUS_NAMESPACE_HANDLE;
+
+ static {
+ try {
+ PREVIOUS_NAMESPACE_HANDLE = MethodHandles.lookup()
+ .findVarHandle(ValueStore.class, "previousNamespaceEntry", Object[].class);
+ } catch (ReflectiveOperationException e) {
+ throw new ExceptionInInitializerError(e);
+ }
+ }
+
/**
* Used to do the actual storage of values, once they're translated to byte arrays.
*/
@@ -126,7 +141,9 @@ class ValueStore extends AbstractValueFactory {
/**
* A simple cache containing the [VALUE_CACHE_SIZE] most-recently used values stored by their ID.
*/
- private final AtomicReferenceArray valueCache;
+ private final LmdbValue[] valueCache;
+ private final long[] valueCacheId;
+ private final int valueCacheMask;
/**
* A simple cache containing the [ID_CACHE_SIZE] most-recently used value-IDs stored by their value.
*/
@@ -158,6 +175,7 @@ class ValueStore extends AbstractValueFactory {
private final boolean forceSync;
private final boolean autoGrow;
private boolean invalidateRevisionOnCommit = false;
+
/**
* This lock is required to block transactions while auto-growing the map size.
*/
@@ -167,7 +185,7 @@ class ValueStore extends AbstractValueFactory {
* An object that indicates the revision of the value store, which is used to check if cached value IDs are still
* valid. In order to be valid, the ValueStoreRevision object of a LmdbValue needs to be equal to this object.
*/
- private volatile ValueStoreRevision revision;
+ private volatile ValueStoreRevision.Default revision;
/**
* A wrapper object for the revision of the value store, which is used within lazy (uninitialized values). If this
* object is GCed then it is safe to finally remove the ID-value associations and to reuse IDs.
@@ -186,19 +204,35 @@ class ValueStore extends AbstractValueFactory {
final Set unusedRevisionIds = new HashSet<>();
private final ConcurrentCleaner cleaner = new ConcurrentCleaner();
+ private final Set readTransactions = Collections
+ .synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
+ private final ThreadLocal threadLocalReadTxn = ThreadLocal.withInitial(() -> {
+ ReadTxn readTxn = new ReadTxn(readTransactions);
+ readTxn.registerIfNeeded();
+ readTxn.cleaner(cleaner);
+ return readTxn;
+ });
+
+ @SuppressWarnings("unused")
+ private Object[] previousNamespaceEntry;
+
+ private final long valueEvictionInterval;
ValueStore(File dir, LmdbStoreConfig config) throws IOException {
this.dir = dir;
this.forceSync = config.getForceSync();
this.autoGrow = config.getAutoGrow();
this.mapSize = config.getValueDBSize();
+ this.valueEvictionInterval = config.getValueEvictionInterval();
open();
- valueCache = new AtomicReferenceArray<>(config.getValueCacheSize());
+ int cacheSize = nextPowerOfTwo(config.getValueCacheSize());
+ valueCache = new LmdbValue[cacheSize];
+ valueCacheId = new long[cacheSize];
+ valueCacheMask = cacheSize - 1;
valueIDCache = new ConcurrentCache<>(config.getValueIDCacheSize());
namespaceCache = new ConcurrentCache<>(config.getNamespaceCacheSize());
namespaceIDCache = new ConcurrentCache<>(config.getNamespaceIDCacheSize());
-
setNewRevision();
// read maximum id from store
@@ -443,13 +477,25 @@ protected byte[] getData(long id) throws IOException {
* @return the value object or null if not found
*/
LmdbValue cachedValue(long id) {
- LmdbValue value = valueCache.get((int) (id % valueCache.length()));
+ int idx = (int) (id & valueCacheMask);
+
+ // Faster to read the long from an array than calling LmdbValue#getInternalID() on the value object. There may
+ // be race conditions, especially if the cache is small and has a high churn rate, but we can live with that
+ // since we anyway check the ID on the value object later on.
+ if (valueCacheId[idx] != id) {
+ return null;
+ }
+
+ LmdbValue value = valueCache[idx];
if (value != null && value.getInternalID() == id) {
return value;
}
return null;
}
+ long prevId;
+ long prevPrevId;
+
/**
* Cache value by ID.
*
@@ -460,7 +506,17 @@ LmdbValue cachedValue(long id) {
* @return the value object or null if not found
*/
void cacheValue(long id, LmdbValue value) {
- valueCache.lazySet((int) (id % valueCache.length()), value);
+ int idx = (int) (id & valueCacheMask);
+ valueCacheId[idx] = id;
+ valueCache[idx] = value;
+ }
+
+ private static int nextPowerOfTwo(int n) {
+ if (n <= 1) {
+ return 1;
+ }
+ int highest = Integer.highestOneBit(n - 1) << 1;
+ return highest > 0 ? highest : 1 << 30;
}
/**
@@ -540,7 +596,7 @@ public boolean resolveValue(long id, LmdbValue value) {
return true;
}
} catch (IOException e) {
- // should not happen
+ throw new SailException(e);
}
return false;
}
@@ -614,6 +670,7 @@ private void incrementRefCount(MemoryStack stack, long writeTxn, byte[] data) th
Varint.writeUnsigned(countBb, newCount);
dataVal.mv_data(countBb.flip());
E(mdb_put(writeTxn, refCountsDbi, idVal, dataVal, 0));
+
} finally {
stack.pop();
}
@@ -639,6 +696,7 @@ private boolean decrementRefCount(MemoryStack stack, long writeTxn, ByteBuffer i
Varint.writeUnsigned(countBb, newCount);
dataVal.mv_data(countBb.flip());
E(mdb_put(writeTxn, refCountsDbi, idVal, dataVal, 0));
+
}
}
return false;
@@ -708,7 +766,6 @@ private long findId(byte[] data, boolean create) throws IOException {
writeTransaction((stack2, writeTxn) -> {
dataVal.mv_size(data.length);
idVal.mv_data(id2data(idBuffer(stack), newId).flip());
-
// store mapping of hash -> ID
E(mdb_put(txn, dbi, hashVal, idVal, 0));
// store mapping of ID -> data
@@ -799,7 +856,10 @@ private long findId(byte[] data, boolean create) throws IOException {
T readTransaction(long env, Transaction transaction) throws IOException {
txnLock.readLock().lock();
try {
- return LmdbUtil.readTransaction(env, writeTxn, transaction);
+ if (writeTxn != 0) {
+ return LmdbUtil.readTransaction(env, writeTxn, transaction);
+ }
+ return threadLocalReadTxn.get().execute(transaction, env);
} finally {
txnLock.readLock().unlock();
}
@@ -924,6 +984,10 @@ private static boolean isCommonVocabulary(IRI nv) {
}
public void gcIds(Collection ids, Collection nextIds) throws IOException {
+ if (!enableGC()) {
+ return;
+ }
+
if (!ids.isEmpty()) {
// wrap into read txn as resizeMap expects an active surrounding read txn
readTransaction(env, (stack1, txn1) -> {
@@ -952,13 +1016,14 @@ public void gcIds(Collection ids, Collection nextIds) throws IOExcep
}
// mark id as unused
E(mdb_put(writeTxn, unusedDbi, revIdVal, dataVal, 0));
+
}
deleteValueToIdMappings(stack, writeTxn, finalIds, finalNextIds);
- invalidateRevisionOnCommit = true;
+ invalidateRevisionOnCommit = enableGC();
if (nextValueEvictionTime < 0) {
- nextValueEvictionTime = System.currentTimeMillis() + VALUE_EVICTION_INTERVAL;
+ nextValueEvictionTime = System.currentTimeMillis() + this.valueEvictionInterval;
}
return null;
});
@@ -1044,6 +1109,7 @@ protected void deleteValueToIdMappings(MemoryStack stack, long txn, Collection readTransactions;
+ private Cleaner.Cleanable cleaner;
+
+ static class State implements Runnable {
+ public long txn;
+ public long depth;
+ private boolean initialized;
+
+ public State(long txn) {
+ this.txn = txn;
+ }
+
+ @Override
+ public void run() {
+ if (initialized) {
+ var txn = this.txn;
+ this.txn = -1;
+ if (txn != -1) {
+ try {
+ if (depth > 0) {
+ mdb_txn_reset(txn);
+ depth = 0;
+ }
+ } finally {
+ mdb_txn_abort(txn);
+ }
+ }
+ initialized = false;
+ }
+ }
+ }
+
+ public ReadTxn(Set readTransactions) {
+ this.readTransactions = readTransactions;
+ }
+
+ public void cleaner(ConcurrentCleaner cleaner) {
+ this.cleaner = cleaner.register(this, state);
+ }
+
+ synchronized T execute(Transaction transaction, long env) throws IOException {
+ try (MemoryStack stack = MemoryStack.stackPush()) {
+ try {
+ ensureTxn(env);
+ state.depth++;
+ try {
+ return transaction.exec(stack, state.txn);
+ } finally {
+ releaseTxn();
+ }
+ } catch (Exception e) {
+ // Retry once
+ try {
+ System.gc();
+ Thread.sleep(1);
+ System.gc();
+ Thread.sleep(1);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ }
+
+ ensureTxn(env);
+ state.depth++;
+ try {
+ return transaction.exec(stack, state.txn);
+ } finally {
+ releaseTxn();
+ }
+ }
+ }
+ }
+
+ private void ensureTxn(long env) throws IOException {
+ registerIfNeeded();
+
+ if (!state.initialized) {
+ startTxn(env);
+ state.initialized = true;
+ return;
+ }
+
+ if (state.depth == 0) {
+ try {
+ E(mdb_txn_renew(state.txn));
+ } catch (IOException e) {
+ closeInternal();
+ startTxn(env);
+ state.initialized = true;
+ }
+ }
+
+ }
+
+ private void startTxn(long env) throws IOException {
+ try (MemoryStack stack = MemoryStack.stackPush()) {
+ PointerBuffer pp = stack.mallocPointer(1);
+ E(mdb_txn_begin(env, NULL, MDB_RDONLY, pp));
+ state.txn = pp.get(0);
+ }
+ }
+
+ private void releaseTxn() {
+ if (state.depth == 0) {
+ return;
+ }
+ state.depth--;
+ if (state.depth == 0 && state.initialized) {
+ mdb_txn_reset(state.txn);
+ }
+ }
+
+ private void registerIfNeeded() {
+ if (!registered) {
+ readTransactions.add(this);
+ registered = true;
+ }
+ }
+
+ private void unregister() {
+ readTransactions.remove(this);
+ registered = false;
+ }
+
+ private void closeInternal() {
+ if (state.initialized) {
+ cleaner.clean();
+ }
+ if (registered) {
+ unregister();
+ }
+ }
+
+ void close() {
+ closeInternal();
+ }
+ }
+
/**
* Checks if the supplied Value object is a LmdbValue object that has been created by this ValueStore.
*/
@@ -1390,7 +1616,7 @@ private LmdbIRI data2uri(long id, byte[] data, LmdbIRI value) throws IOException
ByteBuffer bb = ByteBuffer.wrap(data);
// skip type marker
bb.get();
- long nsID = Varint.readUnsigned(bb);
+ long nsID = Varint.readUnsignedHeap(bb);
String namespace = getNamespace(nsID);
String localName = new String(data, bb.position(), bb.remaining(), StandardCharsets.UTF_8);
@@ -1417,7 +1643,7 @@ private LmdbLiteral data2literal(long id, byte[] data, LmdbLiteral value) throws
// skip type marker
bb.get();
// Get datatype
- long datatypeID = Varint.readUnsigned(bb);
+ long datatypeID = Varint.readUnsignedHeap(bb);
IRI datatype = null;
if (datatypeID != LmdbValue.UNKNOWN_ID) {
datatype = (IRI) getValue(datatypeID);
@@ -1484,6 +1710,11 @@ private long getNamespaceID(String namespace, boolean create) throws IOException
*-------------------------------------*/
private String getNamespace(long id) throws IOException {
+ Object[] cached = (Object[]) PREVIOUS_NAMESPACE_HANDLE.getAcquire(this);
+ if (cached != null && (long) cached[0] == id) {
+ return (String) cached[1];
+ }
+
Long cacheID = id;
String namespace = namespaceCache.get(cacheID);
@@ -1495,6 +1726,8 @@ private String getNamespace(long id) throws IOException {
}
}
+ PREVIOUS_NAMESPACE_HANDLE.setRelease(this, new Object[] { cacheID, namespace });
+
return namespace;
}
@@ -1606,6 +1839,10 @@ public LmdbLiteral getLmdbLiteral(Literal l) {
}
}
+ private boolean enableGC() {
+ return this.valueEvictionInterval > 0;
+ }
+
public void forceEvictionOfValues() {
nextValueEvictionTime = 0L;
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/Varint.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/Varint.java
index 283186c0246..af6632804d6 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/Varint.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/Varint.java
@@ -11,12 +11,38 @@
package org.eclipse.rdf4j.sail.lmdb;
import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+import org.eclipse.rdf4j.sail.lmdb.util.SignificantBytesBE;
/**
* Encodes and decodes unsigned values using variable-length encoding.
*/
public final class Varint {
+ static final byte[] ENCODED_LONG_MAX = new byte[] {
+ (byte) 0xFF, // header: 8 payload bytes
+ 0x7F, // MSB of Long.MAX_VALUE
+ (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF
+ };
+
+ static final byte[] ENCODED_LONG_MAX_QUAD = new byte[] {
+ (byte) 0xFF, // header: 8 payload bytes
+ 0x7F, // MSB of Long.MAX_VALUE
+ (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF,
+ (byte) 0xFF, // header: 8 payload bytes
+ 0x7F, // MSB of Long.MAX_VALUE
+ (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF,
+ (byte) 0xFF, // header: 8 payload bytes
+ 0x7F, // MSB of Long.MAX_VALUE
+ (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF,
+ (byte) 0xFF, // header: 8 payload bytes
+ 0x7F, // MSB of Long.MAX_VALUE
+ (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF
+ };
+
+ static final byte[] ALL_ZERO_QUAD = new byte[] { 0, 0, 0, 0 };
+
private Varint() {
}
@@ -89,19 +115,102 @@ private Varint() {
* @param value value to encode
*/
public static void writeUnsigned(final ByteBuffer bb, final long value) {
+
+ // Fast path for Long.MAX_VALUE (0xFF header + 8 data bytes)
+ if (value == Long.MAX_VALUE) {
+ final ByteOrder prev = bb.order();
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(ByteOrder.BIG_ENDIAN);
+ }
+ try {
+ bb.put(ENCODED_LONG_MAX);
+ } finally {
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(prev);
+ }
+ }
+ return;
+ }
+
if (value <= 240) {
bb.put((byte) value);
} else if (value <= 2287) {
- bb.put((byte) ((value - 240) / 256 + 241));
- bb.put((byte) ((value - 240) % 256));
+ // header: 241..248, then 1 payload byte
+ // Using bit ops instead of div/mod and putShort to batch the two bytes.
+ long v = value - 240; // 1..2047
+ final ByteOrder prev = bb.order();
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(ByteOrder.BIG_ENDIAN);
+ }
+ try {
+ int hi = (int) (v >>> 8) + 241; // 241..248
+ int lo = (int) (v & 0xFF); // 0..255
+ bb.putShort((short) ((hi << 8) | lo));
+ } finally {
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(prev);
+ }
+ }
} else if (value <= 67823) {
+ // header 249, then 2 payload bytes (value - 2288), big-endian
+ long v = value - 2288; // 0..65535
bb.put((byte) 249);
- bb.put((byte) ((value - 2288) / 256));
- bb.put((byte) ((value - 2288) % 256));
+ final ByteOrder prev = bb.order();
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(ByteOrder.BIG_ENDIAN);
+ }
+ try {
+ bb.putShort((short) v);
+ } finally {
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(prev);
+ }
+ }
} else {
- int bytes = descriptor(value) + 1;
- bb.put((byte) (250 + (bytes - 3)));
- writeSignificantBits(bb, value, bytes);
+ int bytes = descriptor(value) + 1; // 3..8
+ bb.put((byte) (250 + (bytes - 3))); // header 250..255
+ writeSignificantBits(bb, value, bytes); // payload (batched)
+ }
+ }
+
+ // Writes the top `bytes` significant bytes of `value` in big-endian order.
+// Uses putLong/putInt/putShort to batch writes and a single leading byte if needed.
+ private static void writeSignificantBits(ByteBuffer bb, long value, int bytes) {
+ final ByteOrder prev = bb.order();
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(ByteOrder.BIG_ENDIAN);
+ }
+ try {
+ int i = bytes;
+
+ // If odd number of bytes, write the leading MSB first
+ if ((i & 1) != 0) {
+ bb.put((byte) (value >>> ((i - 1) * 8)));
+ i--;
+ }
+
+ // Now i is even: prefer largest chunks first
+ if (i == 8) { // exactly 8 bytes
+ bb.putLong(value);
+ return;
+ }
+
+ if (i >= 4) { // write next 4 bytes, if any
+ int shift = (i - 4) * 8;
+ bb.putInt((int) (value >>> shift));
+ i -= 4;
+ }
+
+ while (i >= 2) { // write remaining pairs
+ int shift = (i - 2) * 8;
+ bb.putShort((short) (value >>> shift));
+ i -= 2;
+ }
+ // i must be 0 here.
+ } finally {
+ if (prev != ByteOrder.BIG_ENDIAN) {
+ bb.order(prev);
+ }
}
}
@@ -152,8 +261,10 @@ private static byte descriptor(long value) {
}
/**
- * Decodes a value using the variable-length encoding of
- * SQLite.
+ * Decodes a value using SQLite's variable-length integer encoding.
+ *
+ * Lead-byte layout → number of additional bytes: 0..240 → 0 241..248→ 1 249 → 2 250..255→ 3..8 (i.e., 250→3, 251→4,
+ * …, 255→8)
*
* @param bb buffer for reading bytes
* @return decoded value
@@ -161,7 +272,86 @@ private static byte descriptor(long value) {
* @see #writeUnsigned(ByteBuffer, long)
*/
public static long readUnsigned(ByteBuffer bb) throws IllegalArgumentException {
+ final int a0 = bb.get() & 0xFF; // lead byte, unsigned
+
+ if (a0 <= 240) {
+ return a0;
+ }
+
+ final int extra = VARINT_EXTRA_BYTES[a0]; // 0..8 additional bytes
+
+ switch (extra) {
+ case 1: {
+ // 1 extra byte; 241..248
+ final int a1 = bb.get() & 0xFF;
+ // 240 + 256*(a0-241) + a1
+ return 240L + ((long) (a0 - 241) << 8) + a1;
+ }
+
+ case 2: {
+ // 2 extra bytes; lead byte == 249
+ final int a1 = bb.get() & 0xFF;
+ final int a2 = bb.get() & 0xFF;
+ // 2288 + 256*a1 + a2
+ return 2288L + ((long) a1 << 8) + a2;
+ }
+
+ case 3:
+ case 4:
+ case 5:
+ case 6:
+ case 7:
+ case 8:
+ return readSignificantBitsDirect(bb, extra);
+ // 3..8 extra bytes; 250..255
+ default:
+ throw new IllegalArgumentException("Bytes is higher than 8: " + extra);
+
+ }
+ }
+
+ public static void skipUnsigned(ByteBuffer bb) throws IllegalArgumentException {
+ final int a0 = bb.get() & 0xFF; // lead byte, unsigned
+
+ if (a0 <= 240) {
+ return;
+ }
+
+ final int extra = VARINT_EXTRA_BYTES[a0]; // 0..8 additional bytes
+ bb.position(bb.position() + extra);
+
+ }
+
+ /** Lookup: lead byte (0..255) → number of additional bytes (0..8). */
+ private static final byte[] VARINT_EXTRA_BYTES = buildVarintExtraBytes();
+
+ private static byte[] buildVarintExtraBytes() {
+ final byte[] t = new byte[256];
+
+ // 0..240 → 0 extra bytes
+ for (int i = 0; i <= 240; i++) {
+ t[i] = 0;
+ }
+
+ // 241..248 → 1 extra byte
+ for (int i = 241; i <= 248; i++) {
+ t[i] = 1;
+ }
+
+ // 249 → 2 extra bytes
+ t[249] = 2;
+
+ // 250..255 → 3..8 extra bytes
+ for (int i = 250; i <= 255; i++) {
+ t[i] = (byte) (i - 247); // 250→3, …, 255→8
+ }
+
+ return t;
+ }
+
+ public static long readUnsignedHeap(ByteBuffer bb) throws IllegalArgumentException {
int a0 = bb.get() & 0xFF;
+
if (a0 <= 240) {
return a0;
} else if (a0 <= 248) {
@@ -173,7 +363,7 @@ public static long readUnsigned(ByteBuffer bb) throws IllegalArgumentException {
return 2288 + 256 * a1 + a2;
} else {
int bytes = a0 - 250 + 3;
- return readSignificantBits(bb, bytes);
+ return readSignificantBitsHeap(bb, bytes);
}
}
@@ -195,7 +385,7 @@ public static long readUnsigned(ByteBuffer bb, int pos) throws IllegalArgumentEx
return 240 + 256 * (a0 - 241) + a1;
} else if (a0 == 249) {
int a1 = bb.get(pos + 1) & 0xFF;
- int a2 = bb.get(pos + 1) & 0xFF;
+ int a2 = bb.get(pos + 2) & 0xFF;
return 2288 + 256 * a1 + a2;
} else {
int bytes = a0 - 250 + 3;
@@ -203,6 +393,27 @@ public static long readUnsigned(ByteBuffer bb, int pos) throws IllegalArgumentEx
}
}
+ private static final int[] FIRST_TO_LENGTH = buildFirstToLength();
+
+ private static int[] buildFirstToLength() {
+ int[] t = new int[256];
+ // 0..240 → 1
+ for (int i = 0; i <= 240; i++) {
+ t[i] = 1;
+ }
+ // 241..248 → 2
+ for (int i = 241; i <= 248; i++) {
+ t[i] = 2;
+ }
+ // 249 → 3
+ t[249] = 3;
+ // 250..255 → 4..9
+ for (int i = 250; i <= 255; i++) {
+ t[i] = i - 246; // 250→4, 255→9
+ }
+ return t;
+ }
+
/**
* Determines length of an encoded varint value by inspecting the first byte.
*
@@ -210,17 +421,7 @@ public static long readUnsigned(ByteBuffer bb, int pos) throws IllegalArgumentEx
* @return decoded value
*/
public static int firstToLength(byte a0) {
- int a0Unsigned = a0 & 0xFF;
- if (a0Unsigned <= 240) {
- return 1;
- } else if (a0Unsigned <= 248) {
- return 2;
- } else if (a0Unsigned == 249) {
- return 3;
- } else {
- int bytes = a0Unsigned - 250 + 3;
- return 1 + bytes;
- }
+ return FIRST_TO_LENGTH[a0 & 0xFF];
}
/**
@@ -245,6 +446,7 @@ public static long readListElementUnsigned(ByteBuffer bb, int index) {
* @param values array with values to write
*/
public static void writeListUnsigned(final ByteBuffer bb, final long[] values) {
+ // TODO: Optimise for quads and also call writeUnsigned
for (int i = 0; i < values.length; i++) {
final long value = values[i];
if (value <= 240) {
@@ -276,6 +478,13 @@ public static void readListUnsigned(ByteBuffer bb, long[] values) {
}
}
+ public static void readQuadUnsigned(ByteBuffer bb, long[] values) {
+ values[0] = readUnsigned(bb);
+ values[1] = readUnsigned(bb);
+ values[2] = readUnsigned(bb);
+ values[3] = readUnsigned(bb);
+ }
+
/**
* Decodes multiple values using variable-length encoding from the given buffer.
*
@@ -289,32 +498,33 @@ public static void readListUnsigned(ByteBuffer bb, int[] indexMap, long[] values
}
}
- /**
- * Writes only the significant bytes of the given value in big-endian order.
- *
- * @param bb buffer for writing bytes
- * @param value value to encode
- * @param bytes number of significant bytes
- */
- private static void writeSignificantBits(ByteBuffer bb, long value, int bytes) {
- while (bytes-- > 0) {
- bb.put((byte) (0xFF & (value >>> (bytes * 8))));
- }
+ public static void readQuadUnsigned(ByteBuffer bb, int[] indexMap, long[] values) {
+ values[indexMap[0]] = readUnsigned(bb);
+ values[indexMap[1]] = readUnsigned(bb);
+ values[indexMap[2]] = readUnsigned(bb);
+ values[indexMap[3]] = readUnsigned(bb);
}
/**
* Reads only the significant bytes of the given value in big-endian order.
*
- * @param bb buffer for reading bytes
- * @param bytes number of significant bytes
+ * @param bb buffer for reading bytes
+ * @param n number of significant bytes
*/
- private static long readSignificantBits(ByteBuffer bb, int bytes) {
- bytes--;
- long value = (long) (bb.get() & 0xFF) << (bytes * 8);
- while (bytes-- > 0) {
- value |= (long) (bb.get() & 0xFF) << (bytes * 8);
+ private static long readSignificantBits(ByteBuffer bb, int n) {
+ if (bb.isDirect()) {
+ return readSignificantBitsDirect(bb, n);
+ } else {
+ return readSignificantBitsHeap(bb, n);
}
- return value;
+ }
+
+ private static long readSignificantBitsDirect(ByteBuffer bb, int n) {
+ return SignificantBytesBE.readDirect(bb, n);
+ }
+
+ private static long readSignificantBitsHeap(ByteBuffer bb, int n) {
+ return SignificantBytesBE.read(bb, n);
}
/**
@@ -342,8 +552,9 @@ private static int compareRegion(ByteBuffer bb1, int startIdx1, ByteBuffer bb2,
}
/**
- * A matcher for partial equality tests of varint lists.
+ * Use of this class is deprecated, use {@link org.eclipse.rdf4j.sail.lmdb.util.GroupMatcher} instead.
*/
+ @Deprecated(forRemoval = true)
public static class GroupMatcher {
final ByteBuffer value;
@@ -383,4 +594,5 @@ public boolean matches(ByteBuffer other) {
return true;
}
}
+
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreConfig.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreConfig.java
index 572008314e5..4c072e91317 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreConfig.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreConfig.java
@@ -10,6 +10,8 @@
*******************************************************************************/
package org.eclipse.rdf4j.sail.lmdb.config;
+import java.time.Duration;
+
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.Resource;
import org.eclipse.rdf4j.model.ValueFactory;
@@ -71,6 +73,8 @@ public class LmdbStoreConfig extends BaseSailConfig {
private boolean autoGrow = true;
+ private long valueEvictionInterval = Duration.ofSeconds(60).toMillis();
+
/*--------------*
* Constructors *
*--------------*/
@@ -92,7 +96,6 @@ public LmdbStoreConfig(String tripleIndexes, boolean forceSync) {
/*---------*
* Methods *
*---------*/
-
public String getTripleIndexes() {
return tripleIndexes;
}
@@ -178,6 +181,15 @@ public LmdbStoreConfig setAutoGrow(boolean autoGrow) {
return this;
}
+ public long getValueEvictionInterval() {
+ return valueEvictionInterval;
+ }
+
+ public LmdbStoreConfig setValueEvictionInterval(long valueEvictionInterval) {
+ this.valueEvictionInterval = valueEvictionInterval;
+ return this;
+ }
+
@Override
public Resource export(Model m) {
Resource implNode = super.export(m);
@@ -211,6 +223,9 @@ public Resource export(Model m) {
if (!autoGrow) {
m.add(implNode, LmdbStoreSchema.AUTO_GROW, vf.createLiteral(false));
}
+ if (valueEvictionInterval != Duration.ofSeconds(60).toMillis()) {
+ m.add(implNode, LmdbStoreSchema.VALUE_EVICTION_INTERVAL, vf.createLiteral(valueEvictionInterval));
+ }
return implNode;
}
@@ -304,6 +319,17 @@ public void parse(Model m, Resource implNode) throws SailConfigException {
"Boolean value required for " + LmdbStoreSchema.AUTO_GROW + " property, found " + lit);
}
});
+
+ Models.objectLiteral(m.getStatements(implNode, LmdbStoreSchema.VALUE_EVICTION_INTERVAL, null))
+ .ifPresent(lit -> {
+ try {
+ setValueEvictionInterval(lit.longValue());
+ } catch (NumberFormatException e) {
+ throw new SailConfigException(
+ "Long value required for " + LmdbStoreSchema.VALUE_EVICTION_INTERVAL
+ + " property, found " + lit);
+ }
+ });
} catch (ModelException e) {
throw new SailConfigException(e.getMessage(), e);
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreSchema.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreSchema.java
index 63c28cfa018..8a9c5acca8d 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreSchema.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreSchema.java
@@ -71,6 +71,11 @@ public class LmdbStoreSchema {
*/
public final static IRI AUTO_GROW;
+ /**
+ * http://rdf4j.org/config/sail/lmdb#valueEvictionInterval
+ */
+ public final static IRI VALUE_EVICTION_INTERVAL;
+
static {
ValueFactory factory = SimpleValueFactory.getInstance();
TRIPLE_INDEXES = factory.createIRI(NAMESPACE, "tripleIndexes");
@@ -82,5 +87,6 @@ public class LmdbStoreSchema {
NAMESPACE_CACHE_SIZE = factory.createIRI(NAMESPACE, "namespaceCacheSize");
NAMESPACE_ID_CACHE_SIZE = factory.createIRI(NAMESPACE, "namespaceIDCacheSize");
AUTO_GROW = factory.createIRI(NAMESPACE, "autoGrow");
+ VALUE_EVICTION_INTERVAL = factory.createIRI(NAMESPACE, "valueEvictionInterval");
}
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/model/LmdbIRI.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/model/LmdbIRI.java
index 8bc261d44d0..99cf00cbe37 100644
--- a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/model/LmdbIRI.java
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/model/LmdbIRI.java
@@ -14,10 +14,13 @@
import org.eclipse.rdf4j.model.impl.SimpleIRI;
import org.eclipse.rdf4j.sail.lmdb.ValueStoreRevision;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class LmdbIRI extends SimpleIRI implements LmdbResource {
private static final long serialVersionUID = -5888138591826143179L;
+ private static final Logger log = LoggerFactory.getLogger(LmdbIRI.class);
/*-----------*
* Constants *
@@ -103,9 +106,12 @@ public void init() {
if (!initialized) {
synchronized (this) {
if (!initialized) {
- revision.resolveValue(internalID, this);
+ boolean resolved = revision.resolveValue(internalID, this);
+ if (!resolved) {
+ log.warn("Could not resolve value");
+ }
+ initialized = resolved;
}
- initialized = true;
}
}
}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/Bytes.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/Bytes.java
new file mode 100644
index 00000000000..ab008769e73
--- /dev/null
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/Bytes.java
@@ -0,0 +1,610 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+
+package org.eclipse.rdf4j.sail.lmdb.util;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+public final class Bytes {
+
+ private final static boolean bigEndian = ByteOrder.nativeOrder() == ByteOrder.BIG_ENDIAN;
+
+ private Bytes() {
+ }
+
+ @FunctionalInterface
+ public interface RegionComparator {
+ boolean equals(byte firstByte, ByteBuffer other);
+ }
+
+ private static boolean equals(int a, int b) {
+ return a == b;
+ }
+
+ private static short toShort(byte[] array, int offset) {
+ return (short) (((array[offset] & 0xFF) << 8) | (array[offset + 1] & 0xFF));
+ }
+
+ private static int toInt(byte[] array, int offset) {
+ return ((array[offset] & 0xFF) << 24)
+ | ((array[offset + 1] & 0xFF) << 16)
+ | ((array[offset + 2] & 0xFF) << 8)
+ | (array[offset + 3] & 0xFF);
+ }
+
+ public static RegionComparator capturedComparator(byte[] array, int offset, int len) {
+ if (len <= 0) {
+ return (firstByte, b) -> true;
+ }
+ switch (len) {
+ case 1:
+ return comparatorLen1(array, offset);
+ case 2:
+ return comparatorLen2(array, offset);
+ case 3:
+ return comparatorLen3(array, offset);
+ case 4:
+ return comparatorLen4(array, offset);
+ case 5:
+ return comparatorLen5(array, offset);
+ case 6:
+ return comparatorLen6(array, offset);
+ case 7:
+ return comparatorLen7(array, offset);
+ case 8:
+ return comparatorLen8(array, offset);
+ case 9:
+ return comparatorLen9(array, offset);
+ case 10:
+ return comparatorLen10(array, offset);
+ case 11:
+ return comparatorLen11(array, offset);
+ case 12:
+ return comparatorLen12(array, offset);
+ case 13:
+ return comparatorLen13(array, offset);
+ case 14:
+ return comparatorLen14(array, offset);
+ case 15:
+ return comparatorLen15(array, offset);
+ case 16:
+ return comparatorLen16(array, offset);
+ case 17:
+ return comparatorLen17(array, offset);
+ case 18:
+ return comparatorLen18(array, offset);
+ case 19:
+ return comparatorLen19(array, offset);
+ case 20:
+ return comparatorLen20(array, offset);
+ default:
+ return comparatorGeneric(array, offset, len);
+ }
+ }
+
+ private static RegionComparator comparatorLen1(byte[] array, int offset) {
+ return (firstByte, b) -> equals(array[offset], firstByte);
+ }
+
+ private static RegionComparator comparatorLen2(byte[] array, int offset) {
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ return equals(array[offset + 1], b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen3(byte[] array, int offset) {
+
+ final short expected = toShort(array, offset + 1);
+ final short expectedEndian = bigEndian ? expected : Short.reverseBytes(expected);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ return equals(expectedEndian, b.getShort());
+ };
+ }
+
+ private static RegionComparator comparatorLen4(byte[] array, int offset) {
+
+ final int expected = toInt(array, offset);
+ final int expectedEndian = bigEndian ? expected : Integer.reverseBytes(expected);
+
+ return (firstByte, b) -> {
+
+ b.position(b.position() - 1);
+
+ return equals(expectedEndian, b.getInt());
+ };
+ }
+
+ private static RegionComparator comparatorLen5(byte[] array, int offset) {
+
+ final int expected = toInt(array, offset + 1);
+ final int expectedEndian = bigEndian ? expected : Integer.reverseBytes(expected);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ return equals(expectedEndian, b.getInt());
+ };
+ }
+
+ private static RegionComparator comparatorLen6(byte[] array, int offset) {
+
+ final int expected = toInt(array, offset + 1);
+ final int expectedEndian = bigEndian ? expected : Integer.reverseBytes(expected);
+ final byte tail = array[offset + 5];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expectedEndian, b.getInt())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen7(byte[] array, int offset) {
+
+ final int expected = toInt(array, offset + 1);
+ final int expectedEndian = bigEndian ? expected : Integer.reverseBytes(expected);
+ final short expectedTail = toShort(array, offset + 5);
+ final short expectedTailEndian = bigEndian ? expectedTail : Short.reverseBytes(expectedTail);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expectedEndian, b.getInt())) {
+ return false;
+ }
+
+ return equals(expectedTailEndian, b.getShort());
+ };
+ }
+
+ private static RegionComparator comparatorLen8(byte[] array, int offset) {
+
+ final int expected = toInt(array, offset + 1);
+ final int expectedEndian = bigEndian ? expected : Integer.reverseBytes(expected);
+ final short expectedShort = toShort(array, offset + 5);
+ final short expectedShortEndian = bigEndian ? expectedShort : Short.reverseBytes(expectedShort);
+ final byte tail = array[offset + 7];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expectedEndian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expectedShortEndian, b.getShort())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen9(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(expected2Endian, b.getInt());
+ };
+ }
+
+ private static RegionComparator comparatorLen10(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final byte tail = array[offset + 9];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen11(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final short expectedShort = toShort(array, offset + 9);
+ final short expectedShortEndian = bigEndian ? expectedShort : Short.reverseBytes(expectedShort);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(expectedShortEndian, b.getShort());
+ };
+ }
+
+ private static RegionComparator comparatorLen12(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final short expectedShort = toShort(array, offset + 9);
+ final short expectedShortEndian = bigEndian ? expectedShort : Short.reverseBytes(expectedShort);
+ final byte tail = array[offset + 11];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expectedShortEndian, b.getShort())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen13(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(expected3Endian, b.getInt());
+ };
+ }
+
+ private static RegionComparator comparatorLen14(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+ final byte tail = array[offset + 13];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected3Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen15(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+ final short expectedShort = toShort(array, offset + 13);
+ final short expectedShortEndian = bigEndian ? expectedShort : Short.reverseBytes(expectedShort);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected3Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(expectedShortEndian, b.getShort());
+ };
+ }
+
+ private static RegionComparator comparatorLen16(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+ final short expectedShort = toShort(array, offset + 13);
+ final short expectedShortEndian = bigEndian ? expectedShort : Short.reverseBytes(expectedShort);
+ final byte tail = array[offset + 15];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected3Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expectedShortEndian, b.getShort())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen17(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+ final int expected4 = toInt(array, offset + 13);
+ final int expected4Endian = bigEndian ? expected4 : Integer.reverseBytes(expected4);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected3Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(expected4Endian, b.getInt());
+ };
+ }
+
+ private static RegionComparator comparatorLen18(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+ final int expected4 = toInt(array, offset + 13);
+ final int expected4Endian = bigEndian ? expected4 : Integer.reverseBytes(expected4);
+ final byte tail = array[offset + 17];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected3Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected4Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorLen19(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+ final int expected4 = toInt(array, offset + 13);
+ final int expected4Endian = bigEndian ? expected4 : Integer.reverseBytes(expected4);
+ final short expectedShort = toShort(array, offset + 17);
+ final short expectedShortEndian = bigEndian ? expectedShort : Short.reverseBytes(expectedShort);
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected3Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected4Endian, b.getInt())) {
+ return false;
+ }
+
+ return equals(expectedShortEndian, b.getShort());
+ };
+ }
+
+ private static RegionComparator comparatorLen20(byte[] array, int offset) {
+
+ final int expected1 = toInt(array, offset + 1);
+ final int expected1Endian = bigEndian ? expected1 : Integer.reverseBytes(expected1);
+ final int expected2 = toInt(array, offset + 5);
+ final int expected2Endian = bigEndian ? expected2 : Integer.reverseBytes(expected2);
+ final int expected3 = toInt(array, offset + 9);
+ final int expected3Endian = bigEndian ? expected3 : Integer.reverseBytes(expected3);
+ final int expected4 = toInt(array, offset + 13);
+ final int expected4Endian = bigEndian ? expected4 : Integer.reverseBytes(expected4);
+ final short expectedShort = toShort(array, offset + 17);
+ final short expectedShortEndian = bigEndian ? expectedShort : Short.reverseBytes(expectedShort);
+ final byte tail = array[offset + 19];
+
+ return (firstByte, b) -> {
+ if (!equals(array[offset], firstByte)) {
+ return false;
+ }
+
+ if (!equals(expected1Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected2Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected3Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expected4Endian, b.getInt())) {
+ return false;
+ }
+
+ if (!equals(expectedShortEndian, b.getShort())) {
+ return false;
+ }
+
+ return equals(tail, b.get());
+ };
+ }
+
+ private static RegionComparator comparatorGeneric(byte[] array, int offset, int len) {
+ final int start = offset;
+ final int end = offset + len;
+ return (firstByte, b) -> {
+ if (!equals(array[start], firstByte)) {
+ return false;
+ }
+
+ int idx = start + 1;
+ while (idx < end) {
+ if (!equals(array[idx], b.get())) {
+ return false;
+ }
+ idx++;
+ }
+ return true;
+ };
+ }
+}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/GroupMatcher.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/GroupMatcher.java
new file mode 100644
index 00000000000..016090b949f
--- /dev/null
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/GroupMatcher.java
@@ -0,0 +1,424 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+
+package org.eclipse.rdf4j.sail.lmdb.util;
+
+import static org.eclipse.rdf4j.sail.lmdb.Varint.firstToLength;
+
+import java.nio.ByteBuffer;
+
+/**
+ * A matcher for partial equality tests of varint lists.
+ */
+public class GroupMatcher {
+
+ public static final Bytes.RegionComparator NULL_REGION_COMPARATOR = (a, b) -> true;
+ private final int length0;
+ private final int length1;
+ private final int length2;
+ private final int length3;
+ private final Bytes.RegionComparator cmp0;
+ private final Bytes.RegionComparator cmp1;
+ private final Bytes.RegionComparator cmp2;
+ private final Bytes.RegionComparator cmp3;
+ private final byte firstByte0;
+ private final byte firstByte1;
+ private final byte firstByte2;
+ private final byte firstByte3;
+ private final MatchFn matcher;
+
+ public GroupMatcher(byte[] valueArray, boolean[] shouldMatch) {
+ assert shouldMatch.length == 4;
+
+ int baseOffset = 0;
+
+ // Loop is unrolled for performance. Do not change back to a loop, do not extract into method, unless you
+ // benchmark with QueryBenchmark first!
+ {
+ byte fb = valueArray[0];
+ this.firstByte0 = fb;
+ int len = firstToLength(fb);
+ this.length0 = len;
+ if (shouldMatch[0]) {
+ this.cmp0 = Bytes.capturedComparator(valueArray, 0, len);
+ } else {
+ this.cmp0 = NULL_REGION_COMPARATOR;
+ ;
+ }
+
+ baseOffset += len;
+ }
+ {
+
+ byte fb = valueArray[baseOffset];
+ this.firstByte1 = fb;
+ int len = firstToLength(fb);
+ this.length1 = len;
+
+ if (shouldMatch[1]) {
+ this.cmp1 = Bytes.capturedComparator(valueArray, baseOffset, len);
+ } else {
+ this.cmp1 = NULL_REGION_COMPARATOR;
+ }
+
+ baseOffset += len;
+ }
+ {
+ byte fb = valueArray[baseOffset];
+ this.firstByte2 = fb;
+ int len = firstToLength(fb);
+ this.length2 = len;
+ if (shouldMatch[2]) {
+ this.cmp2 = Bytes.capturedComparator(valueArray, baseOffset, len);
+ } else {
+ this.cmp2 = NULL_REGION_COMPARATOR;
+ }
+
+ baseOffset += len;
+ }
+ {
+ byte fb = valueArray[baseOffset];
+ this.firstByte3 = fb;
+ int len = firstToLength(fb);
+ this.length3 = len;
+
+ if (shouldMatch[3]) {
+ this.cmp3 = Bytes.capturedComparator(valueArray, baseOffset, len);
+ } else {
+ this.cmp3 = NULL_REGION_COMPARATOR;
+ }
+ }
+
+ this.matcher = selectMatcher(shouldMatch);
+
+ }
+
+ public boolean matches(ByteBuffer other) {
+ return matcher.matches(other);
+ }
+
+ @FunctionalInterface
+ private interface MatchFn {
+ boolean matches(ByteBuffer other);
+ }
+
+ private MatchFn selectMatcher(boolean[] shouldMatch) {
+ byte mask = 0;
+ if (shouldMatch[0]) {
+ mask |= 0b0001;
+ }
+ if (shouldMatch[1]) {
+ mask |= 0b0010;
+ }
+ if (shouldMatch[2]) {
+ mask |= 0b0100;
+ }
+ if (shouldMatch[3]) {
+ mask |= 0b1000;
+ }
+
+ switch (mask) {
+ case 0b0000:
+ return this::match0000;
+ case 0b0001:
+ return this::match0001;
+ case 0b0010:
+ return this::match0010;
+ case 0b0011:
+ return this::match0011;
+ case 0b0100:
+ return this::match0100;
+ case 0b0101:
+ return this::match0101;
+ case 0b0110:
+ return this::match0110;
+ case 0b0111:
+ return this::match0111;
+ case 0b1000:
+ return this::match1000;
+ case 0b1001:
+ return this::match1001;
+ case 0b1010:
+ return this::match1010;
+ case 0b1011:
+ return this::match1011;
+ case 0b1100:
+ return this::match1100;
+ case 0b1101:
+ return this::match1101;
+ case 0b1110:
+ return this::match1110;
+ case 0b1111:
+ return this::match1111;
+ default:
+ throw new IllegalStateException("Unsupported matcher mask: " + mask);
+ }
+ }
+
+ private boolean match0000(ByteBuffer other) {
+ return true;
+ }
+
+ private boolean match0001(ByteBuffer other) {
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ return length0 == 1 || cmp0.equals(otherFirst0, other);
+ }
+ return false;
+ }
+
+ private boolean match0010(ByteBuffer other) {
+
+ skipAhead(other);
+
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ return length1 == 1 || cmp1.equals(otherFirst1, other);
+ }
+ return false;
+ }
+
+ private boolean match0011(ByteBuffer other) {
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ if (length0 == 1 || cmp0.equals(otherFirst0, other)) {
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ return length1 == 1 || cmp1.equals(otherFirst1, other);
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private boolean match0100(ByteBuffer other) {
+
+ skipAhead(other);
+ skipAhead(other);
+
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ return length2 == 1 || cmp2.equals(otherFirst2, other);
+ }
+ return false;
+ }
+
+ private boolean match0101(ByteBuffer other) {
+
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ if (length0 == 1 || cmp0.equals(otherFirst0, other)) {
+ skipAhead(other);
+
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ return length2 == 1 || cmp2.equals(otherFirst2, other);
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match0110(ByteBuffer other) {
+
+ skipAhead(other);
+
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ if (length1 == 1 || cmp1.equals(otherFirst1, other)) {
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ return length2 == 1 || cmp2.equals(otherFirst2, other);
+ }
+ }
+ }
+ return false;
+ }
+
+ private void skipAhead(ByteBuffer other) {
+ int i = firstToLength(other.get()) - 1;
+ assert i >= 0;
+ if (i > 0) {
+ other.position(i + other.position());
+ }
+ }
+
+ private boolean match0111(ByteBuffer other) {
+
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ if (length0 == 1 || cmp0.equals(otherFirst0, other)) {
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ if (length1 == 1 || cmp1.equals(otherFirst1, other)) {
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ return length2 == 1 || cmp2.equals(otherFirst2, other);
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match1000(ByteBuffer other) {
+
+ skipAhead(other);
+ skipAhead(other);
+ skipAhead(other);
+
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ return false;
+ }
+
+ private boolean match1001(ByteBuffer other) {
+
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ if (length0 == 1 || cmp0.equals(otherFirst0, other)) {
+ skipAhead(other);
+ skipAhead(other);
+
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match1010(ByteBuffer other) {
+
+ skipAhead(other);
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ if (length1 == 1 || cmp1.equals(otherFirst1, other)) {
+ skipAhead(other);
+
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match1011(ByteBuffer other) {
+
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ if (length0 == 1 || cmp0.equals(otherFirst0, other)) {
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ if (length1 == 1 || cmp1.equals(otherFirst1, other)) {
+ skipAhead(other);
+
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match1100(ByteBuffer other) {
+
+ skipAhead(other);
+ skipAhead(other);
+
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ if (length2 == 1 || cmp2.equals(otherFirst2, other)) {
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match1101(ByteBuffer other) {
+
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ if (length0 == 1 || cmp0.equals(otherFirst0, other)) {
+ skipAhead(other);
+
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ if (length2 == 1 || cmp2.equals(otherFirst2, other)) {
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match1110(ByteBuffer other) {
+
+ skipAhead(other);
+
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ if (length1 == 1 || cmp1.equals(otherFirst1, other)) {
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ if (length2 == 1 || cmp2.equals(otherFirst2, other)) {
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean match1111(ByteBuffer other) {
+ byte otherFirst0 = other.get();
+ if (firstByte0 == otherFirst0) {
+ if (length0 == 1 || cmp0.equals(otherFirst0, other)) {
+ byte otherFirst1 = other.get();
+ if (firstByte1 == otherFirst1) {
+ if (length1 == 1 || cmp1.equals(otherFirst1, other)) {
+ byte otherFirst2 = other.get();
+ if (firstByte2 == otherFirst2) {
+ if (length2 == 1 || cmp2.equals(otherFirst2, other)) {
+ byte otherFirst3 = other.get();
+ if (firstByte3 == otherFirst3) {
+ return length3 == 1 || cmp3.equals(otherFirst3, other);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/IndexKeyWriters.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/IndexKeyWriters.java
new file mode 100644
index 00000000000..bfd81aae41b
--- /dev/null
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/IndexKeyWriters.java
@@ -0,0 +1,497 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+package org.eclipse.rdf4j.sail.lmdb.util;
+
+import java.nio.ByteBuffer;
+
+import org.eclipse.rdf4j.sail.lmdb.Varint;
+
+public final class IndexKeyWriters {
+
+ private static final int CACHE_SIZE = 1 << 12; // 4096 entries
+ private static final int MASK = CACHE_SIZE - 1;
+
+ private IndexKeyWriters() {
+ }
+
+ @FunctionalInterface
+ public interface KeyWriter {
+ void write(ByteBuffer bb, long subj, long pred, long obj, long context, boolean shouldCache);
+ }
+
+ @FunctionalInterface
+ interface BasicWriter {
+ void write(ByteBuffer bb, long subj, long pred, long obj, long context);
+ }
+
+ @FunctionalInterface
+ public interface MatcherFactory {
+ boolean[] create(long subj, long pred, long obj, long context);
+ }
+
+ public static KeyWriter forFieldSeq(String fieldSeq) {
+ final BasicWriter basic;
+ switch (fieldSeq) {
+ case "spoc":
+ basic = IndexKeyWriters::spoc;
+ break;
+ case "spco":
+ basic = IndexKeyWriters::spco;
+ break;
+ case "sopc":
+ basic = IndexKeyWriters::sopc;
+ break;
+ case "socp":
+ basic = IndexKeyWriters::socp;
+ break;
+ case "scpo":
+ basic = IndexKeyWriters::scpo;
+ break;
+ case "scop":
+ basic = IndexKeyWriters::scop;
+ break;
+ case "psoc":
+ basic = IndexKeyWriters::psoc;
+ break;
+ case "psco":
+ basic = IndexKeyWriters::psco;
+ break;
+ case "posc":
+ basic = IndexKeyWriters::posc;
+ break;
+ case "pocs":
+ basic = IndexKeyWriters::pocs;
+ break;
+ case "pcso":
+ basic = IndexKeyWriters::pcso;
+ break;
+ case "pcos":
+ basic = IndexKeyWriters::pcos;
+ break;
+ case "ospc":
+ basic = IndexKeyWriters::ospc;
+ break;
+ case "oscp":
+ basic = IndexKeyWriters::oscp;
+ break;
+ case "opsc":
+ basic = IndexKeyWriters::opsc;
+ break;
+ case "opcs":
+ basic = IndexKeyWriters::opcs;
+ break;
+ case "ocsp":
+ basic = IndexKeyWriters::ocsp;
+ break;
+ case "ocps":
+ basic = IndexKeyWriters::ocps;
+ break;
+ case "cspo":
+ basic = IndexKeyWriters::cspo;
+ break;
+ case "csop":
+ basic = IndexKeyWriters::csop;
+ break;
+ case "cpso":
+ basic = IndexKeyWriters::cpso;
+ break;
+ case "cpos":
+ basic = IndexKeyWriters::cpos;
+ break;
+ case "cosp":
+ basic = IndexKeyWriters::cosp;
+ break;
+ case "cops":
+ basic = IndexKeyWriters::cops;
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported field sequence: " + fieldSeq);
+ }
+ // Wrap the basic writer with a caching KeyWriter implementation
+ return new CachingKeyWriter(basic);
+ }
+
+ // Simple array-based cache keyed by a masked index computed from a hashCode.
+ private static final class CachingKeyWriter implements KeyWriter {
+
+ private final CachingKeyWriter.Entry[] cache = new CachingKeyWriter.Entry[CACHE_SIZE];
+
+ private static final class Entry {
+ final long hashCode;
+ final long s, p, o, c;
+ final byte[] bytes;
+ final int length;
+
+ Entry(long hashCode, long s, long p, long o, long c, byte[] bytes) {
+ this.hashCode = hashCode;
+ this.s = s;
+ this.p = p;
+ this.o = o;
+ this.c = c;
+ this.bytes = bytes;
+ this.length = bytes.length;
+ }
+ }
+
+ private final BasicWriter basic;
+ // Races are acceptable; we overwrite slots without synchronization.
+
+ CachingKeyWriter(BasicWriter basic) {
+ this.basic = basic;
+ }
+
+ @Override
+ public void write(ByteBuffer bb, long subj, long pred, long obj, long context, boolean shouldCache) {
+ if (!shouldCache) {
+ basic.write(bb, subj, pred, obj, context);
+ return;
+ }
+
+ long hashCode = subj - Long.MAX_VALUE + (pred - Long.MAX_VALUE) * 2 + (obj - Long.MAX_VALUE) * 3
+ + (context - Long.MAX_VALUE) * 4;
+ int slot = (int) (hashCode & MASK);
+
+ Entry e = cache[slot];
+
+ if (e != null && e.hashCode == hashCode && e.s == subj && e.p == pred && e.o == obj && e.c == context) {
+ bb.put(e.bytes, 0, e.length);
+ return;
+ }
+
+ int len = Varint.calcListLengthUnsigned(subj, pred, obj, context);
+ byte[] bytes = new byte[len];
+ ByteBuffer out = ByteBuffer.wrap(bytes);
+ basic.write(out, subj, pred, obj, context);
+ out.flip();
+ bb.put(out);
+ cache[slot] = new Entry(hashCode, subj, pred, obj, context, bytes);
+ }
+ }
+
+ public static MatcherFactory matcherFactory(String fieldSeq) {
+ switch (fieldSeq) {
+ case "spoc":
+ return IndexKeyWriters::spocShouldMatch;
+ case "spco":
+ return IndexKeyWriters::spcoShouldMatch;
+ case "sopc":
+ return IndexKeyWriters::sopcShouldMatch;
+ case "socp":
+ return IndexKeyWriters::socpShouldMatch;
+ case "scpo":
+ return IndexKeyWriters::scpoShouldMatch;
+ case "scop":
+ return IndexKeyWriters::scopShouldMatch;
+ case "psoc":
+ return IndexKeyWriters::psocShouldMatch;
+ case "psco":
+ return IndexKeyWriters::pscoShouldMatch;
+ case "posc":
+ return IndexKeyWriters::poscShouldMatch;
+ case "pocs":
+ return IndexKeyWriters::pocsShouldMatch;
+ case "pcso":
+ return IndexKeyWriters::pcsoShouldMatch;
+ case "pcos":
+ return IndexKeyWriters::pcosShouldMatch;
+ case "ospc":
+ return IndexKeyWriters::ospcShouldMatch;
+ case "oscp":
+ return IndexKeyWriters::oscpShouldMatch;
+ case "opsc":
+ return IndexKeyWriters::opscShouldMatch;
+ case "opcs":
+ return IndexKeyWriters::opcsShouldMatch;
+ case "ocsp":
+ return IndexKeyWriters::ocspShouldMatch;
+ case "ocps":
+ return IndexKeyWriters::ocpsShouldMatch;
+ case "cspo":
+ return IndexKeyWriters::cspoShouldMatch;
+ case "csop":
+ return IndexKeyWriters::csopShouldMatch;
+ case "cpso":
+ return IndexKeyWriters::cpsoShouldMatch;
+ case "cpos":
+ return IndexKeyWriters::cposShouldMatch;
+ case "cosp":
+ return IndexKeyWriters::cospShouldMatch;
+ case "cops":
+ return IndexKeyWriters::copsShouldMatch;
+ default:
+ throw new IllegalArgumentException("Unsupported field sequence: " + fieldSeq);
+ }
+ }
+
+ static void spoc(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, context);
+ }
+
+ static void spco(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, obj);
+ }
+
+ static void sopc(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, context);
+ }
+
+ static void socp(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, pred);
+ }
+
+ static void scpo(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, obj);
+ }
+
+ static void scop(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, pred);
+ }
+
+ static void psoc(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, context);
+ }
+
+ static void psco(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, obj);
+ }
+
+ static void posc(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, context);
+ }
+
+ static void pocs(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, subj);
+ }
+
+ static void pcso(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, obj);
+ }
+
+ static void pcos(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, subj);
+ }
+
+ static void ospc(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, context);
+ }
+
+ static void oscp(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, pred);
+ }
+
+ static void opsc(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, context);
+ }
+
+ static void opcs(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, subj);
+ }
+
+ static void ocsp(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, pred);
+ }
+
+ static void ocps(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, subj);
+ }
+
+ static void cspo(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, obj);
+ }
+
+ static void csop(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, pred);
+ }
+
+ static void cpso(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, obj);
+ }
+
+ static void cpos(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, subj);
+ }
+
+ static void cosp(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, subj);
+ Varint.writeUnsigned(bb, pred);
+ }
+
+ static void cops(ByteBuffer bb, long subj, long pred, long obj, long context) {
+ Varint.writeUnsigned(bb, context);
+ Varint.writeUnsigned(bb, obj);
+ Varint.writeUnsigned(bb, pred);
+ Varint.writeUnsigned(bb, subj);
+ }
+
+ static boolean[] spocShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { subj > 0, pred > 0, obj > 0, context >= 0 };
+ }
+
+ static boolean[] spcoShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { subj > 0, pred > 0, context >= 0, obj > 0 };
+ }
+
+ static boolean[] sopcShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { subj > 0, obj > 0, pred > 0, context >= 0 };
+ }
+
+ static boolean[] socpShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { subj > 0, obj > 0, context >= 0, pred > 0 };
+ }
+
+ static boolean[] scpoShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { subj > 0, context >= 0, pred > 0, obj > 0 };
+ }
+
+ static boolean[] scopShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { subj > 0, context >= 0, obj > 0, pred > 0 };
+ }
+
+ static boolean[] psocShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { pred > 0, subj > 0, obj > 0, context >= 0 };
+ }
+
+ static boolean[] pscoShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { pred > 0, subj > 0, context >= 0, obj > 0 };
+ }
+
+ static boolean[] poscShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { pred > 0, obj > 0, subj > 0, context >= 0 };
+ }
+
+ static boolean[] pocsShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { pred > 0, obj > 0, context >= 0, subj > 0 };
+ }
+
+ static boolean[] pcsoShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { pred > 0, context >= 0, subj > 0, obj > 0 };
+ }
+
+ static boolean[] pcosShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { pred > 0, context >= 0, obj > 0, subj > 0 };
+ }
+
+ static boolean[] ospcShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { obj > 0, subj > 0, pred > 0, context >= 0 };
+ }
+
+ static boolean[] oscpShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { obj > 0, subj > 0, context >= 0, pred > 0 };
+ }
+
+ static boolean[] opscShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { obj > 0, pred > 0, subj > 0, context >= 0 };
+ }
+
+ static boolean[] opcsShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { obj > 0, pred > 0, context >= 0, subj > 0 };
+ }
+
+ static boolean[] ocspShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { obj > 0, context >= 0, subj > 0, pred > 0 };
+ }
+
+ static boolean[] ocpsShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { obj > 0, context >= 0, pred > 0, subj > 0 };
+ }
+
+ static boolean[] cspoShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { context >= 0, subj > 0, pred > 0, obj > 0 };
+ }
+
+ static boolean[] csopShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { context >= 0, subj > 0, obj > 0, pred > 0 };
+ }
+
+ static boolean[] cpsoShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { context >= 0, pred > 0, subj > 0, obj > 0 };
+ }
+
+ static boolean[] cposShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { context >= 0, pred > 0, obj > 0, subj > 0 };
+ }
+
+ static boolean[] cospShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { context >= 0, obj > 0, subj > 0, pred > 0 };
+ }
+
+ static boolean[] copsShouldMatch(long subj, long pred, long obj, long context) {
+ return new boolean[] { context >= 0, obj > 0, pred > 0, subj > 0 };
+ }
+}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/SignificantBytesBE.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/SignificantBytesBE.java
new file mode 100644
index 00000000000..a335023bb73
--- /dev/null
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/SignificantBytesBE.java
@@ -0,0 +1,95 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+
+package org.eclipse.rdf4j.sail.lmdb.util;
+
+import java.nio.BufferUnderflowException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/**
+ * Fast reader for 1..8 "significant" big-endian bytes from a ByteBuffer. Chooses optimal path at runtime: - heap-backed
+ * buffers: raw array indexing (no per-byte virtual calls), - direct/read-only buffers: absolute wide reads +
+ * conditional byte-swap.
+ *
+ * Returns an unsigned value in the low bits of the long (0 .. 2^(8*n)-1).
+ */
+public final class SignificantBytesBE {
+ private SignificantBytesBE() {
+ }
+
+ /**
+ * Read n (1..8) big-endian significant bytes from the buffer and advance position by n.
+ *
+ * @throws IllegalArgumentException if n is not in [1,8]
+ * @throws BufferUnderflowException if fewer than n bytes remain
+ */
+ public static long read(ByteBuffer bb, int n) {
+ return readDirect(bb, n);
+ }
+
+ // -------- Direct/read-only fast path (absolute wide reads + conditional byte swap) --------
+
+ private static long u32(int x) {
+ return x & 0xFFFF_FFFFL;
+ }
+
+ private static int u16(short x) {
+ return x & 0xFFFF;
+ }
+
+ private static short getShortBE(ByteBuffer bb, boolean littleEndian) {
+ short s = bb.getShort();
+ return (littleEndian) ? Short.reverseBytes(s) : s;
+ }
+
+ private static int getIntBE(ByteBuffer bb, boolean littleEndian) {
+ int i = bb.getInt();
+ return (littleEndian) ? Integer.reverseBytes(i) : i;
+ }
+
+ private static long getLongBE(ByteBuffer bb, boolean littleEndian) {
+ long l = bb.getLong();
+ return (littleEndian) ? Long.reverseBytes(l) : l;
+ }
+
+ public static long readDirect(ByteBuffer bb, int n) {
+ if (n < 3 || n > 8) {
+ throw new IllegalArgumentException("n must be in [3,8]");
+ }
+
+ boolean littleEndian = bb.order() == ByteOrder.LITTLE_ENDIAN;
+
+ switch (n) {
+ case 8:
+ return getLongBE(bb, littleEndian);
+ case 7:
+ return ((bb.get() & 0xFFL) << 48)
+ | ((u32(getIntBE(bb, littleEndian)) << 16))
+ | (u16(getShortBE(bb, littleEndian)));
+ case 6:
+ return (((long) u16(getShortBE(bb, littleEndian)) << 32))
+ | (u32(getIntBE(bb, littleEndian)));
+ case 5:
+ return ((bb.get() & 0xFFL) << 32)
+ | (u32(getIntBE(bb, littleEndian)));
+ case 4:
+ return u32(getIntBE(bb, littleEndian));
+ case 3:
+ return (((long) u16(getShortBE(bb, littleEndian)) << 8))
+ | (bb.get() & 0xFFL);
+ // TODO: add 1 and 2 byte cases here!!!
+ default:
+ throw new AssertionError("unreachable");
+ }
+ }
+
+}
diff --git a/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/package-info.java b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/package-info.java
new file mode 100644
index 00000000000..6501904cc48
--- /dev/null
+++ b/core/sail/lmdb/src/main/java/org/eclipse/rdf4j/sail/lmdb/util/package-info.java
@@ -0,0 +1,21 @@
+/*******************************************************************************
+ * Copyright (c) 2020 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+
+/**
+ * @apiNote This feature is for internal use only: its existence, signature or behavior may change without warning from
+ * one release to the next.
+ */
+
+@InternalUseOnly
+
+package org.eclipse.rdf4j.sail.lmdb.util;
+
+import org.eclipse.rdf4j.common.annotation.InternalUseOnly;
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/CardinalityTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/CardinalityTest.java
index 1b4aebe72fd..685db90c83a 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/CardinalityTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/CardinalityTest.java
@@ -38,7 +38,7 @@ public class CardinalityTest {
public void before() throws Exception {
File dataDir = new File(tempFolder, "triplestore");
dataDir.mkdir();
- tripleStore = new TripleStore(dataDir, new LmdbStoreConfig("spoc,posc"));
+ tripleStore = new TripleStore(dataDir, new LmdbStoreConfig("spoc,posc"), null);
}
int count(RecordIterator it) {
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/DefaultIndexTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/DefaultIndexTest.java
index 9af0da5a956..b1e8b23df7c 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/DefaultIndexTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/DefaultIndexTest.java
@@ -26,7 +26,7 @@ public class DefaultIndexTest {
@Test
public void testDefaultIndex(@TempDir File dir) throws Exception {
- TripleStore store = new TripleStore(dir, new LmdbStoreConfig());
+ TripleStore store = new TripleStore(dir, new LmdbStoreConfig(), null);
store.close();
// check that the triple store used the default index
assertEquals("spoc,posc", findIndex(dir));
@@ -36,11 +36,11 @@ public void testDefaultIndex(@TempDir File dir) throws Exception {
@Test
public void testExistingIndex(@TempDir File dir) throws Exception {
// set a non-default index
- TripleStore store = new TripleStore(dir, new LmdbStoreConfig("spoc,opsc"));
+ TripleStore store = new TripleStore(dir, new LmdbStoreConfig("spoc,opsc"), null);
store.close();
String before = findIndex(dir);
// check that the index is preserved with a null value
- store = new TripleStore(dir, new LmdbStoreConfig(null));
+ store = new TripleStore(dir, new LmdbStoreConfig(null), null);
store.close();
assertEquals(before, findIndex(dir));
FileUtil.deleteDir(dir);
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/GroupMatcherTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/GroupMatcherTest.java
new file mode 100644
index 00000000000..a259b0edd53
--- /dev/null
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/GroupMatcherTest.java
@@ -0,0 +1,295 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ ******************************************************************************/
+package org.eclipse.rdf4j.sail.lmdb;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.eclipse.rdf4j.sail.lmdb.util.GroupMatcher;
+import org.junit.jupiter.api.Test;
+
+class GroupMatcherTest {
+
+ private static final int FIELD_COUNT = 4;
+ private static final int MAX_LENGTH = 9;
+
+ private static final ValueVariants[] VALUE_VARIANTS = buildValueVariants();
+ private static final List ALL_LENGTH_COMBINATIONS = buildAllLengthCombinations();
+ private static final CandidateStrategy[] CANDIDATE_STRATEGIES = CandidateStrategy.values();
+
+ @Test
+ void coversEveryMatcherMaskAcrossAllLengthCombinations() {
+ for (int mask = 0; mask < (1 << FIELD_COUNT); mask++) {
+ final int maskBits = mask;
+ boolean[] shouldMatch = maskToArray(mask);
+ for (byte[] valueLengths : ALL_LENGTH_COMBINATIONS) {
+ final byte[] lengthsRef = valueLengths;
+ long[] referenceValues = valuesForLengths(valueLengths);
+ GroupMatcher matcher = new GroupMatcher(encodeBE(referenceValues).duplicate().array(), shouldMatch);
+
+ for (CandidateStrategy strategy : CANDIDATE_STRATEGIES) {
+ final CandidateStrategy strategyRef = strategy;
+ long[] candidateValues = buildCandidateValues(referenceValues, valueLengths, shouldMatch, strategy);
+ final long[] candidateCopy = candidateValues;
+ ByteBuffer matchBuffer = encode(candidateCopy);
+
+ assertTrue(matcher.matches(nativeOrder(matchBuffer.duplicate())),
+ () -> failureMessage("expected match", maskBits, lengthsRef, strategyRef, candidateCopy,
+ null));
+
+ if (hasMatch(shouldMatch)) {
+ for (int index = 0; index < FIELD_COUNT; index++) {
+ if (!shouldMatch[index]) {
+ continue;
+ }
+ for (MismatchType mismatchType : MismatchType.values()) {
+ long[] mismatchValues = createMismatch(candidateCopy, lengthsRef, index, mismatchType);
+ if (mismatchValues == null) {
+ continue;
+ }
+ final long[] mismatchCopy = mismatchValues;
+ ByteBuffer mismatchBuffer = encode(mismatchCopy);
+ assertFalse(matcher.matches(nativeOrder(mismatchBuffer.duplicate())),
+ () -> failureMessage("expected mismatch",
+ maskBits, lengthsRef, strategyRef, mismatchCopy, mismatchType));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private ByteBuffer nativeOrder(ByteBuffer duplicate) {
+ duplicate.order(ByteOrder.nativeOrder());
+ return duplicate;
+ }
+
+ private static long[] valuesForLengths(byte[] lengthIndices) {
+ long[] values = new long[FIELD_COUNT];
+ for (int i = 0; i < FIELD_COUNT; i++) {
+ int lengthIndex = Byte.toUnsignedInt(lengthIndices[i]);
+ values[i] = VALUE_VARIANTS[lengthIndex].base;
+ }
+ return values;
+ }
+
+ private static long[] buildCandidateValues(long[] referenceValues, byte[] valueLengths, boolean[] shouldMatch,
+ CandidateStrategy strategy) {
+ long[] candidateValues = new long[FIELD_COUNT];
+ for (int i = 0; i < FIELD_COUNT; i++) {
+ if (shouldMatch[i]) {
+ candidateValues[i] = referenceValues[i];
+ } else {
+ int lengthIndex = selectLengthIndex(valueLengths, i, strategy);
+ candidateValues[i] = VALUE_VARIANTS[lengthIndex].nonMatchingSameLength;
+ }
+ }
+ return candidateValues;
+ }
+
+ private static int selectLengthIndex(byte[] lengths, int position, CandidateStrategy strategy) {
+ int base = Byte.toUnsignedInt(lengths[position]);
+ switch (strategy) {
+ case SAME_LENGTHS:
+ return base;
+ case ROTATED_LENGTHS:
+ return Byte.toUnsignedInt(lengths[(position + 1) % FIELD_COUNT]);
+ case INCREMENTED_LENGTHS:
+ return base == MAX_LENGTH ? 1 : base + 1;
+ default:
+ throw new IllegalStateException("Unsupported strategy: " + strategy);
+ }
+ }
+
+ private static ByteBuffer encode(long[] values) {
+ ByteBuffer buffer = ByteBuffer
+ .allocate(Varint.calcListLengthUnsigned(values[0], values[1], values[2], values[3]));
+ buffer.order(ByteOrder.nativeOrder());
+ for (long value : values) {
+ Varint.writeUnsigned(buffer, value);
+ }
+ buffer.flip();
+ return buffer;
+ }
+
+ private static ByteBuffer encodeBE(long[] values) {
+ ByteBuffer buffer = ByteBuffer
+ .allocate(Varint.calcListLengthUnsigned(values[0], values[1], values[2], values[3]));
+ buffer.order(ByteOrder.nativeOrder());
+ for (long value : values) {
+ Varint.writeUnsigned(buffer, value);
+ }
+ buffer.flip();
+ return buffer;
+ }
+
+ private static boolean[] maskToArray(int mask) {
+ boolean[] shouldMatch = new boolean[FIELD_COUNT];
+ for (int i = 0; i < FIELD_COUNT; i++) {
+ shouldMatch[i] = (mask & (1 << i)) != 0;
+ }
+ return shouldMatch;
+ }
+
+ private static boolean hasMatch(boolean[] shouldMatch) {
+ for (boolean flag : shouldMatch) {
+ if (flag) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static int firstMatchedIndex(boolean[] shouldMatch) {
+ for (int i = 0; i < FIELD_COUNT; i++) {
+ if (shouldMatch[i]) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static List buildAllLengthCombinations() {
+ List combos = new ArrayList<>((int) Math.pow(MAX_LENGTH, FIELD_COUNT));
+ buildCombos(combos, new byte[FIELD_COUNT], 0);
+ return combos;
+ }
+
+ private static void buildCombos(List combos, byte[] current, int index) {
+ if (index == FIELD_COUNT) {
+ combos.add(current.clone());
+ return;
+ }
+ for (int len = 1; len <= MAX_LENGTH; len++) {
+ current[index] = (byte) len;
+ buildCombos(combos, current, index + 1);
+ }
+ }
+
+ private static String failureMessage(String expectation, int mask, byte[] valueLengths, CandidateStrategy strategy,
+ long[] candidateValues, MismatchType mismatchType) {
+ return expectation + " for mask " + toMask(mask) + ", valueLengths=" + Arrays.toString(toIntArray(valueLengths))
+ + ", strategy=" + strategy
+ + (mismatchType == null ? "" : ", mismatchType=" + mismatchType)
+ + ", candidate=" + Arrays.toString(candidateValues);
+ }
+
+ private static String toMask(int mask) {
+ return String.format("%4s", Integer.toBinaryString(mask)).replace(' ', '0');
+ }
+
+ private static int[] toIntArray(byte[] values) {
+ int[] ints = new int[values.length];
+ for (int i = 0; i < values.length; i++) {
+ ints[i] = Byte.toUnsignedInt(values[i]);
+ }
+ return ints;
+ }
+
+ private static long[] createMismatch(long[] baseCandidate, byte[] valueLengths, int index,
+ MismatchType mismatchType) {
+ int lengthIndex = Byte.toUnsignedInt(valueLengths[index]);
+ ValueVariants variants = VALUE_VARIANTS[lengthIndex];
+ long replacement;
+ switch (mismatchType) {
+ case SAME_FIRST_BYTE:
+ if (variants.sameFirstVariant == null) {
+ return null;
+ }
+ replacement = variants.sameFirstVariant;
+ break;
+ case DIFFERENT_FIRST_BYTE:
+ replacement = variants.differentFirstVariant;
+ break;
+ default:
+ throw new IllegalStateException("Unsupported mismatch type: " + mismatchType);
+ }
+ if (replacement == baseCandidate[index]) {
+ return null;
+ }
+ long[] mismatch = baseCandidate.clone();
+ mismatch[index] = replacement;
+ return mismatch;
+ }
+
+ private static ValueVariants[] buildValueVariants() {
+ ValueVariants[] variants = new ValueVariants[MAX_LENGTH + 1];
+ variants[1] = new ValueVariants(42L, 99L, null, 99L);
+ variants[2] = new ValueVariants(241L, 330L, 330L, 600L);
+ variants[3] = new ValueVariants(50_000L, 60_000L, 60_000L, 70_000L);
+ variants[4] = new ValueVariants(1_048_576L, 1_048_577L, 1_048_577L, 16_777_216L);
+ variants[5] = new ValueVariants(16_777_216L, 16_777_217L, 16_777_217L, 4_294_967_296L);
+ variants[6] = new ValueVariants(4_294_967_296L, 4_294_967_297L, 4_294_967_297L, 1_099_511_627_776L);
+ variants[7] = new ValueVariants(1_099_511_627_776L, 1_099_511_627_777L, 1_099_511_627_777L,
+ 281_474_976_710_656L);
+ variants[8] = new ValueVariants(281_474_976_710_656L, 281_474_976_710_657L, 281_474_976_710_657L,
+ 72_057_594_037_927_936L);
+ variants[9] = new ValueVariants(72_057_594_037_927_936L, 72_057_594_037_927_937L,
+ 72_057_594_037_927_937L, 281_474_976_710_656L);
+
+ for (int len = 1; len <= MAX_LENGTH; len++) {
+ ValueVariants v = variants[len];
+ if (Varint.calcLengthUnsigned(v.base) != len) {
+ throw new IllegalStateException("Unexpected length for base value " + v.base + " (len=" + len + ")");
+ }
+ if (Varint.calcLengthUnsigned(v.nonMatchingSameLength) != len) {
+ throw new IllegalStateException(
+ "Unexpected length for same-length variant " + v.nonMatchingSameLength + " (len=" + len + ")");
+ }
+ if (v.sameFirstVariant != null && firstByte(v.sameFirstVariant.longValue()) != firstByte(v.base)) {
+ throw new IllegalStateException("Expected same-first variant to share header for length " + len);
+ }
+ if (firstByte(v.differentFirstVariant) == firstByte(v.base)) {
+ throw new IllegalStateException("Expected different-first variant to differ for length " + len);
+ }
+ }
+
+ return variants;
+ }
+
+ private static byte firstByte(long value) {
+ ByteBuffer buffer = ByteBuffer.allocate(Varint.calcLengthUnsigned(value));
+ Varint.writeUnsigned(buffer, value);
+ return buffer.array()[0];
+ }
+
+ private static final class ValueVariants {
+ final long base;
+ final long nonMatchingSameLength;
+ final Long sameFirstVariant;
+ final long differentFirstVariant;
+
+ ValueVariants(long base, long nonMatchingSameLength, Long sameFirstVariant, long differentFirstVariant) {
+ this.base = base;
+ this.nonMatchingSameLength = nonMatchingSameLength;
+ this.sameFirstVariant = sameFirstVariant;
+ this.differentFirstVariant = differentFirstVariant;
+ }
+ }
+
+ private enum MismatchType {
+ SAME_FIRST_BYTE,
+ DIFFERENT_FIRST_BYTE
+ }
+
+ private enum CandidateStrategy {
+ SAME_LENGTHS,
+ ROTATED_LENGTHS,
+ INCREMENTED_LENGTHS
+ }
+}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/GroupMatcherTest2.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/GroupMatcherTest2.java
new file mode 100644
index 00000000000..00f58e384ab
--- /dev/null
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/GroupMatcherTest2.java
@@ -0,0 +1,312 @@
+///*******************************************************************************
+// * Copyright (c) 2025 Eclipse RDF4J contributors.
+// *
+// * All rights reserved. This program and the accompanying materials
+// * are made available under the terms of the Eclipse Distribution License v1.0
+// * which accompanies this distribution, and is available at
+// * http://www.eclipse.org/org/documents/edl-v10.php.
+// *
+// * SPDX-License-Identifier: BSD-3-Clause
+// ******************************************************************************/
+//package org.eclipse.rdf4j.sail.lmdb;
+//
+//import org.eclipse.rdf4j.sail.lmdb.util.GroupMatcher;
+//import org.junit.jupiter.api.DynamicTest;
+//import org.junit.jupiter.api.TestFactory;
+//
+//import java.nio.ByteBuffer;
+//import java.util.ArrayList;
+//import java.util.Arrays;
+//import java.util.List;
+//import java.util.Optional;
+//import java.util.stream.IntStream;
+//import java.util.stream.Stream;
+//
+//import static org.junit.jupiter.api.Assertions.assertFalse;
+//import static org.junit.jupiter.api.Assertions.assertTrue;
+//
+//class GroupMatcherTest2 {
+//
+// private static final int FIELD_COUNT = 4;
+// private static final int MAX_LENGTH = 9;
+//
+// private static final ValueVariants[] VALUE_VARIANTS = buildValueVariants();
+// private static final List ALL_LENGTH_COMBINATIONS = buildAllLengthCombinations();
+// private static final CandidateStrategy[] CANDIDATE_STRATEGIES = CandidateStrategy.values();
+//
+// @TestFactory
+// Stream coversEveryMatcherMaskAcrossAllLengthCombinations() {
+// return IntStream.range(0, 1 << FIELD_COUNT)
+// .mapToObj(Integer::valueOf)
+// .flatMap(this::dynamicTestsForMask);
+// }
+//
+// private Stream dynamicTestsForMask(int maskBits) {
+// boolean[] shouldMatch = maskToArray(maskBits);
+// return ALL_LENGTH_COMBINATIONS.stream()
+// .flatMap(lengths -> dynamicTestsForLengths(maskBits, shouldMatch, lengths));
+// }
+//
+// private Stream dynamicTestsForLengths(int maskBits, boolean[] shouldMatch, byte[] valueLengths) {
+// return Arrays.stream(CANDIDATE_STRATEGIES)
+// .flatMap(strategy -> dynamicTestsForStrategy(maskBits, shouldMatch, valueLengths, strategy));
+// }
+//
+// private Stream dynamicTestsForStrategy(int maskBits, boolean[] shouldMatch, byte[] valueLengths,
+// CandidateStrategy strategy) {
+// long[] referenceValues = valuesForLengths(valueLengths);
+// Stream matchTest = Stream.of(createMatchTest(maskBits, shouldMatch, valueLengths, strategy,
+// referenceValues));
+// Stream mismatchTests = hasMatch(shouldMatch)
+// ? dynamicMismatchTests(maskBits, shouldMatch, valueLengths, referenceValues, strategy)
+// : Stream.empty();
+// return Stream.concat(matchTest, mismatchTests);
+// }
+//
+// private DynamicTest createMatchTest(int maskBits, boolean[] shouldMatch, byte[] valueLengths,
+// CandidateStrategy strategy, long[] referenceValues) {
+// String displayName = "match mask=" + toMask(maskBits) + ", valueLengths="
+// + Arrays.toString(toIntArray(valueLengths))
+// + ", strategy=" + strategy;
+// return DynamicTest.dynamicTest(displayName, () -> {
+// boolean[] shouldMatchCopy = Arrays.copyOf(shouldMatch, shouldMatch.length);
+// GroupMatcher matcher = new GroupMatcher(encode(referenceValues).duplicate(), shouldMatchCopy);
+// long[] candidateValues = buildCandidateValues(referenceValues, valueLengths, shouldMatchCopy, strategy);
+// assertTrue(matcher.matches(encode(candidateValues).duplicate()),
+// () -> failureMessage("expected match", maskBits, valueLengths, strategy, candidateValues, null));
+// });
+// }
+//
+// private Stream dynamicMismatchTests(int maskBits, boolean[] shouldMatch, byte[] valueLengths,
+// long[] referenceValues, CandidateStrategy strategy) {
+// return IntStream.range(0, FIELD_COUNT)
+// .filter(index -> shouldMatch[index])
+// .mapToObj(Integer::valueOf)
+// .flatMap(index -> Arrays.stream(MismatchType.values())
+// .map(mismatchType -> createMismatchTest(maskBits, shouldMatch, valueLengths, referenceValues,
+// strategy,
+// index, mismatchType))
+// .flatMap(Optional::stream));
+// }
+//
+// private Optional createMismatchTest(int maskBits, boolean[] shouldMatch, byte[] valueLengths,
+// long[] referenceValues, CandidateStrategy strategy, int index, MismatchType mismatchType) {
+// long[] candidateValues = buildCandidateValues(referenceValues, valueLengths, shouldMatch, strategy);
+// long[] mismatchValues = createMismatch(candidateValues, valueLengths, index, mismatchType);
+// if (mismatchValues == null) {
+// return Optional.empty();
+// }
+// String displayName = "mismatch mask=" + toMask(maskBits) + ", valueLengths="
+// + Arrays.toString(toIntArray(valueLengths)) + ", strategy=" + strategy + ", index=" + index + ", type="
+// + mismatchType;
+// return Optional.of(DynamicTest.dynamicTest(displayName, () -> {
+// boolean[] shouldMatchCopy = Arrays.copyOf(shouldMatch, shouldMatch.length);
+// GroupMatcher matcher = new GroupMatcher(encode(referenceValues).duplicate(), shouldMatchCopy);
+// assertFalse(matcher.matches(encode(mismatchValues).duplicate()),
+// () -> failureMessage("expected mismatch", maskBits, valueLengths, strategy, mismatchValues,
+// mismatchType));
+// }));
+// }
+//
+// private static long[] valuesForLengths(byte[] lengthIndices) {
+// long[] values = new long[FIELD_COUNT];
+// for (int i = 0; i < FIELD_COUNT; i++) {
+// int lengthIndex = Byte.toUnsignedInt(lengthIndices[i]);
+// values[i] = VALUE_VARIANTS[lengthIndex].base;
+// }
+// return values;
+// }
+//
+// private static long[] buildCandidateValues(long[] referenceValues, byte[] valueLengths, boolean[] shouldMatch,
+// CandidateStrategy strategy) {
+// long[] candidateValues = new long[FIELD_COUNT];
+// for (int i = 0; i < FIELD_COUNT; i++) {
+// if (shouldMatch[i]) {
+// candidateValues[i] = referenceValues[i];
+// } else {
+// int lengthIndex = selectLengthIndex(valueLengths, i, strategy);
+// candidateValues[i] = VALUE_VARIANTS[lengthIndex].nonMatchingSameLength;
+// }
+// }
+// return candidateValues;
+// }
+//
+// private static int selectLengthIndex(byte[] lengths, int position, CandidateStrategy strategy) {
+// int base = Byte.toUnsignedInt(lengths[position]);
+// switch (strategy) {
+// case SAME_LENGTHS:
+// return base;
+// case ROTATED_LENGTHS:
+// return Byte.toUnsignedInt(lengths[(position + 1) % FIELD_COUNT]);
+// case INCREMENTED_LENGTHS:
+// return base == MAX_LENGTH ? 1 : base + 1;
+// default:
+// throw new IllegalStateException("Unsupported strategy: " + strategy);
+// }
+// }
+//
+// private static ByteBuffer encode(long[] values) {
+// ByteBuffer buffer = ByteBuffer
+// .allocate(Varint.calcListLengthUnsigned(values[0], values[1], values[2], values[3]));
+// for (long value : values) {
+// Varint.writeUnsigned(buffer, value);
+// }
+// buffer.flip();
+// return buffer;
+// }
+//
+// private static boolean[] maskToArray(int mask) {
+// boolean[] shouldMatch = new boolean[FIELD_COUNT];
+// for (int i = 0; i < FIELD_COUNT; i++) {
+// shouldMatch[i] = (mask & (1 << i)) != 0;
+// }
+// return shouldMatch;
+// }
+//
+// private static boolean hasMatch(boolean[] shouldMatch) {
+// for (boolean flag : shouldMatch) {
+// if (flag) {
+// return true;
+// }
+// }
+// return false;
+// }
+//
+// private static int firstMatchedIndex(boolean[] shouldMatch) {
+// for (int i = 0; i < FIELD_COUNT; i++) {
+// if (shouldMatch[i]) {
+// return i;
+// }
+// }
+// return -1;
+// }
+//
+// private static List buildAllLengthCombinations() {
+// List combos = new ArrayList<>((int) Math.pow(MAX_LENGTH, FIELD_COUNT));
+// buildCombos(combos, new byte[FIELD_COUNT], 0);
+// return combos;
+// }
+//
+// private static void buildCombos(List combos, byte[] current, int index) {
+// if (index == FIELD_COUNT) {
+// combos.add(current.clone());
+// return;
+// }
+// for (int len = 1; len <= MAX_LENGTH; len++) {
+// current[index] = (byte) len;
+// buildCombos(combos, current, index + 1);
+// }
+// }
+//
+// private static String failureMessage(String expectation, int mask, byte[] valueLengths, CandidateStrategy strategy,
+// long[] candidateValues, MismatchType mismatchType) {
+// return expectation + " for mask " + toMask(mask) + ", valueLengths=" + Arrays.toString(toIntArray(valueLengths))
+// + ", strategy=" + strategy
+// + (mismatchType == null ? "" : ", mismatchType=" + mismatchType)
+// + ", candidate=" + Arrays.toString(candidateValues);
+// }
+//
+// private static String toMask(int mask) {
+// return String.format("%4s", Integer.toBinaryString(mask)).replace(' ', '0');
+// }
+//
+// private static int[] toIntArray(byte[] values) {
+// int[] ints = new int[values.length];
+// for (int i = 0; i < values.length; i++) {
+// ints[i] = Byte.toUnsignedInt(values[i]);
+// }
+// return ints;
+// }
+//
+// private static long[] createMismatch(long[] baseCandidate, byte[] valueLengths, int index,
+// MismatchType mismatchType) {
+// int lengthIndex = Byte.toUnsignedInt(valueLengths[index]);
+// ValueVariants variants = VALUE_VARIANTS[lengthIndex];
+// long replacement;
+// switch (mismatchType) {
+// case SAME_FIRST_BYTE:
+// if (variants.sameFirstVariant == null) {
+// return null;
+// }
+// replacement = variants.sameFirstVariant;
+// break;
+// case DIFFERENT_FIRST_BYTE:
+// replacement = variants.differentFirstVariant;
+// break;
+// default:
+// throw new IllegalStateException("Unsupported mismatch type: " + mismatchType);
+// }
+// if (replacement == baseCandidate[index]) {
+// return null;
+// }
+// long[] mismatch = baseCandidate.clone();
+// mismatch[index] = replacement;
+// return mismatch;
+// }
+//
+// private static ValueVariants[] buildValueVariants() {
+// ValueVariants[] variants = new ValueVariants[MAX_LENGTH + 1];
+// variants[1] = new ValueVariants(42L, 99L, null, 99L);
+// variants[2] = new ValueVariants(241L, 330L, 330L, 600L);
+// variants[3] = new ValueVariants(50_000L, 60_000L, 60_000L, 70_000L);
+// variants[4] = new ValueVariants(1_048_576L, 1_048_577L, 1_048_577L, 16_777_216L);
+// variants[5] = new ValueVariants(16_777_216L, 16_777_217L, 16_777_217L, 4_294_967_296L);
+// variants[6] = new ValueVariants(4_294_967_296L, 4_294_967_297L, 4_294_967_297L, 1_099_511_627_776L);
+// variants[7] = new ValueVariants(1_099_511_627_776L, 1_099_511_627_777L, 1_099_511_627_777L,
+// 281_474_976_710_656L);
+// variants[8] = new ValueVariants(281_474_976_710_656L, 281_474_976_710_657L, 281_474_976_710_657L,
+// 72_057_594_037_927_936L);
+// variants[9] = new ValueVariants(72_057_594_037_927_936L, 72_057_594_037_927_937L,
+// 72_057_594_037_927_937L, 281_474_976_710_656L);
+//
+// for (int len = 1; len <= MAX_LENGTH; len++) {
+// ValueVariants v = variants[len];
+// if (Varint.calcLengthUnsigned(v.base) != len) {
+// throw new IllegalStateException("Unexpected length for base value " + v.base + " (len=" + len + ")");
+// }
+// if (Varint.calcLengthUnsigned(v.nonMatchingSameLength) != len) {
+// throw new IllegalStateException(
+// "Unexpected length for same-length variant " + v.nonMatchingSameLength + " (len=" + len + ")");
+// }
+// if (v.sameFirstVariant != null && firstByte(v.sameFirstVariant.longValue()) != firstByte(v.base)) {
+// throw new IllegalStateException("Expected same-first variant to share header for length " + len);
+// }
+// if (firstByte(v.differentFirstVariant) == firstByte(v.base)) {
+// throw new IllegalStateException("Expected different-first variant to differ for length " + len);
+// }
+// }
+//
+// return variants;
+// }
+//
+// private static byte firstByte(long value) {
+// ByteBuffer buffer = ByteBuffer.allocate(Varint.calcLengthUnsigned(value));
+// Varint.writeUnsigned(buffer, value);
+// return buffer.array()[0];
+// }
+//
+// private static final class ValueVariants {
+// final long base;
+// final long nonMatchingSameLength;
+// final Long sameFirstVariant;
+// final long differentFirstVariant;
+//
+// ValueVariants(long base, long nonMatchingSameLength, Long sameFirstVariant, long differentFirstVariant) {
+// this.base = base;
+// this.nonMatchingSameLength = nonMatchingSameLength;
+// this.sameFirstVariant = sameFirstVariant;
+// this.differentFirstVariant = differentFirstVariant;
+// }
+// }
+//
+// private enum MismatchType {
+// SAME_FIRST_BYTE,
+// DIFFERENT_FIRST_BYTE
+// }
+//
+// private enum CandidateStrategy {
+// SAME_LENGTHS,
+// ROTATED_LENGTHS,
+// INCREMENTED_LENGTHS
+// }
+//}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStoreTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStoreTest.java
index 2e416067a18..b735074c00c 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStoreTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/LmdbSailStoreTest.java
@@ -16,6 +16,8 @@
import java.io.File;
+import org.eclipse.rdf4j.common.iteration.CloseableIteration;
+import org.eclipse.rdf4j.common.iteration.EmptyIteration;
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Resource;
@@ -28,6 +30,8 @@
import org.eclipse.rdf4j.repository.Repository;
import org.eclipse.rdf4j.repository.RepositoryConnection;
import org.eclipse.rdf4j.repository.sail.SailRepository;
+import org.eclipse.rdf4j.sail.SailException;
+import org.eclipse.rdf4j.sail.base.SailDataset;
import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -193,6 +197,18 @@ public void testPassConnectionBetweenThreadsWithTx() throws InterruptedException
}
}
+ @Test
+ public void testInferredSourceHasEmptyIterationWithoutInferredStatements() throws SailException {
+ LmdbStore sail = (LmdbStore) ((SailRepository) repo).getSail();
+ LmdbSailStore backingStore = sail.getBackingStore();
+
+ try (SailDataset dataset = backingStore.getInferredSailSource().dataset(IsolationLevels.NONE);
+ CloseableIteration extends Statement> iteration = dataset.getStatements(null, null, null)) {
+ assertTrue(iteration instanceof EmptyIteration);
+ assertFalse(iteration.hasNext());
+ }
+ }
+
@AfterEach
public void after() {
repo.shutDown();
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/QueryBenchmarkTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/QueryBenchmarkTest.java
index 7a0fc04b60f..3f8b3068fdf 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/QueryBenchmarkTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/QueryBenchmarkTest.java
@@ -27,6 +27,7 @@
import org.eclipse.rdf4j.model.Statement;
import org.eclipse.rdf4j.model.vocabulary.RDF;
import org.eclipse.rdf4j.query.BindingSet;
+import org.eclipse.rdf4j.query.TupleQueryResult;
import org.eclipse.rdf4j.repository.sail.SailRepository;
import org.eclipse.rdf4j.repository.sail.SailRepositoryConnection;
import org.eclipse.rdf4j.rio.RDFFormat;
@@ -44,37 +45,70 @@ public class QueryBenchmarkTest {
private static SailRepository repository;
public static TemporaryFolder tempDir = new TemporaryFolder();
+ static List statementList;
private static final String query1;
private static final String query2;
private static final String query3;
private static final String query4;
- private static final String query5;
- private static final String optionalLhsFilterQuery;
- private static final String optionalRhsFilterQuery;
- private static final String orderedUnionLimitQuery;
- private static final String subSelectQuery;
- private static final String multipleSubSelectQuery;
-
- static List statementList;
+ private static final String query7_pathexpression1;
+ private static final String query8_pathexpression2;
+
+ private static final String common_themes;
+ private static final String different_datasets_with_similar_distributions;
+ private static final String long_chain;
+ private static final String optional_lhs_filter;
+ private static final String optional_rhs_filter;
+ private static final String ordered_union_limit;
+ private static final String lots_of_optional;
+ private static final String minus;
+ private static final String nested_optionals;
+ private static final String particularly_large_join_surface;
+ private static final String query_distinct_predicates;
+ private static final String simple_filter_not;
+ private static final String wild_card_chain_with_common_ends;
+ private static final String sub_select;
+ private static final String multiple_sub_select;
static {
try {
+ common_themes = IOUtils.toString(getResourceAsStream("benchmarkFiles/common-themes.qr"),
+ StandardCharsets.UTF_8);
+ different_datasets_with_similar_distributions = IOUtils.toString(
+ getResourceAsStream("benchmarkFiles/different-datasets-with-similar-distributions.qr"),
+ StandardCharsets.UTF_8);
+ long_chain = IOUtils.toString(getResourceAsStream("benchmarkFiles/long-chain.qr"), StandardCharsets.UTF_8);
+ optional_lhs_filter = IOUtils.toString(getResourceAsStream("benchmarkFiles/optional-lhs-filter.qr"),
+ StandardCharsets.UTF_8);
+ optional_rhs_filter = IOUtils.toString(getResourceAsStream("benchmarkFiles/optional-rhs-filter.qr"),
+ StandardCharsets.UTF_8);
+ ordered_union_limit = IOUtils.toString(getResourceAsStream("benchmarkFiles/ordered-union-limit.qr"),
+ StandardCharsets.UTF_8);
+ lots_of_optional = IOUtils.toString(getResourceAsStream("benchmarkFiles/lots-of-optional.qr"),
+ StandardCharsets.UTF_8);
+ minus = IOUtils.toString(getResourceAsStream("benchmarkFiles/minus.qr"), StandardCharsets.UTF_8);
+ nested_optionals = IOUtils.toString(getResourceAsStream("benchmarkFiles/nested-optionals.qr"),
+ StandardCharsets.UTF_8);
+ particularly_large_join_surface = IOUtils.toString(
+ getResourceAsStream("benchmarkFiles/particularly-large-join-surface.qr"), StandardCharsets.UTF_8);
query1 = IOUtils.toString(getResourceAsStream("benchmarkFiles/query1.qr"), StandardCharsets.UTF_8);
query2 = IOUtils.toString(getResourceAsStream("benchmarkFiles/query2.qr"), StandardCharsets.UTF_8);
query3 = IOUtils.toString(getResourceAsStream("benchmarkFiles/query3.qr"), StandardCharsets.UTF_8);
query4 = IOUtils.toString(getResourceAsStream("benchmarkFiles/query4.qr"), StandardCharsets.UTF_8);
- query5 = IOUtils.toString(getResourceAsStream("benchmarkFiles/query5.qr"), StandardCharsets.UTF_8);
- optionalLhsFilterQuery = IOUtils.toString(
- getResourceAsStream("benchmarkFiles/optional-lhs-filter.qr"), StandardCharsets.UTF_8);
- optionalRhsFilterQuery = IOUtils.toString(
- getResourceAsStream("benchmarkFiles/optional-rhs-filter.qr"), StandardCharsets.UTF_8);
- orderedUnionLimitQuery = IOUtils.toString(
- getResourceAsStream("benchmarkFiles/ordered-union-limit.qr"), StandardCharsets.UTF_8);
- subSelectQuery = IOUtils.toString(getResourceAsStream("benchmarkFiles/sub-select.qr"),
+ query7_pathexpression1 = IOUtils.toString(getResourceAsStream("benchmarkFiles/query7-pathexpression1.qr"),
+ StandardCharsets.UTF_8);
+ query8_pathexpression2 = IOUtils.toString(getResourceAsStream("benchmarkFiles/query8-pathexpression2.qr"),
StandardCharsets.UTF_8);
- multipleSubSelectQuery = IOUtils.toString(
+ query_distinct_predicates = IOUtils.toString(
+ getResourceAsStream("benchmarkFiles/query-distinct-predicates.qr"), StandardCharsets.UTF_8);
+ simple_filter_not = IOUtils.toString(getResourceAsStream("benchmarkFiles/simple-filter-not.qr"),
+ StandardCharsets.UTF_8);
+ wild_card_chain_with_common_ends = IOUtils.toString(
+ getResourceAsStream("benchmarkFiles/wild-card-chain-with-common-ends.qr"), StandardCharsets.UTF_8);
+ sub_select = IOUtils.toString(getResourceAsStream("benchmarkFiles/sub-select.qr"), StandardCharsets.UTF_8);
+ multiple_sub_select = IOUtils.toString(
getResourceAsStream("benchmarkFiles/multiple-sub-select.qr"), StandardCharsets.UTF_8);
+
} catch (IOException e) {
throw new RuntimeException(e);
}
@@ -138,7 +172,7 @@ public void complexQuery() {
public void distinctPredicatesQuery() {
try (SailRepositoryConnection connection = repository.getConnection()) {
long count;
- try (var stream = connection.prepareTupleQuery(query5).evaluate().stream()) {
+ try (var stream = connection.prepareTupleQuery(query_distinct_predicates).evaluate().stream()) {
count = stream.count();
}
System.out.println(count);
@@ -149,7 +183,7 @@ public void distinctPredicatesQuery() {
public void optionalLhsFilterQueryProducesExpectedCount() {
try (SailRepositoryConnection connection = repository.getConnection()) {
long count;
- try (var stream = connection.prepareTupleQuery(optionalLhsFilterQuery).evaluate().stream()) {
+ try (var stream = connection.prepareTupleQuery(optional_lhs_filter).evaluate().stream()) {
count = stream.count();
}
assertEquals(34904L, count);
@@ -160,7 +194,7 @@ public void optionalLhsFilterQueryProducesExpectedCount() {
public void optionalRhsFilterQueryProducesExpectedCount() {
try (SailRepositoryConnection connection = repository.getConnection()) {
long count;
- try (var stream = connection.prepareTupleQuery(optionalRhsFilterQuery).evaluate().stream()) {
+ try (var stream = connection.prepareTupleQuery(optional_rhs_filter).evaluate().stream()) {
count = stream.count();
}
assertEquals(37917L, count);
@@ -171,7 +205,7 @@ public void optionalRhsFilterQueryProducesExpectedCount() {
public void orderedUnionLimitQueryProducesExpectedCount() {
try (SailRepositoryConnection connection = repository.getConnection()) {
long count;
- try (var stream = connection.prepareTupleQuery(orderedUnionLimitQuery).evaluate().stream()) {
+ try (var stream = connection.prepareTupleQuery(ordered_union_limit).evaluate().stream()) {
count = stream.count();
}
assertEquals(250L, count);
@@ -182,7 +216,7 @@ public void orderedUnionLimitQueryProducesExpectedCount() {
public void subSelectQueryProducesExpectedCount() {
try (SailRepositoryConnection connection = repository.getConnection()) {
long count;
- try (var stream = connection.prepareTupleQuery(subSelectQuery).evaluate().stream()) {
+ try (var stream = connection.prepareTupleQuery(sub_select).evaluate().stream()) {
count = stream.count();
}
assertEquals(16035L, count);
@@ -193,7 +227,7 @@ public void subSelectQueryProducesExpectedCount() {
public void multipleSubSelectQueryProducesExpectedCount() {
try (SailRepositoryConnection connection = repository.getConnection()) {
long count;
- try (var stream = connection.prepareTupleQuery(multipleSubSelectQuery).evaluate().stream()) {
+ try (var stream = connection.prepareTupleQuery(multiple_sub_select).evaluate().stream()) {
count = stream.count();
}
assertEquals(27881L, count);
@@ -262,10 +296,28 @@ public void simpleUpdateQueryIsolationNone() {
}
+ @Test
+ public void ordered_union_limit() {
+ for (int i = 0; i < 100; i++) {
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ long count = count(connection
+ .prepareTupleQuery(ordered_union_limit)
+ .evaluate());
+ assertEquals(250L, count);
+ }
+ }
+ }
+
private boolean hasStatement() {
try (SailRepositoryConnection connection = repository.getConnection()) {
return connection.hasStatement(RDF.TYPE, RDF.TYPE, RDF.TYPE, true);
}
}
+ private static long count(TupleQueryResult evaluate) {
+ try (Stream stream = evaluate.stream()) {
+ return stream.count();
+ }
+ }
+
}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/RecordIteratorBenchmark.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/RecordIteratorBenchmark.java
index b26b86a6279..db6d2da0486 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/RecordIteratorBenchmark.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/RecordIteratorBenchmark.java
@@ -18,8 +18,24 @@
import org.apache.commons.io.FileUtils;
import org.assertj.core.util.Files;
import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig;
-import org.openjdk.jmh.annotations.*;
+import org.openjdk.jmh.Main;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
/**
* @author Piotr Sowiński
@@ -27,10 +43,10 @@
@State(Scope.Benchmark)
@Warmup(iterations = 5)
@BenchmarkMode({ Mode.AverageTime })
-@Fork(value = 4, jvmArgs = { "-Xms1G", "-Xmx1G" })
+@Fork(value = 1, jvmArgs = { "-Xms1G", "-Xmx1G" })
//@Fork(value = 1, jvmArgs = {"-Xms1G", "-Xmx1G", "-XX:StartFlightRecording=jdk.CPUTimeSample#enabled=true,filename=profile.jfr,method-profiling=max","-XX:FlightRecorderOptions=stackdepth=1024", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints"})
@Threads(value = 8)
-@Measurement(iterations = 10)
+@Measurement(iterations = 5)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class RecordIteratorBenchmark {
@@ -40,7 +56,7 @@ public class RecordIteratorBenchmark {
@Setup(Level.Trial)
public void setup() throws IOException {
dataDir = Files.newTemporaryFolder();
- tripleStore = new TripleStore(dataDir, new LmdbStoreConfig("spoc,posc"));
+ tripleStore = new TripleStore(dataDir, new LmdbStoreConfig("spoc,posc"), null);
final int statements = 1_000_000;
tripleStore.startTransaction();
@@ -67,4 +83,18 @@ public void iterateAll(Blackhole blackhole) throws IOException {
}
}
}
+
+ public static void main(String[] args) throws Exception {
+ if (args != null && args.length > 0) {
+ Main.main(args);
+ return;
+ }
+
+ Options options = new OptionsBuilder()
+ .include(RecordIteratorBenchmark.class.getSimpleName() + ".iterateAll")
+ .forks(0)
+ .build();
+
+ new Runner(options).run();
+ }
}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleIndexToKeyCacheTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleIndexToKeyCacheTest.java
new file mode 100644
index 00000000000..0e82f5246fe
--- /dev/null
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleIndexToKeyCacheTest.java
@@ -0,0 +1,96 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.sail.lmdb;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+
+import java.io.File;
+import java.nio.ByteBuffer;
+
+import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Focused tests that directly exercise TripleStore.TripleIndex#toKey to provide coverage for behavior-neutral
+ * optimizations such as internal key encoding caching.
+ */
+class TripleIndexToKeyCacheTest {
+
+ private TripleStore tripleStore;
+
+ @BeforeEach
+ void setup(@TempDir File dataDir) throws Exception {
+ // Create a small store; index set is irrelevant for constructing standalone TripleIndex instances
+ tripleStore = new TripleStore(dataDir, new LmdbStoreConfig("spoc,posc"), null);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (tripleStore != null) {
+ tripleStore.close();
+ }
+ }
+
+ @Test
+ void spoc_subjectBound_othersWildcard() throws Exception {
+ // Given: only subject is bound, others are wildcards (Long.MAX_VALUE)
+ long subj = 123L;
+ long pred = Long.MAX_VALUE;
+ long obj = Long.MAX_VALUE;
+ long context = Long.MAX_VALUE;
+
+ TripleStore.TripleIndex index = tripleStore.new TripleIndex("spoc");
+
+ int len = Varint.calcListLengthUnsigned(subj, pred, obj, context);
+ ByteBuffer actual = ByteBuffer.allocate(len);
+ index.toKey(actual, subj, pred, obj, context);
+ actual.flip();
+
+ // Expected: varints in spoc order
+ ByteBuffer expected = ByteBuffer.allocate(len);
+ Varint.writeUnsigned(expected, subj);
+ Varint.writeUnsigned(expected, pred);
+ Varint.writeUnsigned(expected, obj);
+ Varint.writeUnsigned(expected, context);
+ expected.flip();
+
+ assertArrayEquals(expected.array(), actual.array());
+ }
+
+ @Test
+ void posc_predicateBound_othersWildcard() throws Exception {
+ // Given: only predicate is bound, others are wildcards (Long.MAX_VALUE)
+ long subj = Long.MAX_VALUE;
+ long pred = 456L;
+ long obj = Long.MAX_VALUE;
+ long context = Long.MAX_VALUE;
+
+ TripleStore.TripleIndex index = tripleStore.new TripleIndex("posc");
+
+ int len = Varint.calcListLengthUnsigned(subj, pred, obj, context);
+ ByteBuffer actual = ByteBuffer.allocate(len);
+ index.toKey(actual, subj, pred, obj, context);
+ actual.flip();
+
+ // Expected: varints in posc order
+ ByteBuffer expected = ByteBuffer.allocate(len);
+ Varint.writeUnsigned(expected, pred);
+ Varint.writeUnsigned(expected, obj);
+ Varint.writeUnsigned(expected, subj);
+ Varint.writeUnsigned(expected, context);
+ expected.flip();
+
+ assertArrayEquals(expected.array(), actual.array());
+ }
+}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreAutoGrowTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreAutoGrowTest.java
index 04c25dee20e..afcfc5e64b0 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreAutoGrowTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreAutoGrowTest.java
@@ -34,7 +34,7 @@ public class TripleStoreAutoGrowTest {
public void before(@TempDir File dataDir) throws Exception {
var config = new LmdbStoreConfig("spoc,posc");
config.setTripleDBSize(4096 * 10);
- tripleStore = new TripleStore(dataDir, config);
+ tripleStore = new TripleStore(dataDir, config, null);
((Logger) LoggerFactory
.getLogger(TripleStore.class.getName()))
.setLevel(ch.qos.logback.classic.Level.DEBUG);
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreManyIndexesTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreManyIndexesTest.java
index 7d027cefc13..f6e7ca850a9 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreManyIndexesTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreManyIndexesTest.java
@@ -10,7 +10,7 @@
*******************************************************************************/
package org.eclipse.rdf4j.sail.lmdb;
-import static org.junit.Assert.*;
+import static org.junit.Assert.assertNotNull;
import java.io.File;
@@ -33,7 +33,7 @@ public void before(@TempDir File dataDir) throws Exception {
@Test
public void testSixIndexes() throws Exception {
TripleStore tripleStore = new TripleStore(dataDir,
- new LmdbStoreConfig("spoc,posc,ospc,cspo,cpos,cosp"));
+ new LmdbStoreConfig("spoc,posc,ospc,cspo,cpos,cosp"), null);
tripleStore.startTransaction();
tripleStore.storeTriple(1, 2, 3, 1, true);
tripleStore.commit();
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreTest.java
index 32e20f2e766..336c22b9378 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/TripleStoreTest.java
@@ -11,7 +11,6 @@
package org.eclipse.rdf4j.sail.lmdb;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
import java.io.File;
import java.util.Arrays;
@@ -34,7 +33,7 @@ public class TripleStoreTest {
@BeforeEach
public void before(@TempDir File dataDir) throws Exception {
- tripleStore = new TripleStore(dataDir, new LmdbStoreConfig("spoc,posc"));
+ tripleStore = new TripleStore(dataDir, new LmdbStoreConfig("spoc,posc"), null);
}
int count(RecordIterator it) {
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreCacheTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreCacheTest.java
new file mode 100644
index 00000000000..a3bc9787345
--- /dev/null
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreCacheTest.java
@@ -0,0 +1,52 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.sail.lmdb;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertSame;
+
+import java.io.File;
+
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.sail.lmdb.model.LmdbValue;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Focused test to prove that the ValueStore cache path is exercised by existing operations.
+ */
+class ValueStoreCacheTest {
+
+ @Test
+ void cachedValuePath(@TempDir File dataDir) throws Exception {
+ LmdbStore store = new LmdbStore(dataDir);
+ store.init();
+ try {
+ ValueFactory vf = store.getValueFactory();
+ // ValueFactory is actually the package-private ValueStore
+ ValueStore vs = (ValueStore) vf;
+
+ IRI iri = vf.createIRI("urn:example:foo");
+ long id = vs.getId(iri, true);
+
+ // Populate cache via lazy retrieval
+ LmdbValue v1 = vs.getLazyValue(id);
+ // Direct cache hit
+ LmdbValue v2 = vs.cachedValue(id);
+
+ assertNotNull(v1);
+ assertSame(v1, v2);
+ } finally {
+ store.shutDown();
+ }
+ }
+}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreNamespaceCacheTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreNamespaceCacheTest.java
new file mode 100644
index 00000000000..dabb2942c70
--- /dev/null
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreNamespaceCacheTest.java
@@ -0,0 +1,83 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.sail.lmdb;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.File;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Field;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ValueStoreNamespaceCacheTest {
+
+ @Test
+ void getNamespaceUsesLastResult(@TempDir File dataDir) throws Throwable {
+ ValueStore valueStore = new ValueStore(new File(dataDir, "values"), new LmdbStoreConfig());
+ try {
+ MethodHandles.Lookup privateLookup = MethodHandles.privateLookupIn(ValueStore.class,
+ MethodHandles.lookup());
+ TestConcurrentCache cache = new TestConcurrentCache(32);
+ Field namespaceCacheField = ValueStore.class.getDeclaredField("namespaceCache");
+ namespaceCacheField.setAccessible(true);
+ namespaceCacheField.set(valueStore, cache);
+
+ MethodHandle getNamespace = privateLookup.findVirtual(ValueStore.class, "getNamespace",
+ MethodType.methodType(String.class, long.class));
+
+ String namespace = "http://example.com/";
+ long id = 123L;
+ cache.put(id, namespace);
+ String first = (String) getNamespace.invoke(valueStore, id);
+ assertEquals(namespace, first);
+ cache.failOnFurtherGets();
+
+ String second = (String) getNamespace.invoke(valueStore, id);
+ assertEquals(namespace, second);
+ assertEquals(1, cache.getInvocations());
+ } finally {
+ valueStore.close();
+ }
+ }
+
+ private static final class TestConcurrentCache extends ConcurrentCache {
+
+ private final AtomicInteger invocations = new AtomicInteger();
+ private volatile boolean failOnFurtherGets;
+
+ private TestConcurrentCache(int capacity) {
+ super(capacity);
+ }
+
+ @Override
+ public String get(Object key) {
+ int count = invocations.incrementAndGet();
+ if (failOnFurtherGets && count > 1) {
+ throw new AssertionError("namespaceCache#get must not be invoked after caching last namespace");
+ }
+ return super.get(key);
+ }
+
+ private void failOnFurtherGets() {
+ failOnFurtherGets = true;
+ }
+
+ private int getInvocations() {
+ return invocations.get();
+ }
+ }
+}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreTest.java
index 724ed3102fa..cdfef66f530 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/ValueStoreTest.java
@@ -230,6 +230,54 @@ public void testGcURIs() throws Exception {
}
}
+ @Test
+ public void testDisableGc() throws Exception {
+ final Random random = new Random(1337);
+ final LmdbValue values[] = new LmdbValue[1000];
+
+ final ValueStore valueStore = new ValueStore(
+ new File(dataDir, "values"), new LmdbStoreConfig().setValueEvictionInterval(-1));
+
+ valueStore.startTransaction(true);
+ for (int i = 0; i < values.length; i++) {
+ values[i] = valueStore.createLiteral("This is a random literal:" + random.nextLong());
+ valueStore.storeValue(values[i]);
+ }
+ valueStore.commit();
+
+ final ValueStoreRevision revBefore = valueStore.getRevision();
+
+ valueStore.startTransaction(true);
+ Set ids = new HashSet<>();
+ for (int i = 0; i < 30; i++) {
+ ids.add(values[i].getInternalID());
+ }
+ valueStore.gcIds(ids, new HashSet<>());
+ valueStore.commit();
+
+ final ValueStoreRevision revAfter = valueStore.getRevision();
+
+ assertEquals("revisions must NOT change since GC is disabled", revBefore, revAfter);
+
+ Arrays.fill(values, null);
+ valueStore.unusedRevisionIds.add(revBefore.getRevisionId());
+
+ valueStore.forceEvictionOfValues();
+ valueStore.startTransaction(true);
+ valueStore.commit();
+
+ valueStore.startTransaction(true);
+ for (int i = 0; i < 30; i++) {
+ LmdbValue value = valueStore.createLiteral("This is a random literal:" + random.nextLong());
+ values[i] = value;
+ valueStore.storeValue(value);
+ ids.remove(value.getInternalID());
+ }
+ valueStore.commit();
+
+ assertNotEquals("IDs should NOT have been reused since GC is disabled", Collections.emptySet(), ids);
+ }
+
@AfterEach
public void after() throws Exception {
valueStore.close();
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/VarintTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/VarintTest.java
index 79cdf1b293d..fef3bd67313 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/VarintTest.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/VarintTest.java
@@ -14,6 +14,7 @@
import static org.junit.Assert.assertEquals;
import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
import org.junit.jupiter.api.Test;
@@ -26,7 +27,7 @@ public class VarintTest {
@Test
public void testVarint() {
- ByteBuffer bb = ByteBuffer.allocate(9);
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
for (int i = 0; i < values.length; i++) {
bb.clear();
Varint.writeUnsigned(bb, values[i]);
@@ -36,9 +37,94 @@ public void testVarint() {
}
}
+ @Test
+ public void testVarint2() {
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
+ bb.clear();
+ Varint.writeUnsigned(bb, values[1]);
+ bb.flip();
+ assertEquals("Encoding should use " + (2) + " bytes", 2, bb.remaining());
+ assertEquals("Encoded and decoded value should be equal", values[1], Varint.readUnsigned(bb));
+
+ }
+
+ @Test
+ public void testVarint3() {
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
+ bb.clear();
+ Varint.writeUnsigned(bb, 67823);
+ bb.flip();
+ assertEquals("Encoded and decoded value should be equal", 67823, Varint.readUnsigned(bb));
+
+ }
+
+ @Test
+ public void testVarint4() {
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
+ bb.clear();
+ Varint.writeUnsigned(bb, 67824);
+ bb.flip();
+ assertEquals("Encoded and decoded value should be equal", 67824, Varint.readUnsigned(bb));
+
+ }
+
+ @Test
+ public void testVarint5() {
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
+ bb.clear();
+ Varint.writeUnsigned(bb, 4299999999L);
+ bb.flip();
+ assertEquals("Encoded and decoded value should be equal", 4299999999L, Varint.readUnsigned(bb));
+
+ }
+
+ @Test
+ public void testVarintSequential() {
+ for (long i = 0; i < 99999999; i++) {
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
+ bb.clear();
+ Varint.writeUnsigned(bb, i);
+ bb.flip();
+ try {
+ assertEquals("Encoded and decoded value should be equal", i, Varint.readUnsigned(bb));
+ } catch (Exception e) {
+ System.err.println("Failed for i=" + i);
+ throw e;
+ }
+ }
+
+ for (long i = 99999999; i < 999999999999999L; i += 10000000) {
+ try {
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
+ bb.clear();
+ Varint.writeUnsigned(bb, i);
+ bb.flip();
+
+ assertEquals("Encoded and decoded value should be equal", i, Varint.readUnsigned(bb));
+ } catch (Exception e) {
+ System.err.println("Failed for i=" + i);
+ throw e;
+ }
+ }
+
+ for (long i = Long.MAX_VALUE; i > Long.MAX_VALUE - 999999L; i -= 1) {
+ ByteBuffer bb = ByteBuffer.allocate(9).order(ByteOrder.nativeOrder());
+ bb.clear();
+ Varint.writeUnsigned(bb, i);
+ bb.flip();
+ try {
+ assertEquals("Encoded and decoded value should be equal", i, Varint.readUnsigned(bb));
+ } catch (Exception e) {
+ System.err.println("Failed for i=" + i);
+ throw e;
+ }
+ }
+
+ }
+
@Test
public void testVarintList() {
- ByteBuffer bb = ByteBuffer.allocate(2 + 4 * Long.BYTES);
+ ByteBuffer bb = ByteBuffer.allocate(2 + 4 * Long.BYTES).order(ByteOrder.nativeOrder());
for (int i = 0; i < values.length - 4; i++) {
long[] expected = new long[4];
System.arraycopy(values, 0, expected, 0, 4);
@@ -50,4 +136,16 @@ public void testVarintList() {
assertArrayEquals("Encoded and decoded value should be equal", expected, actual);
}
}
+
+ @Test
+ public void testVarintReadUnsignedAtPositionThreeByteEncoding() {
+ long value = 3000L;
+ ByteBuffer bb = ByteBuffer.allocate(Varint.calcLengthUnsigned(value))
+ .order(ByteOrder.nativeOrder());
+ Varint.writeUnsigned(bb, value);
+ bb.flip();
+ assertEquals("Expected three byte encoding", 3, bb.remaining());
+ long decoded = Varint.readUnsigned(bb, 0);
+ assertEquals("Encoded and decoded value using positional read should match", value, decoded);
+ }
}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/BenchmarkBaseFoaf.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/BenchmarkBaseFoaf.java
index f9223ff12ec..349413d7248 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/BenchmarkBaseFoaf.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/BenchmarkBaseFoaf.java
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2022 Eclipse RDF4J contributors.
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Distribution License v1.0
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/OverflowBenchmarkConcurrent.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/OverflowBenchmarkConcurrent.java
index eef34f93d1c..0544ef7b970 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/OverflowBenchmarkConcurrent.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/OverflowBenchmarkConcurrent.java
@@ -27,7 +27,6 @@
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
-import org.apache.commons.io.FileUtils;
import org.assertj.core.util.Files;
import org.eclipse.rdf4j.common.io.FileUtil;
import org.eclipse.rdf4j.model.IRI;
@@ -59,10 +58,7 @@
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;
-import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
-import org.openjdk.jmh.runner.options.Options;
-import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.slf4j.LoggerFactory;
import ch.qos.logback.classic.Logger;
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmark.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmark.java
index d4a93a3060e..3a9c88ad840 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmark.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmark.java
@@ -52,7 +52,7 @@
@Warmup(iterations = 5)
@BenchmarkMode({ Mode.AverageTime })
@Fork(value = 1, jvmArgs = { "-Xms1G", "-Xmx1G" })
-//@Fork(value = 1, jvmArgs = {"-Xms1G", "-Xmx1G", "-XX:StartFlightRecording=delay=60s,duration=120s,filename=recording.jfr,settings=profile", "-XX:FlightRecorderOptions=samplethreads=true,stackdepth=1024", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints"})
+//@Fork(value = 1, jvmArgs = {"-Xms1G", "-Xmx1G", "-XX:StartFlightRecording=jdk.CPUTimeSample#enabled=true,filename=profile.jfr,method-profiling=max","-XX:FlightRecorderOptions=stackdepth=1024", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints"})
@Measurement(iterations = 5)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class QueryBenchmark {
@@ -126,8 +126,8 @@ public class QueryBenchmark {
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
- .include("QueryBenchmark.*") // adapt to run other benchmark tests
- .forks(1)
+ .include("QueryBenchmark.ordered_union_limit") // adapt to run other benchmark tests
+ .forks(0)
.build();
new Runner(opt).run();
@@ -160,6 +160,92 @@ private static long count(TupleQueryResult evaluate) {
}
}
+ // @Benchmark
+ public long allBenchmarks() {
+ long ret = 0;
+ long result;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result = count(connection
+ .prepareTupleQuery(query1)
+ .evaluate());
+ }
+ ret += result;
+ long result1;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result1 = count(connection
+ .prepareTupleQuery(query4)
+ .evaluate()
+ );
+ }
+ ret += result1;
+ long result2;
+
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result2 = count(connection
+ .prepareTupleQuery(query7_pathexpression1)
+ .evaluate());
+
+ }
+ ret += result2;
+ long result3;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result3 = count(connection
+ .prepareTupleQuery(query8_pathexpression2)
+ .evaluate());
+ }
+ ret += result3;
+ long result4;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result4 = count(connection
+ .prepareTupleQuery(different_datasets_with_similar_distributions)
+ .evaluate());
+ }
+ ret += result4;
+ long result5;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result5 = count(connection
+ .prepareTupleQuery(long_chain)
+ .evaluate());
+ }
+ ret += result5;
+ long result6;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result6 = count(connection
+ .prepareTupleQuery(lots_of_optional)
+ .evaluate());
+ }
+ ret += result6;
+ long result7;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result7 = count(connection
+ .prepareTupleQuery(minus)
+ .evaluate());
+ }
+ ret += result7;
+ long result8;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result8 = count(connection
+ .prepareTupleQuery(nested_optionals)
+ .evaluate());
+ }
+ ret += result8;
+ long result9;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result9 = count(connection
+ .prepareTupleQuery(query_distinct_predicates)
+ .evaluate());
+ }
+ ret += result9;
+ long result10;
+ try (SailRepositoryConnection connection = repository.getConnection()) {
+ result10 = count(connection
+ .prepareTupleQuery(simple_filter_not)
+ .evaluate());
+ }
+ ret += result10;
+ return ret;
+ }
+
@Benchmark
public long groupByQuery() {
try (SailRepositoryConnection connection = repository.getConnection()) {
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmarkFoaf.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmarkFoaf.java
index eedfe2ceb96..a217cdc575d 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmarkFoaf.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/QueryBenchmarkFoaf.java
@@ -40,7 +40,7 @@
* Benchmarks query performance with extended FOAF data.
*/
@State(Scope.Benchmark)
-@Warmup(iterations = 2)
+@Warmup(iterations = 5)
@BenchmarkMode({ Mode.AverageTime })
@Fork(value = 1, jvmArgs = { "-Xms2G", "-Xmx2G", "-Xmn1G", "-XX:+UseSerialGC" })
@Measurement(iterations = 5)
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/RandomLiteralGenerator.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/RandomLiteralGenerator.java
new file mode 100644
index 00000000000..80dfe5bb478
--- /dev/null
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/RandomLiteralGenerator.java
@@ -0,0 +1,119 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.sail.lmdb.benchmark;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+import org.eclipse.rdf4j.model.Literal;
+import org.eclipse.rdf4j.model.ValueFactory;
+import org.eclipse.rdf4j.model.vocabulary.XSD;
+
+/**
+ * A utility class for generating random RDF literals using a variety of data types, including numeric types (integer,
+ * float, double, etc.), boolean, string, and date/time types.
+ *
+ * This class is primarily useful for testing and demonstration purposes where randomized literal values are needed.
+ */
+public class RandomLiteralGenerator {
+
+ /**
+ * The {@link ValueFactory} used to create RDF literals.
+ */
+ private final ValueFactory vf;
+
+ /**
+ * The {@link Random} instance used to generate random values.
+ */
+ private final Random random;
+
+ /**
+ * A list of suppliers, each of which produces a different type of RDF literal.
+ */
+ private List> literalSuppliers;
+
+ /**
+ * Constructs a new {@code RandomLiteralGenerator} with the specified {@link ValueFactory} and {@link Random}
+ * instances.
+ *
+ * @param vf the value factory used to create RDF literals
+ * @param random the random generator used to generate random values
+ */
+ public RandomLiteralGenerator(ValueFactory vf, Random random) {
+ this.vf = vf;
+ this.random = random;
+ init();
+ }
+
+ /**
+ * Initializes the list of literal suppliers with a variety of data types. Includes decimals, doubles, floats,
+ * integers, booleans, strings, unsigned values, and date/time literals.
+ */
+ private void init() {
+ literalSuppliers = Arrays.asList(
+ // Decimal
+ () -> vf.createLiteral(BigDecimal.valueOf(random.nextDouble() * 100000 - 50000)),
+ // Double
+ () -> vf.createLiteral(random.nextBoolean() ? Double.NaN : random.nextDouble() * 1000),
+ () -> vf.createLiteral(random.nextBoolean() ? Double.POSITIVE_INFINITY : Double.NEGATIVE_INFINITY),
+ () -> vf.createLiteral((double) random.nextInt(1000)),
+ // Float
+ () -> vf.createLiteral(random.nextBoolean() ? Float.NaN : random.nextFloat() * 100),
+ () -> vf.createLiteral(random.nextBoolean() ? Float.POSITIVE_INFINITY : Float.NEGATIVE_INFINITY),
+ // Integer
+ () -> vf.createLiteral(BigInteger.valueOf(random.nextInt(1_000_000) - 500_000)),
+ () -> vf.createLiteral(random.nextInt(1_000_000) - 500_000),
+ // Long
+ () -> vf.createLiteral(random.nextLong()),
+ // Short
+ () -> vf.createLiteral((short) (random.nextInt(Short.MAX_VALUE - Short.MIN_VALUE) + Short.MIN_VALUE)),
+ // Byte
+ () -> vf.createLiteral((byte) (random.nextInt(Byte.MAX_VALUE - Byte.MIN_VALUE) + Byte.MIN_VALUE)),
+ // Unsigned Int types
+ () -> vf.createLiteral(String.valueOf(random.nextInt(1 << 16)), XSD.UNSIGNED_SHORT),
+ () -> vf.createLiteral(String.valueOf(random.nextInt(1 << 8)), XSD.UNSIGNED_BYTE),
+ () -> vf.createLiteral(String.valueOf(random.nextInt(100000)), XSD.UNSIGNED_INT),
+ // Positive/Negative Integer
+ () -> vf.createLiteral(String.valueOf(1 + random.nextInt(1_000_000)), XSD.POSITIVE_INTEGER),
+ () -> vf.createLiteral("-" + (1 + random.nextInt(1_000_000)), XSD.NEGATIVE_INTEGER),
+ // Non-negative/Non-positive
+ () -> vf.createLiteral(String.valueOf(random.nextInt(1_000_000)), XSD.NON_NEGATIVE_INTEGER),
+ () -> vf.createLiteral("-" + random.nextInt(1_000_000), XSD.NON_POSITIVE_INTEGER),
+ // String
+ () -> vf.createLiteral(UUID.randomUUID().toString().substring(0, 8), XSD.STRING),
+ () -> vf.createLiteral("testString" + random.nextInt(100), XSD.STRING),
+ // Boolean
+ () -> vf.createLiteral(random.nextBoolean()),
+ // Date and DateTime
+ () -> vf.createLiteral(
+ LocalDate.of(1970 + random.nextInt(100), 1 + random.nextInt(12), 1 + random.nextInt(28))),
+ () -> vf.createLiteral(
+ LocalDateTime.of(1970 + random.nextInt(100), 1 + random.nextInt(12), 1 + random.nextInt(28),
+ random.nextInt(24), random.nextInt(60), random.nextInt(60)))
+ );
+ }
+
+ /**
+ * Generates a random RDF literal.
+ *
+ * @return a randomly selected RDF literal
+ */
+ public Literal createRandomLiteral() {
+ return literalSuppliers.get(random.nextInt(literalSuppliers.size())).get();
+ }
+}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmark.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmark.java
index e6f6ac5d911..dd20a7ceac4 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmark.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmark.java
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2021 Eclipse RDF4J contributors.
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Distribution License v1.0
@@ -13,12 +13,15 @@
import java.io.File;
import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
import java.util.concurrent.TimeUnit;
import org.apache.commons.io.FileUtils;
import org.assertj.core.util.Files;
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
-import org.eclipse.rdf4j.model.vocabulary.RDFS;
+import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.repository.sail.SailRepository;
import org.eclipse.rdf4j.repository.sail.SailRepositoryConnection;
import org.eclipse.rdf4j.sail.lmdb.LmdbStore;
@@ -46,20 +49,23 @@
@Warmup(iterations = 2)
@BenchmarkMode({ Mode.Throughput })
@Fork(value = 1, jvmArgs = { "-Xms2G", "-Xmx2G", "-XX:+UseG1GC" })
-//@Fork(value = 1, jvmArgs = {"-Xms8G", "-Xmx8G", "-XX:+UseG1GC", "-XX:+UnlockCommercialFeatures", "-XX:StartFlightRecording=delay=60s,duration=120s,filename=recording.jfr,settings=profile", "-XX:FlightRecorderOptions=samplethreads=true,stackdepth=1024", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints"})
-@Measurement(iterations = 5)
+@Measurement(iterations = 3)
@OutputTimeUnit(TimeUnit.SECONDS)
public class TransactionsPerSecondBenchmark {
- private SailRepository repository;
- private File file;
-
SailRepositoryConnection connection;
+ RandomLiteralGenerator literalGenerator;
+ Random random;
int i;
+ List resources;
+ List predicates;
+ protected SailRepository repository;
+ protected File file;
+ protected boolean forceSync = false;
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
- .include("TransactionsPerSecondBenchmark") // adapt to control which benchmark tests to run
+ .include("TransactionsPerSecondBenchmark\\.") // adapt to control which benchmarks to run
.forks(1)
.build();
@@ -75,12 +81,29 @@ public void beforeClass() {
i = 0;
file = Files.newTemporaryFolder();
- LmdbStore sail = new LmdbStore(file, ConfigUtil.createConfig());
+ LmdbStore sail = new LmdbStore(file, ConfigUtil.createConfig().setForceSync(forceSync));
repository = new SailRepository(sail);
connection = repository.getConnection();
+ random = new Random(1337);
+ literalGenerator = new RandomLiteralGenerator(connection.getValueFactory(), random);
+ resources = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ resources.add(connection.getValueFactory().createIRI("some:resource-" + i));
+ }
+ predicates = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ predicates.add(connection.getValueFactory().createIRI("some:predicate-" + i));
+ }
System.gc();
+ }
+
+ IRI randomResource() {
+ return resources.get(random.nextInt(resources.size()));
+ }
+ IRI randomPredicate() {
+ return predicates.get(random.nextInt(predicates.size()));
}
@TearDown(Level.Iteration)
@@ -97,14 +120,14 @@ public void afterClass() throws IOException {
@Benchmark
public void transactions() {
connection.begin();
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++));
+ connection.add(randomResource(), randomPredicate(), literalGenerator.createRandomLiteral());
connection.commit();
}
@Benchmark
public void transactionsLevelNone() {
connection.begin(IsolationLevels.NONE);
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++));
+ connection.add(randomResource(), randomPredicate(), literalGenerator.createRandomLiteral());
connection.commit();
}
@@ -112,7 +135,7 @@ public void transactionsLevelNone() {
public void mediumTransactionsLevelNone() {
connection.begin(IsolationLevels.NONE);
for (int k = 0; k < 10; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
+ connection.add(randomResource(), randomPredicate(), literalGenerator.createRandomLiteral());
}
connection.commit();
}
@@ -121,7 +144,7 @@ public void mediumTransactionsLevelNone() {
public void largerTransaction() {
connection.begin();
for (int k = 0; k < 10000; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
+ connection.add(randomResource(), randomPredicate(), literalGenerator.createRandomLiteral());
}
connection.commit();
}
@@ -130,7 +153,7 @@ public void largerTransaction() {
public void largerTransactionLevelNone() {
connection.begin(IsolationLevels.NONE);
for (int k = 0; k < 10000; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
+ connection.add(randomResource(), randomPredicate(), literalGenerator.createRandomLiteral());
}
connection.commit();
}
@@ -139,7 +162,7 @@ public void largerTransactionLevelNone() {
public void veryLargerTransactionLevelNone() {
connection.begin(IsolationLevels.NONE);
for (int k = 0; k < 1000000; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
+ connection.add(randomResource(), randomPredicate(), literalGenerator.createRandomLiteral());
}
connection.commit();
}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmarkFoaf.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmarkFoaf.java
index 8472095cf2f..a452e3c7202 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmarkFoaf.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondBenchmarkFoaf.java
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2022 Eclipse RDF4J contributors.
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Distribution License v1.0
@@ -33,19 +33,19 @@
import org.openjdk.jmh.runner.options.OptionsBuilder;
/**
- * Benchmarks insertion performance with extended FOAF data.
+ * Benchmarks insertion performance with synthetic FOAF data.
*/
@State(Scope.Benchmark)
@Warmup(iterations = 2)
@BenchmarkMode({ Mode.Throughput })
@Fork(value = 1, jvmArgs = { "-Xms2G", "-Xmx2G", "-XX:+UseG1GC" })
-@Measurement(iterations = 5)
+@Measurement(iterations = 3)
@OutputTimeUnit(TimeUnit.SECONDS)
public class TransactionsPerSecondBenchmarkFoaf extends BenchmarkBaseFoaf {
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
- .include("TransactionsPerSecondBenchmarkFoaf") // adapt to control which benchmark tests to run
+ .include("TransactionsPerSecondBenchmarkFoaf\\.") // adapt to control which benchmark tests to run
.forks(1)
.build();
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondForceSyncBenchmark.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondForceSyncBenchmark.java
index 1ff2dc72549..94b2c42e09e 100644
--- a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondForceSyncBenchmark.java
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/benchmark/TransactionsPerSecondForceSyncBenchmark.java
@@ -43,104 +43,23 @@
* Benchmarks insertion performance with synthetic data.
*/
@State(Scope.Benchmark)
-@Warmup(iterations = 20)
+@Warmup(iterations = 2)
@BenchmarkMode({ Mode.Throughput })
@Fork(value = 1, jvmArgs = { "-Xms2G", "-Xmx2G", "-XX:+UseG1GC" })
-//@Fork(value = 1, jvmArgs = {"-Xms8G", "-Xmx8G", "-XX:+UseG1GC", "-XX:+UnlockCommercialFeatures", "-XX:StartFlightRecording=delay=60s,duration=120s,filename=recording.jfr,settings=profile", "-XX:FlightRecorderOptions=samplethreads=true,stackdepth=1024", "-XX:+UnlockDiagnosticVMOptions", "-XX:+DebugNonSafepoints"})
-@Measurement(iterations = 10)
+@Measurement(iterations = 3)
@OutputTimeUnit(TimeUnit.SECONDS)
-public class TransactionsPerSecondForceSyncBenchmark {
-
- private SailRepository repository;
- private File file;
-
- SailRepositoryConnection connection;
- int i;
+public class TransactionsPerSecondForceSyncBenchmark extends TransactionsPerSecondBenchmark {
+ {
+ // enforce syncing to disk
+ forceSync = true;
+ }
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
- .include("TransactionsPerSecondForceSyncBenchmark") // adapt to control which benchmark tests to run
+ .include("TransactionsPerSecondForceSyncBenchmark\\.") // adapt to control which benchmarks to run
.forks(1)
.build();
new Runner(opt).run();
}
-
- @Setup(Level.Iteration)
- public void beforeClass() {
- if (connection != null) {
- connection.close();
- connection = null;
- }
- i = 0;
- file = Files.newTemporaryFolder();
-
- LmdbStore sail = new LmdbStore(file, ConfigUtil.createConfig().setForceSync(true));
- repository = new SailRepository(sail);
- connection = repository.getConnection();
-
- System.gc();
-
- }
-
- @TearDown(Level.Iteration)
- public void afterClass() throws IOException {
- if (connection != null) {
- connection.close();
- connection = null;
- }
- repository.shutDown();
- FileUtils.deleteDirectory(file);
-
- }
-
- @Benchmark
- public void transactions() {
- connection.begin();
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++));
- connection.commit();
- }
-
- @Benchmark
- public void transactionsLevelNone() {
- connection.begin(IsolationLevels.NONE);
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++));
- connection.commit();
- }
-
- @Benchmark
- public void mediumTransactionsLevelNone() {
- connection.begin(IsolationLevels.NONE);
- for (int k = 0; k < 10; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
- }
- connection.commit();
- }
-
- @Benchmark
- public void largerTransaction() {
- connection.begin();
- for (int k = 0; k < 10000; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
- }
- connection.commit();
- }
-
- @Benchmark
- public void largerTransactionLevelNone() {
- connection.begin(IsolationLevels.NONE);
- for (int k = 0; k < 10000; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
- }
- connection.commit();
- }
-
- @Benchmark
- public void veryLargerTransactionLevelNone() {
- connection.begin(IsolationLevels.NONE);
- for (int k = 0; k < 1000000; k++) {
- connection.add(RDFS.RESOURCE, RDFS.LABEL, connection.getValueFactory().createLiteral(i++ + "_" + k));
- }
- connection.commit();
- }
}
diff --git a/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreConfigTest.java b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreConfigTest.java
new file mode 100644
index 00000000000..6bffee78436
--- /dev/null
+++ b/core/sail/lmdb/src/test/java/org/eclipse/rdf4j/sail/lmdb/config/LmdbStoreConfigTest.java
@@ -0,0 +1,103 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Eclipse RDF4J contributors.
+ *
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Distribution License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/org/documents/edl-v10.php.
+ *
+ * SPDX-License-Identifier: BSD-3-Clause
+ *******************************************************************************/
+package org.eclipse.rdf4j.sail.lmdb.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.eclipse.rdf4j.model.util.Values.bnode;
+import static org.eclipse.rdf4j.sail.lmdb.config.LmdbStoreConfig.VALUE_CACHE_SIZE;
+
+import org.eclipse.rdf4j.model.BNode;
+import org.eclipse.rdf4j.model.IRI;
+import org.eclipse.rdf4j.model.Literal;
+import org.eclipse.rdf4j.model.Model;
+import org.eclipse.rdf4j.model.Resource;
+import org.eclipse.rdf4j.model.impl.LinkedHashModel;
+import org.eclipse.rdf4j.model.util.ModelBuilder;
+import org.eclipse.rdf4j.model.util.Values;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class LmdbStoreConfigTest {
+
+ @ParameterizedTest
+ @ValueSource(longs = { 1, 205454, 0, -1231 })
+ void testThatLmdbStoreConfigParseAndExportValueEvictionInterval(final long valueEvictionInterval) {
+ testParseAndExport(
+ LmdbStoreSchema.VALUE_EVICTION_INTERVAL,
+ Values.literal(valueEvictionInterval),
+ LmdbStoreConfig::getValueEvictionInterval,
+ valueEvictionInterval,
+ true
+ );
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = { true, false })
+ void testThatLmdbStoreConfigParseAndExportAutoGrow(final boolean autoGrow) {
+ testParseAndExport(
+ LmdbStoreSchema.AUTO_GROW,
+ Values.literal(autoGrow),
+ LmdbStoreConfig::getAutoGrow,
+ autoGrow,
+ !autoGrow
+ );
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = { 1, 205454, 0, -1231 })
+ void testThatLmdbStoreConfigParseAndExportValueCacheSize(final int valueCacheSize) {
+ testParseAndExport(
+ LmdbStoreSchema.VALUE_CACHE_SIZE,
+ Values.literal(valueCacheSize >= 0 ? valueCacheSize : VALUE_CACHE_SIZE),
+ LmdbStoreConfig::getValueCacheSize,
+ valueCacheSize >= 0 ? valueCacheSize : VALUE_CACHE_SIZE,
+ true
+ );
+ }
+
+ // TODO: Add more tests for other properties
+
+ /**
+ * Generic method to test parsing and exporting of config properties.
+ *
+ * @param property The schema property to test
+ * @param value The literal value to use in the test
+ * @param getter Function to get the value from the config object
+ * @param expectedValue The expected value after parsing
+ * @param expectedContains The expected result of the contains check
+ * @param The type of the value being tested
+ */
+ private void testParseAndExport(
+ IRI property,
+ Literal value,
+ java.util.function.Function getter,
+ T expectedValue,
+ boolean expectedContains
+ ) {
+ final BNode implNode = bnode();
+ final LmdbStoreConfig lmdbStoreConfig = new LmdbStoreConfig();
+ final Model configModel = new ModelBuilder()
+ .add(implNode, property, value)
+ .build();
+
+ // Parse the config
+ lmdbStoreConfig.parse(configModel, implNode);
+ assertThat(getter.apply(lmdbStoreConfig)).isEqualTo(expectedValue);
+
+ // Export the config
+ final Model exportedModel = new LinkedHashModel();
+ final Resource exportImplNode = lmdbStoreConfig.export(exportedModel);
+
+ // Verify the export
+ assertThat(exportedModel.contains(exportImplNode, property, value))
+ .isEqualTo(expectedContains);
+ }
+}
\ No newline at end of file
diff --git a/core/sail/lucene-api/pom.xml b/core/sail/lucene-api/pom.xml
index 14cd1b73a1c..3fb152fa5f1 100644
--- a/core/sail/lucene-api/pom.xml
+++ b/core/sail/lucene-api/pom.xml
@@ -4,7 +4,7 @@