From 7cde9a3a5785e1cfd67852747a16864b608aa0e0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?=
Date: Fri, 23 Oct 2026 16:16:16 +0200
Subject: [PATCH 1/2] refactor(query): enhance repository ergonomics, dataset
factories and contract javadoc (#643)
- Add Repository shortcuts: construct() alias, mapped select(), size(), hasStatement(), and add()
- Add Repositories convenience factories: createFrom(Path/Stream) and withMemory/computeWithMemory
- Add Dataset.of(IRI...) and collection-based builder methods
- Guarantee binding variable projection order in MaterializedResults
- Formalize stream lifecycle, thread confinement, and dataset override contracts in Javadoc
- Add comprehensive test coverage in RepositoriesTest, DatasetTest, and CoreseRepositoryTest
---
.../corese/core/next/query/Repositories.java | 86 +++++++++++++++++
.../core/next/query/api/dataset/Dataset.java | 38 ++++++++
.../next/query/api/dataset/package-info.java | 25 +++--
.../api/repository/MaterializedResults.java | 7 +-
.../next/query/api/repository/Repository.java | 95 +++++++++++++++++++
.../query/api/repository/package-info.java | 24 ++++-
.../query/api/result/TupleQueryResult.java | 5 +
.../next/query/api/result/package-info.java | 21 +++-
.../corese/core/next/query/package-info.java | 28 +++++-
.../core/next/query/RepositoriesTest.java | 36 +++++++
.../next/query/api/dataset/DatasetTest.java | 21 ++++
.../impl/repository/CoreseRepositoryTest.java | 55 +++++++++++
12 files changed, 423 insertions(+), 18 deletions(-)
diff --git a/src/main/java/fr/inria/corese/core/next/query/Repositories.java b/src/main/java/fr/inria/corese/core/next/query/Repositories.java
index f4734e4e1..b13d9c9eb 100644
--- a/src/main/java/fr/inria/corese/core/next/query/Repositories.java
+++ b/src/main/java/fr/inria/corese/core/next/query/Repositories.java
@@ -1,5 +1,6 @@
package fr.inria.corese.core.next.query;
+import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
import fr.inria.corese.core.next.query.api.exception.RepositoryException;
import fr.inria.corese.core.next.query.api.repository.Repository;
import fr.inria.corese.core.next.query.impl.repository.CoreseRepository;
@@ -8,7 +9,11 @@
import fr.inria.corese.core.next.storage.Storages;
import fr.inria.corese.core.next.storage.api.plugin.PluginException;
+import java.io.InputStream;
+import java.nio.file.Path;
import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Function;
/**
* Public entry point for creating ready-to-use Corese repositories.
@@ -82,4 +87,85 @@ public static Repository create(StorageManager storage, StorageConfig config) {
Objects.requireNonNull(storage, "storage"),
Objects.requireNonNull(config, "config"));
}
+
+ /**
+ * Creates an open in-memory repository pre-loaded with RDF statements from the specified file.
+ * The file format is inferred from the file extension.
+ *
+ * @param source RDF source file
+ * @return a ready-to-use repository containing the loaded data
+ * @throws RepositoryException if repository creation or RDF loading fails
+ * @throws NullPointerException if {@code source} is {@code null}
+ */
+ public static Repository createFrom(Path source) {
+ Objects.requireNonNull(source, "source");
+ Repository repository = create();
+ try {
+ repository.load(source);
+ return repository;
+ } catch (RuntimeException | Error e) {
+ try {
+ repository.close();
+ } catch (Exception closeEx) {
+ e.addSuppressed(closeEx);
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Creates an open in-memory repository pre-loaded with RDF statements from the specified input stream.
+ *
+ * @param source RDF input stream
+ * @param format explicit RDF serialization format
+ * @return a ready-to-use repository containing the loaded data
+ * @throws RepositoryException if repository creation or RDF loading fails
+ * @throws NullPointerException if {@code source} or {@code format} is {@code null}
+ */
+ public static Repository createFrom(InputStream source, RDFFormat format) {
+ Objects.requireNonNull(source, "source");
+ Objects.requireNonNull(format, "format");
+ Repository repository = create();
+ try {
+ repository.load(source, format);
+ return repository;
+ } catch (RuntimeException | Error e) {
+ try {
+ repository.close();
+ } catch (Exception closeEx) {
+ e.addSuppressed(closeEx);
+ }
+ throw e;
+ }
+ }
+
+ /**
+ * Executes work against a temporary in-memory repository, ensuring deterministic closure.
+ *
+ * @param consumer repository callback
+ * @throws RepositoryException if repository creation, execution, or closure fails
+ * @throws NullPointerException if {@code consumer} is {@code null}
+ */
+ public static void withMemory(Consumer super Repository> consumer) {
+ Objects.requireNonNull(consumer, "consumer");
+ try (Repository repository = create()) {
+ consumer.accept(repository);
+ }
+ }
+
+ /**
+ * Computes a result against a temporary in-memory repository, ensuring deterministic closure.
+ *
+ * @param function computation callback
+ * @param result type
+ * @return the computed value
+ * @throws RepositoryException if repository creation, execution, or closure fails
+ * @throws NullPointerException if {@code function} is {@code null}
+ */
+ public static T computeWithMemory(Function super Repository, ? extends T> function) {
+ Objects.requireNonNull(function, "function");
+ try (Repository repository = create()) {
+ return function.apply(repository);
+ }
+ }
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java b/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java
index 4cedbe3de..c3f16b184 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java
@@ -2,6 +2,7 @@
import fr.inria.corese.core.next.data.api.term.IRI;
+import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Objects;
@@ -38,6 +39,25 @@ public static Dataset empty() {
return EMPTY;
}
+ /**
+ * Creates an immutable dataset with the specified default graphs and no named graphs.
+ *
+ * @param defaultGraphs default graph IRIs
+ * @return an immutable Dataset instance
+ * @throws NullPointerException if {@code defaultGraphs} or any element is {@code null}
+ */
+ public static Dataset of(IRI... defaultGraphs) {
+ Objects.requireNonNull(defaultGraphs, "defaultGraphs");
+ if (defaultGraphs.length == 0) {
+ return empty();
+ }
+ Builder builder = builder();
+ for (IRI graph : defaultGraphs) {
+ builder.defaultGraph(graph);
+ }
+ return builder.build();
+ }
+
/** Returns a builder for an immutable dataset. */
public static Builder builder() {
return new Builder();
@@ -94,12 +114,30 @@ public Builder defaultGraph(IRI graph) {
return this;
}
+ /** Adds multiple default graphs. */
+ public Builder defaultGraphs(Collection graphs) {
+ Objects.requireNonNull(graphs, "graphs");
+ for (IRI graph : graphs) {
+ defaultGraph(graph);
+ }
+ return this;
+ }
+
/** Adds a named graph. */
public Builder namedGraph(IRI graph) {
namedGraphs.add(Objects.requireNonNull(graph, "graph"));
return this;
}
+ /** Adds multiple named graphs. */
+ public Builder namedGraphs(Collection graphs) {
+ Objects.requireNonNull(graphs, "graphs");
+ for (IRI graph : graphs) {
+ namedGraph(graph);
+ }
+ return this;
+ }
+
/** Builds an immutable snapshot. */
public Dataset build() {
if (defaultGraphs.isEmpty() && namedGraphs.isEmpty()) {
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/dataset/package-info.java b/src/main/java/fr/inria/corese/core/next/query/api/dataset/package-info.java
index ea78e9e73..235e92832 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/dataset/package-info.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/dataset/package-info.java
@@ -1,11 +1,22 @@
/**
- * Dataset abstraction for SPARQL query evaluation scope.
+ * Dataset abstraction and dataset override specifications for SPARQL query evaluation in Corese v5.
*
- * {@link fr.inria.corese.core.next.query.api.dataset.Dataset} models the FROM / FROM NAMED
- * graph sets that restrict which graphs are visible during query execution.
- * It can be applied at the connection level
- * ({@link fr.inria.corese.core.next.query.api.repository.RepositoryConnection#setDataset})
- * or overridden per individual query
- * ({@link fr.inria.corese.core.next.query.api.Query#setDataset}).
+ * SPARQL Dataset Semantics
+ * {@link fr.inria.corese.core.next.query.api.dataset.Dataset} models the {@code FROM} (default graph)
+ * and {@code FROM NAMED} (named graph) collections that define the active RDF dataset for query evaluation.
+ *
+ * Dataset Override vs. Reset
+ *
+ * - Override: Providing a non-null {@code Dataset} via
+ * {@link fr.inria.corese.core.next.query.api.repository.RepositoryConnection#setDataset(Dataset)} or
+ * {@link fr.inria.corese.core.next.query.api.Query#setDataset(Dataset)} replaces all {@code FROM} and
+ * {@code FROM NAMED} clauses specified in the SPARQL query text.
+ * - Empty Override ({@link fr.inria.corese.core.next.query.api.dataset.Dataset#empty()}): Explicitly
+ * restricts the dataset to contain zero graphs, yielding empty results.
+ * - Reset ({@code null}): Passing {@code null} removes any active override, restoring the default
+ * behavior where the query text's own clauses or all repository graphs are consulted.
+ *
+ *
+ * All {@code Dataset} instances are immutable and inherently thread-safe.
*/
package fr.inria.corese.core.next.query.api.dataset;
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java b/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java
index 7f2cb8c9d..cf033dfd5 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java
@@ -7,6 +7,7 @@
import fr.inria.corese.core.next.query.api.result.GraphQueryResult;
import fr.inria.corese.core.next.query.api.result.TupleQueryResult;
+import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
@@ -44,7 +45,7 @@ private static BindingSet snapshot(BindingSet source) {
private static final class TupleResult implements TupleQueryResult {
private final List bindingNames;
private final Iterator rows;
- private boolean closed;
+ private volatile boolean closed;
private TupleResult(List bindingNames, Iterator rows) {
this.bindingNames = bindingNames;
@@ -82,7 +83,7 @@ private void checkOpen() {
private static final class GraphResult implements GraphQueryResult {
private final Iterator statements;
- private boolean closed;
+ private volatile boolean closed;
private GraphResult(Iterator statements) {
this.statements = statements;
@@ -115,7 +116,7 @@ private void checkOpen() {
private record ImmutableBindingSet(Map values) implements BindingSet {
private ImmutableBindingSet {
- values = Map.copyOf(values);
+ values = Collections.unmodifiableMap(new LinkedHashMap<>(values));
}
@Override
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java b/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java
index 412e03686..cb607c3ed 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java
@@ -19,7 +19,10 @@
import java.util.function.Consumer;
import java.util.function.Function;
import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
+import fr.inria.corese.core.next.data.api.term.IRI;
import fr.inria.corese.core.next.data.api.term.Resource;
+import fr.inria.corese.core.next.data.api.term.Value;
+import java.util.Objects;
/**
* A Corese repository that contains RDF data that can be queried and updated.
@@ -99,6 +102,35 @@ default TupleQueryResult select(String sparqlSelect) throws QuerySyntaxException
return MaterializedResults.tuple(names, bindings);
}
+ /**
+ * Evaluates a SPARQL SELECT query directly against this repository in one call,
+ * mapping each solution {@link BindingSet} using the provided mapper function.
+ *
+ * The repository connection is automatically opened and closed within this call.
+ * Solutions are mapped and collected into an immutable list without retaining the
+ * raw query result in memory.
+ *
+ * @param sparqlSelect the SPARQL SELECT query text
+ * @param mapper mapping function converting each {@link BindingSet} into a result element
+ * @param the mapped result type
+ * @return an immutable list of mapped results
+ * @throws QuerySyntaxException if the query string is syntactically invalid
+ * @throws RepositoryException if evaluation fails
+ * @throws NullPointerException if {@code mapper} is {@code null}
+ */
+ default List select(String sparqlSelect, Function super BindingSet, ? extends T> mapper)
+ throws QuerySyntaxException, RepositoryException {
+ Objects.requireNonNull(mapper, "mapper");
+ List results = new ArrayList<>();
+ try (RepositoryConnection conn = getConnection();
+ TupleQueryResult raw = conn.prepareTupleQuery(sparqlSelect).evaluate()) {
+ while (raw.hasNext()) {
+ results.add(mapper.apply(raw.next()));
+ }
+ }
+ return List.copyOf(results);
+ }
+
/**
* Evaluates a SPARQL ASK query directly against this repository in one call.
*
@@ -139,6 +171,69 @@ default GraphQueryResult graph(String sparqlGraph) throws QuerySyntaxException,
return MaterializedResults.graph(statements);
}
+ /**
+ * Evaluates a SPARQL CONSTRUCT or DESCRIBE query directly against this repository in one call.
+ *
+ * This is a natural semantic alias for {@link #graph(String)} following SPARQL 1.1 terminology.
+ *
+ * @param sparqlConstruct the SPARQL CONSTRUCT query text
+ * @return a materialized {@link GraphQueryResult} containing output statements
+ * @throws QuerySyntaxException if the query string is syntactically invalid
+ * @throws RepositoryException if evaluation fails
+ * @see #graph(String)
+ */
+ default GraphQueryResult construct(String sparqlConstruct) throws QuerySyntaxException, RepositoryException {
+ return graph(sparqlConstruct);
+ }
+
+ /**
+ * Tests whether at least one statement matching the specified pattern exists in this repository.
+ * A {@code null} term is treated as a wildcard.
+ *
+ * @param subject statement subject to match, or {@code null} for wildcard
+ * @param predicate statement predicate to match, or {@code null} for wildcard
+ * @param object statement object to match, or {@code null} for wildcard
+ * @param contexts contexts to match, or empty for every graph
+ * @return {@code true} if a matching statement exists, {@code false} otherwise
+ * @throws RepositoryException if the check fails
+ */
+ default boolean hasStatement(Resource subject, IRI predicate, Value object, Resource... contexts)
+ throws RepositoryException {
+ try (RepositoryConnection conn = getConnection()) {
+ return conn.hasStatement(subject, predicate, object, contexts);
+ }
+ }
+
+ /**
+ * Returns the total number of statements in the designated graph contexts.
+ * An empty {@code contexts} array counts across all graphs (default and named graphs).
+ *
+ * @param contexts contexts to count, or none for every graph
+ * @return number of statements in the selected contexts
+ * @throws RepositoryException if the count cannot be performed
+ */
+ default long size(Resource... contexts) throws RepositoryException {
+ try (RepositoryConnection conn = getConnection()) {
+ return conn.size(contexts);
+ }
+ }
+
+ /**
+ * Adds the specified statements to this repository in an auto-managed connection.
+ *
+ * @param statements statements to add
+ * @throws RepositoryException if the addition fails
+ * @throws NullPointerException if {@code statements} or any element is {@code null}
+ */
+ default void add(Statement... statements) throws RepositoryException {
+ Objects.requireNonNull(statements, "statements");
+ try (RepositoryConnection conn = getConnection()) {
+ for (Statement statement : statements) {
+ conn.add(statement);
+ }
+ }
+ }
+
/**
* Executes a SPARQL UPDATE request directly against this repository in one call.
*
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/repository/package-info.java b/src/main/java/fr/inria/corese/core/next/query/api/repository/package-info.java
index f4303a9a3..390e429cb 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/repository/package-info.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/repository/package-info.java
@@ -1,5 +1,25 @@
/**
- * Repository and connection contracts for executing SPARQL operations.
- * Create repositories with {@link fr.inria.corese.core.next.query.Repositories}.
+ * Repository and connection contracts for executing SPARQL operations in Corese v5.
+ *
+ * Lifecycle and Thread-Safety Contracts
+ *
+ * - {@link fr.inria.corese.core.next.query.api.repository.Repository}: Long-lived, thread-safe root container
+ * owning the underlying storage backend. Closing a repository is idempotent, terminates all active sessions,
+ * and releases storage resources.
+ * - {@link fr.inria.corese.core.next.query.api.repository.RepositoryConnection}: Lightweight, mutable session
+ * confining transactions, dataset scopes, and query preparation to a single thread. Connections are
+ * not safe for concurrent access across multiple threads.
+ *
+ *
+ * Convenience Shortcuts vs. Prepared Operations
+ * Convenience methods on {@code Repository} (such as {@link fr.inria.corese.core.next.query.api.repository.Repository#select(String)},
+ * {@link fr.inria.corese.core.next.query.api.repository.Repository#ask(String)},
+ * {@link fr.inria.corese.core.next.query.api.repository.Repository#construct(String)},
+ * and {@link fr.inria.corese.core.next.query.api.repository.Repository#size})
+ * manage their own connection lifecycle and return fully materialized, detached results safe for use across threads.
+ *
+ * For high-throughput, streaming, or parameterized query execution, obtain an explicit
+ * {@link fr.inria.corese.core.next.query.api.repository.RepositoryConnection} via {@code getConnection()},
+ * prepare operations, and consume results progressively within a {@code try-with-resources} block.
*/
package fr.inria.corese.core.next.query.api.repository;
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/result/TupleQueryResult.java b/src/main/java/fr/inria/corese/core/next/query/api/result/TupleQueryResult.java
index 8d29faf40..50dddf1c7 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/result/TupleQueryResult.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/result/TupleQueryResult.java
@@ -90,6 +90,11 @@ public BindingSet next() {
/**
* Returns a sequential {@link Stream} over the query results.
*
+ * Closing the returned stream triggers {@link #close()} on this result.
+ * Note that terminal operations (such as {@code forEach}, {@code findFirst}, or {@code limit})
+ * do not automatically close the stream. Callers must manage the stream
+ * with {@code try-with-resources} to guarantee underlying snapshot partition release.
+ *
* @return a sequential stream over the binding sets in this result
* @throws IllegalStateException if this result has been closed
*/
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/result/package-info.java b/src/main/java/fr/inria/corese/core/next/query/api/result/package-info.java
index 4e3599869..776cb14f6 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/result/package-info.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/result/package-info.java
@@ -1,5 +1,22 @@
/**
- * Closeable result types for SPARQL queries and progressive RDF statement
- * access, plus immutable query bindings.
+ * Closeable result types and solution bindings for SPARQL query evaluation in Corese v5.
+ *
+ * Result Types
+ *
+ * - {@link fr.inria.corese.core.next.query.api.result.TupleQueryResult}: Solution sequence for SPARQL SELECT queries,
+ * maintaining deterministic projection variable order via {@code getBindingNames()}.
+ * - {@link fr.inria.corese.core.next.query.api.result.GraphQueryResult} &
+ * {@link fr.inria.corese.core.next.query.api.result.StatementResult}: Progressive sequences of RDF statements
+ * produced by SPARQL CONSTRUCT and DESCRIBE queries or repository scans.
+ * - {@link fr.inria.corese.core.next.query.api.result.BindingSet} &
+ * {@link fr.inria.corese.core.next.query.api.result.Binding}: Immutable mappings of variable names to RDF terms.
+ *
+ *
+ * Stream and Resource Lifecycle
+ * Query results retain active storage snapshot references until exhausted, closed, or interrupted.
+ * The {@code stream()} method exposes a standard Java {@link java.util.stream.Stream} registered with an {@code onClose}
+ * hook that closes the parent result. Because terminal stream operations (such as {@code findFirst()}, {@code count()},
+ * or {@code forEach()}) do not trigger stream closure, callers must manage query results and their streams
+ * using {@code try-with-resources}.
*/
package fr.inria.corese.core.next.query.api.result;
diff --git a/src/main/java/fr/inria/corese/core/next/query/package-info.java b/src/main/java/fr/inria/corese/core/next/query/package-info.java
index 2e4e259a7..14f0a74d4 100644
--- a/src/main/java/fr/inria/corese/core/next/query/package-info.java
+++ b/src/main/java/fr/inria/corese/core/next/query/package-info.java
@@ -1,8 +1,28 @@
/**
- * SPARQL query and update services.
+ * Unified SPARQL query, update, and repository management facade for Corese v5.
*
- * {@link fr.inria.corese.core.next.query.Repositories} is the public creation
- * entry point. {@code api} contains the supported contracts; {@code impl}
- * contains the Corese implementation and internal execution machinery.
+ * Entry Points
+ * {@link fr.inria.corese.core.next.query.Repositories} serves as the root factory for creating and
+ * executing operations against RDF repositories:
+ *
+ * - {@link fr.inria.corese.core.next.query.Repositories#create()}: Instantiates an in-memory repository
+ * backed by the native Copy-On-Write index engine.
+ * - {@link fr.inria.corese.core.next.query.Repositories#createFrom(java.nio.file.Path)}: Instantiates and
+ * pre-loads an RDF graph or dataset directly from a file.
+ * - {@link fr.inria.corese.core.next.query.Repositories#withMemory(java.util.function.Consumer)}:
+ * Executes a scoped action against an auto-closed repository.
+ *
+ *
+ * Architecture & Lifecycle
+ * The query engine is organized into four complementary layers:
+ *
+ * - Facade Layer: {@code Repositories} and convenience shortcuts on {@code Repository}.
+ * - API Contracts ({@code api}): Pure, implementation-independent interfaces for repositories,
+ * connections, prepared queries, and results.
+ * - Parser & AST ({@code impl.sparql}): High-performance ANTLR4-based SPARQL 1.1 parser
+ * and abstract syntax tree.
+ * - Execution Pipeline ({@code impl.engine}): Cost-based optimizer (CBO), streaming cursors,
+ * and join evaluation engine.
+ *
*/
package fr.inria.corese.core.next.query;
diff --git a/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java b/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java
index c26aed6cd..73ff05fc4 100644
--- a/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java
@@ -1,5 +1,6 @@
package fr.inria.corese.core.next.query;
+import fr.inria.corese.core.next.data.api.io.format.RDFFormat;
import fr.inria.corese.core.next.query.api.exception.RepositoryException;
import fr.inria.corese.core.next.query.api.repository.Repository;
import fr.inria.corese.core.next.query.api.repository.RepositoryConnection;
@@ -9,7 +10,12 @@
import fr.inria.corese.core.next.storage.api.lifecycle.StorageLifecycle;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -109,4 +115,34 @@ void rejectsNullCreationArguments() {
assertThrows(NullPointerException.class, () -> Repositories.create((StorageManager) null));
assertThrows(NullPointerException.class, () -> Repositories.create(mock(StorageManager.class), null));
}
+
+ @Test
+ void withMemoryAndComputeWithMemoryExecuteAndCloseRepository() {
+ AtomicBoolean wasExecuted = new AtomicBoolean(false);
+ Repositories.withMemory(repo -> {
+ assertTrue(repo.isOpen());
+ repo.update("INSERT DATA { }");
+ assertTrue(repo.ask("ASK { }"));
+ wasExecuted.set(true);
+ });
+ assertTrue(wasExecuted.get());
+
+ boolean present = Repositories.computeWithMemory(repo -> {
+ repo.update("INSERT DATA { 42 }");
+ return repo.ask("ASK { 42 }");
+ });
+ assertTrue(present);
+ }
+
+ @Test
+ void createFromInputStreamLoadsDataImmediately() {
+ String turtle = " \"Alice\" .";
+ ByteArrayInputStream input = new ByteArrayInputStream(turtle.getBytes(StandardCharsets.UTF_8));
+
+ try (Repository repo = Repositories.createFrom(input, RDFFormat.TURTLE)) {
+ assertTrue(repo.isOpen());
+ assertEquals(1L, repo.size());
+ assertTrue(repo.ask("ASK { \"Alice\" }"));
+ }
+ }
}
diff --git a/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java b/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java
index 55c08cba0..e1d9db89f 100644
--- a/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java
@@ -44,6 +44,27 @@ void builderCreatesAnImmutableOrderedSnapshot() {
namedGraphs::clear);
}
+ @Test
+ void ofFactoryAndCollectionBuilderMethodsCreateExpectedDatasets() {
+ IRI g1 = iri("urn:g1");
+ IRI g2 = iri("urn:g2");
+ IRI n1 = iri("urn:n1");
+
+ Dataset datasetOf = Dataset.of(g1, g2);
+ assertEquals(Set.of(g1, g2), datasetOf.getDefaultGraphs());
+ assertTrue(datasetOf.getNamedGraphs().isEmpty());
+
+ Dataset datasetEmpty = Dataset.of();
+ assertSame(Dataset.empty(), datasetEmpty);
+
+ Dataset datasetCollections = Dataset.builder()
+ .defaultGraphs(java.util.List.of(g1, g2))
+ .namedGraphs(java.util.List.of(n1))
+ .build();
+ assertEquals(Set.of(g1, g2), datasetCollections.getDefaultGraphs());
+ assertEquals(Set.of(n1), datasetCollections.getNamedGraphs());
+ }
+
private IRI iri(String value) {
return values.createIRI(value);
}
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java
index a86d119f1..6cc9742cb 100644
--- a/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java
@@ -1,5 +1,9 @@
package fr.inria.corese.core.next.query.impl.repository;
+import fr.inria.corese.core.next.data.Values;
+import fr.inria.corese.core.next.data.api.model.Statement;
+import fr.inria.corese.core.next.data.api.term.IRI;
+import fr.inria.corese.core.next.data.api.term.Literal;
import fr.inria.corese.core.next.query.api.exception.RepositoryException;
import fr.inria.corese.core.next.query.api.repository.RepositoryConnection;
import fr.inria.corese.core.next.storage.impl.memory.MemoryStorageManager;
@@ -9,6 +13,8 @@
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
+import java.util.List;
+
import static org.junit.jupiter.api.Assertions.*;
class CoreseRepositoryTest {
@@ -123,6 +129,55 @@ void constructOneLinerTest() throws RepositoryException {
assertEquals("http://ex.org/s", stmt.getSubject().stringValue());
}
assertThrows(IllegalStateException.class, result::hasNext);
+
+ var aliasResult = repository.construct("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }");
+ try (aliasResult) {
+ assertTrue(aliasResult.hasNext());
+ var stmt = aliasResult.next();
+ assertEquals("http://ex.org/s", stmt.getSubject().stringValue());
+ }
+ }
+
+ @Test
+ @DisplayName("select() with mapper executes and transforms solutions cleanly")
+ void selectWithMapperTest() throws RepositoryException {
+ repository.update("INSERT DATA { \"Alice\" . \"Bob\" }");
+
+ List names = repository.select(
+ "SELECT ?name WHERE { ?s ?name } ORDER BY ?name",
+ bs -> bs.getValue("name").stringValue());
+
+ assertEquals(List.of("Alice", "Bob"), names);
+ }
+
+ @Test
+ @DisplayName("size(), hasStatement() and add() convenience methods operate on repository")
+ void repositoryDirectConvenienceMethods() throws RepositoryException {
+ IRI s = Values.iri("http://ex.org/s1");
+ IRI p = Values.iri("http://ex.org/p1");
+ Literal o = Values.literal("o1");
+ Statement stmt = Values.statement(s, p, o);
+
+ assertEquals(0L, repository.size());
+ assertFalse(repository.hasStatement(s, p, o));
+
+ repository.add(stmt);
+ assertEquals(1L, repository.size());
+ assertTrue(repository.hasStatement(s, p, o));
+ assertTrue(repository.hasStatement(s, null, null));
+ }
+
+ @Test
+ @DisplayName("MaterializedResults preserves binding variable projection order")
+ void materializedResultsPreservesBindingOrder() throws RepositoryException {
+ repository.update("INSERT DATA { }");
+
+ try (var result = repository.select("SELECT ?first ?second WHERE { ?first ?second }")) {
+ assertTrue(result.hasNext());
+ var bs = result.next();
+ List names = List.copyOf(bs.getBindingNames());
+ assertEquals(List.of("first", "second"), names);
+ }
}
}
}
From b31de157b3b4520e7a067f633b32c2a3866310d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?R=C3=A9mi=20C=C3=A9r=C3=A8s?=
Date: Fri, 23 Oct 2026 16:25:57 +0200
Subject: [PATCH 2/2] refactor(query): address copilot review feedback on PR
#646
- Enforce batch atomicity in Repository.add(Statement...) via conn.add(Iterable)
- Use Collections.unmodifiableList in Repository.select mapper and document eager projection
- Adopt Collection extends IRI> (PECS) in Dataset.Builder
- Deduplicate createAndLoad in Repositories
- Enforce deterministic variable projection ordering across all tuple results and binding sets
- Add regression assertions in RepositoriesTest, DatasetTest, and CoreseRepositoryTest
---
.../corese/core/next/query/Repositories.java | 20 +++++++------------
.../core/next/query/api/dataset/Dataset.java | 4 ++--
.../api/repository/MaterializedResults.java | 9 +++++----
.../next/query/api/repository/Repository.java | 14 ++++++-------
.../sparql/execution/CoreseBindingSet.java | 2 +-
.../execution/CoreseTupleQueryResult.java | 5 ++++-
.../execution/StreamingTupleQueryResult.java | 4 +++-
.../core/next/query/RepositoriesTest.java | 9 +++++++++
.../next/query/api/dataset/DatasetTest.java | 5 +++--
.../impl/repository/CoreseRepositoryTest.java | 5 +++++
10 files changed, 46 insertions(+), 31 deletions(-)
diff --git a/src/main/java/fr/inria/corese/core/next/query/Repositories.java b/src/main/java/fr/inria/corese/core/next/query/Repositories.java
index b13d9c9eb..ffe92128a 100644
--- a/src/main/java/fr/inria/corese/core/next/query/Repositories.java
+++ b/src/main/java/fr/inria/corese/core/next/query/Repositories.java
@@ -99,22 +99,12 @@ public static Repository create(StorageManager storage, StorageConfig config) {
*/
public static Repository createFrom(Path source) {
Objects.requireNonNull(source, "source");
- Repository repository = create();
- try {
- repository.load(source);
- return repository;
- } catch (RuntimeException | Error e) {
- try {
- repository.close();
- } catch (Exception closeEx) {
- e.addSuppressed(closeEx);
- }
- throw e;
- }
+ return createAndLoad(repo -> repo.load(source));
}
/**
* Creates an open in-memory repository pre-loaded with RDF statements from the specified input stream.
+ * The input stream is consumed by the parser but remains caller-owned.
*
* @param source RDF input stream
* @param format explicit RDF serialization format
@@ -125,9 +115,13 @@ public static Repository createFrom(Path source) {
public static Repository createFrom(InputStream source, RDFFormat format) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(format, "format");
+ return createAndLoad(repo -> repo.load(source, format));
+ }
+
+ private static Repository createAndLoad(Consumer loader) {
Repository repository = create();
try {
- repository.load(source, format);
+ loader.accept(repository);
return repository;
} catch (RuntimeException | Error e) {
try {
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java b/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java
index c3f16b184..652ba69d0 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/dataset/Dataset.java
@@ -115,7 +115,7 @@ public Builder defaultGraph(IRI graph) {
}
/** Adds multiple default graphs. */
- public Builder defaultGraphs(Collection graphs) {
+ public Builder defaultGraphs(Collection extends IRI> graphs) {
Objects.requireNonNull(graphs, "graphs");
for (IRI graph : graphs) {
defaultGraph(graph);
@@ -130,7 +130,7 @@ public Builder namedGraph(IRI graph) {
}
/** Adds multiple named graphs. */
- public Builder namedGraphs(Collection graphs) {
+ public Builder namedGraphs(Collection extends IRI> graphs) {
Objects.requireNonNull(graphs, "graphs");
for (IRI graph : graphs) {
namedGraph(graph);
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java b/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java
index cf033dfd5..de3133cb5 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/repository/MaterializedResults.java
@@ -21,19 +21,20 @@ private MaterializedResults() {
}
static TupleQueryResult tuple(List bindingNames, List rows) {
+ List orderedNames = List.copyOf(bindingNames);
List snapshots = rows.stream()
- .map(MaterializedResults::snapshot)
+ .map(row -> snapshot(orderedNames, row))
.toList();
- return new TupleResult(List.copyOf(bindingNames), snapshots.iterator());
+ return new TupleResult(orderedNames, snapshots.iterator());
}
static GraphQueryResult graph(List statements) {
return new GraphResult(List.copyOf(statements).iterator());
}
- private static BindingSet snapshot(BindingSet source) {
+ private static BindingSet snapshot(List bindingNames, BindingSet source) {
Map values = new LinkedHashMap<>();
- for (String name : source.getBindingNames()) {
+ for (String name : bindingNames) {
Value value = source.getValue(name);
if (value != null) {
values.put(name, value);
diff --git a/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java b/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java
index cb607c3ed..08178172a 100644
--- a/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java
+++ b/src/main/java/fr/inria/corese/core/next/query/api/repository/Repository.java
@@ -10,6 +10,8 @@
import fr.inria.corese.core.next.query.Repositories;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.io.InputStream;
import java.io.Reader;
@@ -107,13 +109,13 @@ default TupleQueryResult select(String sparqlSelect) throws QuerySyntaxException
* mapping each solution {@link BindingSet} using the provided mapper function.
*
* The repository connection is automatically opened and closed within this call.
- * Solutions are mapped and collected into an immutable list without retaining the
- * raw query result in memory.
+ * The mapper should extract needed terms eagerly rather than retaining references
+ * to the row, because the underlying query cursor is closed upon return.
*
* @param sparqlSelect the SPARQL SELECT query text
* @param mapper mapping function converting each {@link BindingSet} into a result element
* @param the mapped result type
- * @return an immutable list of mapped results
+ * @return an unmodifiable list of mapped results
* @throws QuerySyntaxException if the query string is syntactically invalid
* @throws RepositoryException if evaluation fails
* @throws NullPointerException if {@code mapper} is {@code null}
@@ -128,7 +130,7 @@ default List select(String sparqlSelect, Function super BindingSet, ? e
results.add(mapper.apply(raw.next()));
}
}
- return List.copyOf(results);
+ return Collections.unmodifiableList(results);
}
/**
@@ -228,9 +230,7 @@ default long size(Resource... contexts) throws RepositoryException {
default void add(Statement... statements) throws RepositoryException {
Objects.requireNonNull(statements, "statements");
try (RepositoryConnection conn = getConnection()) {
- for (Statement statement : statements) {
- conn.add(statement);
- }
+ conn.add(Arrays.asList(statements));
}
}
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseBindingSet.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseBindingSet.java
index d5ce7ecaa..37a60793f 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseBindingSet.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseBindingSet.java
@@ -28,7 +28,7 @@ public final class CoreseBindingSet implements BindingSet {
* @throws NullPointerException if the mapping is null
*/
public CoreseBindingSet(Mapping mapping) {
- this(mapping, Set.copyOf(mapping.getVariableNames()));
+ this(mapping, Collections.unmodifiableSet(new LinkedHashSet<>(mapping.getVariableNames())));
}
/**
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseTupleQueryResult.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseTupleQueryResult.java
index 1e4e670e3..d7d0daa83 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseTupleQueryResult.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/CoreseTupleQueryResult.java
@@ -6,7 +6,9 @@
import fr.inria.corese.core.next.query.impl.engine.solution.Mapping;
import fr.inria.corese.core.next.query.impl.engine.solution.Mappings;
+import java.util.Collections;
import java.util.Iterator;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -24,7 +26,8 @@ public final class CoreseTupleQueryResult implements TupleQueryResult {
public CoreseTupleQueryResult(Mappings mappings) {
this.mappings = Objects.requireNonNull(mappings, "mappings");
this.iterator = this.mappings.iterator();
- this.projectedNames = Set.copyOf(this.mappings.getSelect().stream().map(Node::getLabel).toList());
+ this.projectedNames = Collections.unmodifiableSet(
+ new LinkedHashSet<>(this.mappings.getSelect().stream().map(Node::getLabel).toList()));
}
@Override
diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/StreamingTupleQueryResult.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/StreamingTupleQueryResult.java
index 05469415c..c0956f620 100644
--- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/StreamingTupleQueryResult.java
+++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/StreamingTupleQueryResult.java
@@ -13,6 +13,8 @@
import fr.inria.corese.core.next.storage.api.StorageManager;
import fr.inria.corese.core.next.storage.api.exception.StorageException;
+import java.util.Collections;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Set;
@@ -66,7 +68,7 @@ private static ScheduledThreadPoolExecutor deadlines() {
this.timeoutMillis = timeoutMillis;
this.started = started;
names = query.getSelect().stream().map(Node::getLabel).toList();
- projected = Set.copyOf(names);
+ projected = Collections.unmodifiableSet(new LinkedHashSet<>(names));
long remaining = TimeUnit.MILLISECONDS.toNanos(timeoutMillis) - (System.nanoTime() - started);
deadline = timeoutMillis <= 0 ? null
: DEADLINES.schedule(this::expire, Math.max(0, remaining), TimeUnit.NANOSECONDS);
diff --git a/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java b/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java
index 73ff05fc4..16239bc53 100644
--- a/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/RepositoriesTest.java
@@ -13,6 +13,7 @@
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -119,19 +120,27 @@ void rejectsNullCreationArguments() {
@Test
void withMemoryAndComputeWithMemoryExecuteAndCloseRepository() {
AtomicBoolean wasExecuted = new AtomicBoolean(false);
+ AtomicReference capturedRepo = new AtomicReference<>();
Repositories.withMemory(repo -> {
+ capturedRepo.set(repo);
assertTrue(repo.isOpen());
repo.update("INSERT DATA { }");
assertTrue(repo.ask("ASK { }"));
wasExecuted.set(true);
});
assertTrue(wasExecuted.get());
+ assertNotNull(capturedRepo.get());
+ assertFalse(capturedRepo.get().isOpen());
+ AtomicReference capturedComputeRepo = new AtomicReference<>();
boolean present = Repositories.computeWithMemory(repo -> {
+ capturedComputeRepo.set(repo);
repo.update("INSERT DATA { 42 }");
return repo.ask("ASK { 42 }");
});
assertTrue(present);
+ assertNotNull(capturedComputeRepo.get());
+ assertFalse(capturedComputeRepo.get().isOpen());
}
@Test
diff --git a/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java b/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java
index e1d9db89f..64682f416 100644
--- a/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/api/dataset/DatasetTest.java
@@ -5,6 +5,7 @@
import fr.inria.corese.core.next.data.Values;
import org.junit.jupiter.api.Test;
+import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -58,8 +59,8 @@ void ofFactoryAndCollectionBuilderMethodsCreateExpectedDatasets() {
assertSame(Dataset.empty(), datasetEmpty);
Dataset datasetCollections = Dataset.builder()
- .defaultGraphs(java.util.List.of(g1, g2))
- .namedGraphs(java.util.List.of(n1))
+ .defaultGraphs(List.of(g1, g2))
+ .namedGraphs(List.of(n1))
.build();
assertEquals(Set.of(g1, g2), datasetCollections.getDefaultGraphs());
assertEquals(Set.of(n1), datasetCollections.getNamedGraphs());
diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java
index 6cc9742cb..ff8e894e3 100644
--- a/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java
+++ b/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java
@@ -165,6 +165,11 @@ void repositoryDirectConvenienceMethods() throws RepositoryException {
assertEquals(1L, repository.size());
assertTrue(repository.hasStatement(s, p, o));
assertTrue(repository.hasStatement(s, null, null));
+
+ Statement stmt2 = Values.statement(s, p, Values.literal("o2"));
+ Statement stmt3 = Values.statement(s, p, Values.literal("o3"));
+ repository.add(stmt2, stmt3);
+ assertEquals(3L, repository.size());
}
@Test