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 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 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 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 (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 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 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(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