From 7e94824b1ddc76d87da1fd02c964c2dae86f1f69 Mon Sep 17 00:00:00 2001 From: "AD\\aabdoun" Date: Fri, 7 Aug 2026 15:57:36 +0200 Subject: [PATCH] [Feature] Define the future public SPARQL query API --- .../corese/core/next/query/api/Query.java | 18 +- .../core/next/query/api/package-info.java | 25 +- .../api/repository/RepositoryConnection.java | 8 + .../query/impl/dataset/CoreseDataset.java | 45 ++ .../next/query/impl/dataset/package-info.java | 8 + .../impl/query/AbstractCoreseOperation.java | 131 ++++++ .../query/impl/query/CoreseBooleanQuery.java | 48 ++ .../query/impl/query/CoreseGraphQuery.java | 52 +++ .../query/impl/query/CoreseTupleQuery.java | 51 +++ .../next/query/impl/query/CoreseUpdate.java | 137 ++++++ .../next/query/impl/query/package-info.java | 16 + .../impl/repository/CoreseRepository.java | 105 +++++ .../CoreseRepositoryConnection.java | 180 ++++++++ .../query/impl/repository/package-info.java | 15 + .../impl/result/CoreseGraphQueryResult.java | 8 +- .../execution/NextSparqlPipelineExecutor.java | 380 +++++++++++++++- .../core/next/query/kgram/core/Exp.java | 2 +- .../CoreseRepositoryConnectionTest.java | 413 ++++++++++++++++++ .../impl/repository/CoreseRepositoryTest.java | 70 +++ .../NextSparqlPipelineExecutorTest.java | 151 +++++++ 20 files changed, 1837 insertions(+), 26 deletions(-) create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/dataset/CoreseDataset.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/dataset/package-info.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/query/AbstractCoreseOperation.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseBooleanQuery.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseGraphQuery.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseTupleQuery.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseUpdate.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/query/package-info.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepository.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnection.java create mode 100644 src/main/java/fr/inria/corese/core/next/query/impl/repository/package-info.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnectionTest.java create mode 100644 src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java diff --git a/src/main/java/fr/inria/corese/core/next/query/api/Query.java b/src/main/java/fr/inria/corese/core/next/query/api/Query.java index 1d7d79359..d7ed5fb92 100644 --- a/src/main/java/fr/inria/corese/core/next/query/api/Query.java +++ b/src/main/java/fr/inria/corese/core/next/query/api/Query.java @@ -5,8 +5,12 @@ import fr.inria.corese.core.next.query.api.result.TupleQueryResult; /** - * A query on a repository that can be formulated in one of the supported query languages (for example SPARQL). - * It should hold kgram.core.Query + ASTQuery + * A prepared SPARQL query that can be evaluated against a repository. + * + *

Concrete subtypes are {@link TupleQuery} (SELECT), {@link BooleanQuery} (ASK), + * and {@link GraphQuery} (CONSTRUCT / DESCRIBE). + * All evaluation state (bindings, dataset, timeout) is carried by the + * {@link Operation} supertype and propagated on each {@link #evaluate()} call.

*/ public interface Query extends Operation { @@ -42,10 +46,14 @@ enum QueryType { */ QueryLanguage getLanguage(); - // Execution options /** - * Set the execution timeout for the query regarding remote operations (i.e. for SERVICE clauses) - * @param timeoutMillis time in milliseconds + * Sets a fine-grained execution timeout for this specific query, expressed in milliseconds. + * + *

When both this value and {@link Operation#setMaxExecutionTime(int)} are set, + * the shorter of the two limits is enforced. A value of {@code 0} disables this + * query-level timeout.

+ * + * @param timeoutMillis maximum evaluation time in milliseconds; 0 means no limit * @return this */ Query setTimeout(long timeoutMillis); diff --git a/src/main/java/fr/inria/corese/core/next/query/api/package-info.java b/src/main/java/fr/inria/corese/core/next/query/api/package-info.java index 0298bb9b7..e266a6405 100644 --- a/src/main/java/fr/inria/corese/core/next/query/api/package-info.java +++ b/src/main/java/fr/inria/corese/core/next/query/api/package-info.java @@ -1,9 +1,24 @@ /** - * Query-facing contracts, result types, validation entry points, and I/O - * abstractions. + * Public SPARQL query API for Corese. * - *

The final public SPARQL request API is not defined yet. Parser, AST, - * bridge, and execution wiring remain outside this package so they cannot be - * mistaken for that future contract.

+ *

Entry point: obtain a {@link fr.inria.corese.core.next.query.api.repository.RepositoryConnection} + * from a {@link fr.inria.corese.core.next.query.api.repository.Repository}, then prepare + * one of the four query types:

+ * + *
    + *
  • {@link fr.inria.corese.core.next.query.api.TupleQuery} — SPARQL SELECT
  • + *
  • {@link fr.inria.corese.core.next.query.api.BooleanQuery} — SPARQL ASK
  • + *
  • {@link fr.inria.corese.core.next.query.api.GraphQuery} — SPARQL CONSTRUCT / DESCRIBE
  • + *
  • {@link fr.inria.corese.core.next.query.api.Update} — SPARQL 1.1 UPDATE
  • + *
+ * + *

All types implement {@link fr.inria.corese.core.next.query.api.Operation}, which provides + * initial bindings ({@code setBinding}), dataset override ({@code setDataset}), + * and execution-time limit ({@code setMaxExecutionTime}). + * Query types additionally expose {@code setTimeout(long millis)} for millisecond precision.

+ * + *

No internal types (parser, AST, bridge, KGRAM) appear in this package or its sub-packages. + * All errors are reported via the exception hierarchy rooted at + * {@link fr.inria.corese.core.next.query.api.exception.QueryException}.

*/ package fr.inria.corese.core.next.query.api; diff --git a/src/main/java/fr/inria/corese/core/next/query/api/repository/RepositoryConnection.java b/src/main/java/fr/inria/corese/core/next/query/api/repository/RepositoryConnection.java index ab9c12c8b..cdcfb14bd 100644 --- a/src/main/java/fr/inria/corese/core/next/query/api/repository/RepositoryConnection.java +++ b/src/main/java/fr/inria/corese/core/next/query/api/repository/RepositoryConnection.java @@ -141,4 +141,12 @@ Update prepareUpdate(QueryLanguage queryLanguage, String updateString) * @throws IllegalStateException if the connection is closed */ void rollback() throws RepositoryException; + + /** + * Closes this connection and releases any resources it holds. + * + * @throws RepositoryException if closing the connection fails + */ + @Override + void close() throws RepositoryException; } diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/dataset/CoreseDataset.java b/src/main/java/fr/inria/corese/core/next/query/impl/dataset/CoreseDataset.java new file mode 100644 index 000000000..bf454f48e --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/dataset/CoreseDataset.java @@ -0,0 +1,45 @@ +package fr.inria.corese.core.next.query.impl.dataset; + +import fr.inria.corese.core.next.query.api.dataset.Dataset; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Mutable implementation of {@link Dataset}. + */ +public final class CoreseDataset implements Dataset { + + private final Set defaultGraphs = new LinkedHashSet<>(); + private final Set namedGraphs = new LinkedHashSet<>(); + + @Override + public Set getDefaultGraphs() { + return Collections.unmodifiableSet(defaultGraphs); + } + + @Override + public Set getNamedGraphs() { + return Collections.unmodifiableSet(namedGraphs); + } + + @Override + public Dataset addDefaultGraph(String uri) { + defaultGraphs.add(uri); + return this; + } + + @Override + public Dataset addNamedGraph(String uri) { + namedGraphs.add(uri); + return this; + } + + @Override + public Dataset clear() { + defaultGraphs.clear(); + namedGraphs.clear(); + return this; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/dataset/package-info.java b/src/main/java/fr/inria/corese/core/next/query/impl/dataset/package-info.java new file mode 100644 index 000000000..5ce23f0e5 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/dataset/package-info.java @@ -0,0 +1,8 @@ +/** + * Mutable implementation of the {@link fr.inria.corese.core.next.query.api.dataset.Dataset} API. + * + *

Only {@link fr.inria.corese.core.next.query.impl.dataset.CoreseDataset} lives here. + * Users who need a {@code Dataset} instance should import + * {@code CoreseDataset} directly or use a factory in the public API.

+ */ +package fr.inria.corese.core.next.query.impl.dataset; diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/query/AbstractCoreseOperation.java b/src/main/java/fr/inria/corese/core/next/query/impl/query/AbstractCoreseOperation.java new file mode 100644 index 000000000..f2c19cfe9 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/query/AbstractCoreseOperation.java @@ -0,0 +1,131 @@ +package fr.inria.corese.core.next.query.impl.query; + +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.query.api.Operation; +import fr.inria.corese.core.next.query.api.QueryLanguage; +import fr.inria.corese.core.next.query.api.dataset.Dataset; +import fr.inria.corese.core.next.query.api.result.Binding; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.impl.result.CoreseBinding; + +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** + * Base class for prepared SPARQL operations. + * + *

Manages initial bindings, dataset override, inference flag, and timeout. + * Subclasses implement query-type-specific evaluation.

+ */ +abstract class AbstractCoreseOperation implements Operation { + + private final String queryString; + private final QueryLanguage language; + private final LinkedHashMap bindings = new LinkedHashMap<>(); + private Dataset dataset; + private boolean includeInferred = true; + private int maxExecutionTime = 0; + + protected AbstractCoreseOperation(String queryString, QueryLanguage language) { + this.queryString = queryString; + this.language = language; + } + + public String getQueryString() { + return queryString; + } + + public QueryLanguage getLanguage() { + return language; + } + + @Override + public Operation setBinding(String name, Value value) { + bindings.put(name, value); + return this; + } + + @Override + public Operation removeBinding(String name) { + bindings.remove(name); + return this; + } + + @Override + public Operation clearBindings() { + bindings.clear(); + return this; + } + + @Override + public BindingSet getBindings() { + return new MapBackedBindingSet(Map.copyOf(bindings)); + } + + @Override + public Operation setDataset(Dataset dataset) { + this.dataset = dataset; + return this; + } + + @Override + public Dataset getDataset() { + return dataset; + } + + @Override + public Operation setIncludeInferred(boolean includeInferred) { + this.includeInferred = includeInferred; + return this; + } + + @Override + public boolean getIncludeInferred() { + return includeInferred; + } + + @Override + public Operation setMaxExecutionTime(int maxExecutionTimeSeconds) { + this.maxExecutionTime = maxExecutionTimeSeconds; + return this; + } + + @Override + public int getMaxExecutionTime() { + return maxExecutionTime; + } + + private static final class MapBackedBindingSet implements BindingSet { + + private final Map map; + + MapBackedBindingSet(Map map) { + this.map = map; + } + + @Override + public Set getBindingNames() { + return Collections.unmodifiableSet(map.keySet()); + } + + @Override + public boolean hasBinding(String name) { + return map.containsKey(name); + } + + @Override + public Value getValue(String name) { + return map.get(name); + } + + @Override + public Iterator iterator() { + return map.entrySet().stream() + .map(e -> new CoreseBinding(e.getKey(), e.getValue())) + .iterator(); + } + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseBooleanQuery.java b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseBooleanQuery.java new file mode 100644 index 000000000..92ff2c5f7 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseBooleanQuery.java @@ -0,0 +1,48 @@ +package fr.inria.corese.core.next.query.impl.query; + +import fr.inria.corese.core.next.query.api.BooleanQuery; +import fr.inria.corese.core.next.query.api.Query; +import fr.inria.corese.core.next.query.api.QueryLanguage; +import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.impl.sparql.execution.NextSparqlPipelineExecutor; + +/** + * Prepared SPARQL ASK query. + * + *

Delegates evaluation to {@link NextSparqlPipelineExecutor}.

+ */ +public final class CoreseBooleanQuery extends AbstractCoreseOperation implements BooleanQuery { + + private final NextSparqlPipelineExecutor executor; + private long timeoutMillis = 0; + + public CoreseBooleanQuery(String queryString, QueryLanguage language, NextSparqlPipelineExecutor executor) { + super(queryString, language); + this.executor = executor; + } + + @Override + public Query setTimeout(long timeoutMillis) { + this.timeoutMillis = timeoutMillis; + return this; + } + + @Override + public QueryType getQueryType() { + return QueryType.BOOLEAN; + } + + @Override + public Boolean evaluate() throws QueryEvaluationException { + return executor.evaluateBoolean(getQueryString(), getBindings(), getDataset(), effectiveTimeoutMillis()); + } + + private long effectiveTimeoutMillis() { + long fromQuery = this.timeoutMillis; + long fromOperation = (long) getMaxExecutionTime() * 1000L; + if (fromQuery > 0 && fromOperation > 0) { + return Math.min(fromQuery, fromOperation); + } + return fromQuery > 0 ? fromQuery : fromOperation; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseGraphQuery.java b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseGraphQuery.java new file mode 100644 index 000000000..17f447d83 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseGraphQuery.java @@ -0,0 +1,52 @@ +package fr.inria.corese.core.next.query.impl.query; + +import fr.inria.corese.core.next.query.api.GraphQuery; +import fr.inria.corese.core.next.query.api.Query; +import fr.inria.corese.core.next.query.api.QueryLanguage; +import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; +import fr.inria.corese.core.next.query.impl.sparql.execution.NextSparqlPipelineExecutor; + +/** + * Prepared SPARQL CONSTRUCT or DESCRIBE query. + * + *

Delegates evaluation to {@link NextSparqlPipelineExecutor}. Initial bindings, + * dataset override, and timeout are propagated into the KGRAM evaluation layer + * on each {@link #evaluate()} call. The CONSTRUCT template is applied to each + * WHERE-clause result mapping to materialise the output statements.

+ */ +public final class CoreseGraphQuery extends AbstractCoreseOperation implements GraphQuery { + + private final NextSparqlPipelineExecutor executor; + private long timeoutMillis = 0; + + public CoreseGraphQuery(String queryString, QueryLanguage language, NextSparqlPipelineExecutor executor) { + super(queryString, language); + this.executor = executor; + } + + @Override + public Query setTimeout(long timeoutMillis) { + this.timeoutMillis = timeoutMillis; + return this; + } + + @Override + public QueryType getQueryType() { + return QueryType.GRAPH; + } + + @Override + public GraphQueryResult evaluate() throws QueryEvaluationException { + return executor.evaluateGraph(getQueryString(), getBindings(), getDataset(), effectiveTimeoutMillis()); + } + + private long effectiveTimeoutMillis() { + long fromQuery = this.timeoutMillis; + long fromOperation = (long) getMaxExecutionTime() * 1000L; + if (fromQuery > 0 && fromOperation > 0) { + return Math.min(fromQuery, fromOperation); + } + return fromQuery > 0 ? fromQuery : fromOperation; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseTupleQuery.java b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseTupleQuery.java new file mode 100644 index 000000000..7ed8ec69e --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseTupleQuery.java @@ -0,0 +1,51 @@ +package fr.inria.corese.core.next.query.impl.query; + +import fr.inria.corese.core.next.query.api.Query; +import fr.inria.corese.core.next.query.api.QueryLanguage; +import fr.inria.corese.core.next.query.api.TupleQuery; +import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.query.impl.sparql.execution.NextSparqlPipelineExecutor; + +/** + * Prepared SPARQL SELECT query. + * + *

Delegates evaluation to {@link NextSparqlPipelineExecutor}. Initial bindings, + * dataset override, and timeout are propagated into the KGRAM evaluation layer + * on each {@link #evaluate()} call.

+ */ +public final class CoreseTupleQuery extends AbstractCoreseOperation implements TupleQuery { + + private final NextSparqlPipelineExecutor executor; + private long timeoutMillis = 0; + + public CoreseTupleQuery(String queryString, QueryLanguage language, NextSparqlPipelineExecutor executor) { + super(queryString, language); + this.executor = executor; + } + + @Override + public Query setTimeout(long timeoutMillis) { + this.timeoutMillis = timeoutMillis; + return this; + } + + @Override + public QueryType getQueryType() { + return QueryType.TUPLE; + } + + @Override + public TupleQueryResult evaluate() throws QueryEvaluationException { + return executor.evaluateTuple(getQueryString(), getBindings(), getDataset(), effectiveTimeoutMillis()); + } + + private long effectiveTimeoutMillis() { + long fromQuery = this.timeoutMillis; + long fromOperation = (long) getMaxExecutionTime() * 1000L; + if (fromQuery > 0 && fromOperation > 0) { + return Math.min(fromQuery, fromOperation); + } + return fromQuery > 0 ? fromQuery : fromOperation; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseUpdate.java b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseUpdate.java new file mode 100644 index 000000000..c5c4b7cd6 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/query/CoreseUpdate.java @@ -0,0 +1,137 @@ +package fr.inria.corese.core.next.query.impl.query; + +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Statement; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.QueryLanguage; +import fr.inria.corese.core.next.query.api.Update; +import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.api.exception.UnsupportedQueryFeatureException; +import fr.inria.corese.core.next.query.impl.parser.SparqlParser; +import fr.inria.corese.core.next.query.impl.sparql.ast.DeleteDataRequestAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.InsertDataRequestAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.IriAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.LiteralAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.NamedGraphQuadsAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.QuadsAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TermAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.TriplePatternAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.UpdateRequestAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.UpdateRequestUnitAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.path.PredicatePathAst; +import fr.inria.corese.core.next.storagemanager.api.StorageManager; +import fr.inria.corese.core.next.storagemanager.api.operations.MutationOperations; +import fr.inria.corese.core.next.util.StringUtils; + +import java.util.Objects; + +/** + * Prepared SPARQL UPDATE operation. + * + *

Supports {@code INSERT DATA} and {@code DELETE DATA} through the next storage + * pipeline. Other update forms (LOAD, CLEAR, DROP, DELETE/INSERT WHERE, etc.) throw + * {@link UnsupportedQueryFeatureException}.

+ */ +public final class CoreseUpdate extends AbstractCoreseOperation implements Update { + + private final StorageManager storage; + private final SparqlParser parser; + + public CoreseUpdate(String updateString, QueryLanguage language, StorageManager storage, SparqlParser parser) { + super(updateString, language); + this.storage = Objects.requireNonNull(storage, "storage"); + this.parser = Objects.requireNonNull(parser, "parser"); + } + + @Override + public void execute() throws QueryEvaluationException { + UpdateRequestAst request = (UpdateRequestAst) parser.parse(getQueryString()); + MutationOperations mutations = storage.getMutationOperations(); + CoreseValueFactory factory = new CoreseValueFactory(); + + for (UpdateRequestUnitAst operation : request.operations()) { + switch (operation) { + case InsertDataRequestAst insert -> applyQuads(insert.data(), mutations, factory, true); + case DeleteDataRequestAst delete -> applyQuads(delete.data(), mutations, factory, false); + default -> throw new UnsupportedQueryFeatureException( + "SPARQL UPDATE operation not yet supported: " + + operation.getClass().getSimpleName()); + } + } + } + + @Override + public Update setBinding(String name, Value value) { + super.setBinding(name, value); + return this; + } + + // ------------------------------------------------------------------------- + // Quad mutation helpers + // ------------------------------------------------------------------------- + + private void applyQuads(QuadsAst quads, MutationOperations mutations, + CoreseValueFactory factory, boolean insert) { + for (TriplePatternAst triple : quads.defaultTriples()) { + Statement stmt = toStatement(triple, null, factory); + if (insert) { + mutations.insertStatement(stmt); + } else { + mutations.deleteStatement(stmt); + } + } + for (NamedGraphQuadsAst block : quads.namedGraphBlocks()) { + Resource context = (Resource) termToValue(block.graph(), factory); + for (TriplePatternAst triple : block.triples()) { + Statement stmt = toStatement(triple, context, factory); + if (insert) { + mutations.insertStatement(stmt); + } else { + mutations.deleteStatement(stmt); + } + } + } + } + + private Statement toStatement(TriplePatternAst triple, Resource context, CoreseValueFactory factory) { + Value subject = termToValue(triple.subject(), factory); + Value object = termToValue(triple.object(), factory); + + // Resolve predicate — INSERT/DELETE DATA only allows simple predicate IRIs + if (!(triple.predicate() instanceof PredicatePathAst pp)) { + throw new UnsupportedQueryFeatureException( + "Property paths are not allowed in INSERT/DELETE DATA"); + } + Value predicate = termToValue(pp.predicate(), factory); + + if (!(subject instanceof Resource s)) { + throw new QueryEvaluationException("UPDATE subject must be a Resource, got: " + subject); + } + if (!(predicate instanceof IRI p)) { + throw new QueryEvaluationException("UPDATE predicate must be an IRI, got: " + predicate); + } + if (context != null) { + return factory.createStatement(s, p, object, context); + } + return factory.createStatement(s, p, object); + } + + private Value termToValue(TermAst term, CoreseValueFactory factory) { + return switch (term) { + case IriAst iri -> factory.createIRI(StringUtils.trimChevronIRIs(iri.raw())); + case LiteralAst lit -> { + if (lit.lang() != null && !lit.lang().isBlank()) { + yield factory.createLiteral(lit.lexical(), lit.lang()); + } + if (lit.datatype() != null && !lit.datatype().isBlank()) { + yield factory.createLiteral(lit.lexical(), factory.createIRI(lit.datatype())); + } + yield factory.createLiteral(lit.lexical()); + } + default -> throw new UnsupportedQueryFeatureException( + "Variables are not allowed in INSERT/DELETE DATA: " + term); + }; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/query/package-info.java b/src/main/java/fr/inria/corese/core/next/query/impl/query/package-info.java new file mode 100644 index 000000000..03ecd10d0 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/query/package-info.java @@ -0,0 +1,16 @@ +/** + * Prepared-query implementations for the public SPARQL API. + * + *

Each class implements one of the public query contracts:

+ *
    + *
  • {@link fr.inria.corese.core.next.query.impl.query.CoreseTupleQuery} — SELECT
  • + *
  • {@link fr.inria.corese.core.next.query.impl.query.CoreseBooleanQuery} — ASK
  • + *
  • {@link fr.inria.corese.core.next.query.impl.query.CoreseGraphQuery} — CONSTRUCT / DESCRIBE
  • + *
  • {@link fr.inria.corese.core.next.query.impl.query.CoreseUpdate} — SPARQL UPDATE (INSERT DATA / DELETE DATA)
  • + *
+ * + *

All classes extend {@link fr.inria.corese.core.next.query.impl.query.AbstractCoreseOperation}, + * which manages initial bindings, dataset override, timeout, and inference flag. + * None of these classes expose the parser, AST, bridge, or KGRAM engine.

+ */ +package fr.inria.corese.core.next.query.impl.query; diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepository.java b/src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepository.java new file mode 100644 index 000000000..d1e26fe2b --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepository.java @@ -0,0 +1,105 @@ +package fr.inria.corese.core.next.query.impl.repository; + +import fr.inria.corese.core.next.data.api.ValueFactory; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +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; +import fr.inria.corese.core.next.storagemanager.api.StorageManager; +import fr.inria.corese.core.next.storagemanager.api.lifecycle.LifecycleState; +import fr.inria.corese.core.next.storagemanager.api.support.config.StorageConfig; +import fr.inria.corese.core.next.storagemanager.api.support.exception.StorageException; + +import java.io.File; + +/** + * Corese implementation of {@link Repository}. + * + *

Wraps a {@link StorageManager} and exposes the public query API through + * {@link RepositoryConnection} instances. Users create connections via + * {@link #getConnection()} and interact only with the public query API — + * the parser, AST, bridge, and KGRAM remain invisible.

+ * + *

Usage

+ *
{@code
+ * StorageManager storage = MemoryStorageManager.builder().build();
+ * Repository repo = new CoreseRepository(storage);
+ * repo.init();
+ *
+ * try (RepositoryConnection conn = repo.getConnection()) {
+ *     TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }");
+ *     try (TupleQueryResult result = q.evaluate()) {
+ *         result.stream().forEach(bs -> System.out.println(bs));
+ *     }
+ * }
+ * repo.shutDown();
+ * }
+ */ +public final class CoreseRepository implements Repository { + + private final StorageManager storage; + private final ValueFactory valueFactory; + private File dataDir; + + public CoreseRepository(StorageManager storage) { + this.storage = storage; + this.valueFactory = new CoreseValueFactory(); + } + + @Override + public void setDataDir(File dataDir) { + if (isInitialized()) { + throw new IllegalStateException("Cannot set data directory after the repository has been initialized."); + } + this.dataDir = dataDir; + } + + @Override + public File getDataDir() { + return dataDir; + } + + @Override + public void init() throws RepositoryException { + if (isInitialized()) { + throw new IllegalStateException("Repository is already initialized."); + } + try { + storage.getLifecycle().initialize(StorageConfig.builder().build()); + } catch (StorageException e) { + throw new RepositoryException("Failed to initialize repository: " + e.getMessage(), e); + } + } + + @Override + public boolean isInitialized() { + return storage.getLifecycle().getState() == LifecycleState.RUNNING; + } + + @Override + public void shutDown() throws RepositoryException { + try { + storage.getLifecycle().shutdown(); + } catch (StorageException e) { + throw new RepositoryException("Failed to shut down repository: " + e.getMessage(), e); + } + } + + @Override + public boolean isWritable() { + return isInitialized(); + } + + @Override + public RepositoryConnection getConnection() throws RepositoryException { + if (!isInitialized()) { + throw new RepositoryException("Repository is not initialized. Call init() first."); + } + return new CoreseRepositoryConnection(this, storage); + } + + @Override + public ValueFactory getValueFactory() { + return valueFactory; + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnection.java b/src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnection.java new file mode 100644 index 000000000..71b5a5ea6 --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnection.java @@ -0,0 +1,180 @@ +package fr.inria.corese.core.next.query.impl.repository; + +import fr.inria.corese.core.next.data.api.ValueFactory; +import fr.inria.corese.core.next.query.api.BooleanQuery; +import fr.inria.corese.core.next.query.api.GraphQuery; +import fr.inria.corese.core.next.query.api.QueryLanguage; +import fr.inria.corese.core.next.query.api.TupleQuery; +import fr.inria.corese.core.next.query.api.Update; +import fr.inria.corese.core.next.query.api.dataset.Dataset; +import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException; +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; +import fr.inria.corese.core.next.query.impl.parser.SparqlParser; +import fr.inria.corese.core.next.query.impl.query.CoreseBooleanQuery; +import fr.inria.corese.core.next.query.impl.query.CoreseGraphQuery; +import fr.inria.corese.core.next.query.impl.query.CoreseTupleQuery; +import fr.inria.corese.core.next.query.impl.query.CoreseUpdate; +import fr.inria.corese.core.next.query.impl.sparql.ast.AskQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ConstructQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.DescribeQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.QueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.SelectQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.UpdateRequestAst; +import fr.inria.corese.core.next.query.impl.sparql.execution.NextSparqlPipelineExecutor; +import fr.inria.corese.core.next.storagemanager.api.StorageManager; + +/** + * Connection to a Corese repository. + * + *

Validates query syntax at preparation time and hides the internal pipeline + * (parser, AST, bridge, KGRAM) behind the public {@link RepositoryConnection} API. + * Users only interact with {@link TupleQuery}, {@link BooleanQuery}, + * {@link GraphQuery}, and {@link Update} interfaces.

+ */ +public final class CoreseRepositoryConnection implements RepositoryConnection { + + private final Repository repository; + private final StorageManager storage; + private final NextSparqlPipelineExecutor executor; + private final SparqlParser parser; + private Dataset connectionDataset; + private boolean open = true; + + CoreseRepositoryConnection(Repository repository, StorageManager storage) { + this.repository = repository; + this.storage = storage; + this.executor = new NextSparqlPipelineExecutor(storage); + this.parser = new SparqlParser(); + } + + @Override + public Repository getRepository() { + return repository; + } + + @Override + public ValueFactory getValueFactory() { + return repository.getValueFactory(); + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() throws RepositoryException { + open = false; + } + + @Override + public TupleQuery prepareTupleQuery(QueryLanguage queryLanguage, String queryString) + throws QuerySyntaxException, RepositoryException { + checkOpen(); + QueryAst ast = parse(queryString); + if (!(ast instanceof SelectQueryAst)) { + throw new QuerySyntaxException( + "Expected a SELECT query, got: " + ast.getClass().getSimpleName()); + } + CoreseTupleQuery q = new CoreseTupleQuery(queryString, queryLanguage, executor); + applyConnectionDataset(q); + return q; + } + + @Override + public GraphQuery prepareGraphQuery(QueryLanguage queryLanguage, String queryString) + throws QuerySyntaxException, RepositoryException { + checkOpen(); + QueryAst ast = parse(queryString); + if (!(ast instanceof ConstructQueryAst) && !(ast instanceof DescribeQueryAst)) { + throw new QuerySyntaxException( + "Expected a CONSTRUCT or DESCRIBE query, got: " + ast.getClass().getSimpleName()); + } + CoreseGraphQuery q = new CoreseGraphQuery(queryString, queryLanguage, executor); + applyConnectionDataset(q); + return q; + } + + @Override + public BooleanQuery prepareBooleanQuery(QueryLanguage queryLanguage, String queryString) + throws QuerySyntaxException, RepositoryException { + checkOpen(); + QueryAst ast = parse(queryString); + if (!(ast instanceof AskQueryAst)) { + throw new QuerySyntaxException( + "Expected an ASK query, got: " + ast.getClass().getSimpleName()); + } + CoreseBooleanQuery q = new CoreseBooleanQuery(queryString, queryLanguage, executor); + applyConnectionDataset(q); + return q; + } + + @Override + public Update prepareUpdate(QueryLanguage queryLanguage, String updateString) + throws QuerySyntaxException, RepositoryException { + checkOpen(); + QueryAst ast = parse(updateString); + if (!(ast instanceof UpdateRequestAst)) { + throw new QuerySyntaxException( + "Expected a SPARQL UPDATE request, got: " + ast.getClass().getSimpleName()); + } + CoreseUpdate u = new CoreseUpdate(updateString, queryLanguage, storage, parser); + applyConnectionDataset(u); + return u; + } + + @Override + public void setDataset(Dataset dataset) { + this.connectionDataset = dataset; + } + + @Override + public Dataset getDataset() { + return connectionDataset; + } + + @Override + public void begin() throws RepositoryException { + checkOpen(); + throw new RepositoryException("Transactions are not yet supported."); + } + + @Override + public void commit() throws RepositoryException { + checkOpen(); + throw new RepositoryException("Transactions are not yet supported."); + } + + @Override + public void rollback() throws RepositoryException { + checkOpen(); + throw new RepositoryException("Transactions are not yet supported."); + } + + private void checkOpen() { + if (!open) { + throw new RepositoryException("This connection is closed."); + } + } + + /** + * Applies the connection-level dataset to an operation as its initial dataset, + * only when no query-level dataset has been set yet. + * The user can still override it by calling {@link fr.inria.corese.core.next.query.api.Operation#setDataset(Dataset)} + * on the returned operation. + */ + private void applyConnectionDataset(fr.inria.corese.core.next.query.api.Operation operation) { + if (connectionDataset != null && operation.getDataset() == null) { + operation.setDataset(connectionDataset); + } + } + + /** + * Parses the query string, throwing {@link QuerySyntaxException} on any syntax error. + */ + private QueryAst parse(String queryString) throws QuerySyntaxException { + return parser.parse(queryString); + } +} diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/repository/package-info.java b/src/main/java/fr/inria/corese/core/next/query/impl/repository/package-info.java new file mode 100644 index 000000000..c3beb221b --- /dev/null +++ b/src/main/java/fr/inria/corese/core/next/query/impl/repository/package-info.java @@ -0,0 +1,15 @@ +/** + * Concrete implementations of the public repository API. + * + *

Entry point for users: create a {@link fr.inria.corese.core.next.query.impl.repository.CoreseRepository} + * backed by any {@link fr.inria.corese.core.next.storagemanager.api.StorageManager}, + * call {@code init()}, then open a + * {@link fr.inria.corese.core.next.query.api.repository.RepositoryConnection} via + * {@code getConnection()}.

+ * + *

The parser, AST, bridge, and KGRAM engine are invisible from this package. + * {@link fr.inria.corese.core.next.query.impl.repository.CoreseRepositoryConnection} + * hides {@link fr.inria.corese.core.next.query.impl.sparql.execution.NextSparqlPipelineExecutor} + * and validates query syntax at preparation time before returning typed query objects.

+ */ +package fr.inria.corese.core.next.query.impl.repository; diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/result/CoreseGraphQueryResult.java b/src/main/java/fr/inria/corese/core/next/query/impl/result/CoreseGraphQueryResult.java index 9d4c2de67..6f82181c4 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/result/CoreseGraphQueryResult.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/result/CoreseGraphQueryResult.java @@ -6,11 +6,15 @@ import java.util.Iterator; /** - * Basic implementation around a Model + * Basic implementation backed by a pre-built list of statements. */ public class CoreseGraphQueryResult implements GraphQueryResult { - private Iterator iterator; + private final Iterator iterator; + + public CoreseGraphQueryResult(Iterator iterator) { + this.iterator = iterator; + } @Override public boolean hasNext() { diff --git a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java index 3ae32a2c7..0574490d4 100644 --- a/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java +++ b/src/main/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutor.java @@ -1,23 +1,54 @@ package fr.inria.corese.core.next.query.impl.sparql.execution; +import fr.inria.corese.core.next.data.api.BNode; +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Literal; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Statement; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.dataset.Dataset; import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.api.exception.QueryTimeoutException; +import fr.inria.corese.core.next.query.api.result.Binding; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; import fr.inria.corese.core.next.query.api.result.TupleQueryResult; import fr.inria.corese.core.next.query.impl.parser.SparqlParser; +import fr.inria.corese.core.next.query.impl.result.CoreseGraphQueryResult; import fr.inria.corese.core.next.query.impl.result.CoreseTupleQueryResult; import fr.inria.corese.core.next.query.impl.sparql.ast.AskQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.ConstructQueryAst; +import fr.inria.corese.core.next.query.impl.sparql.ast.DescribeQueryAst; import fr.inria.corese.core.next.query.impl.sparql.ast.QueryAst; import fr.inria.corese.core.next.query.impl.sparql.ast.SelectQueryAst; import fr.inria.corese.core.next.query.impl.sparql.bridge.CoreseAstQueryBuilder; +import fr.inria.corese.core.next.query.kgram.api.core.Edge; +import fr.inria.corese.core.next.query.kgram.api.core.Node; import fr.inria.corese.core.next.query.kgram.core.Eval; +import fr.inria.corese.core.next.query.kgram.core.Exp; +import fr.inria.corese.core.next.query.kgram.core.Mapping; import fr.inria.corese.core.next.query.kgram.core.Mappings; import fr.inria.corese.core.next.query.kgram.core.Query; import fr.inria.corese.core.next.query.kgram.core.SparqlException; import fr.inria.corese.core.next.query.kgram.execution.RdfTermMatcher; import fr.inria.corese.core.next.query.kgram.execution.SparqlKgramEvaluator; +import fr.inria.corese.core.next.query.kgram.tool.NodeImpl; import fr.inria.corese.core.next.query.kgram.tool.StorageManagerProducer; import fr.inria.corese.core.next.storagemanager.api.StorageManager; +import fr.inria.corese.core.sparql.api.IDatatype; +import fr.inria.corese.core.sparql.datatype.DatatypeMap; +import fr.inria.corese.core.sparql.triple.parser.Constant; +import fr.inria.corese.core.sparql.triple.parser.Variable; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; /** * Internal orchestrator for the Corese-next SPARQL query path. @@ -34,6 +65,18 @@ */ public final class NextSparqlPipelineExecutor { + /** + * Shared scheduler used to enforce query timeouts. A single daemon thread is + * sufficient because the scheduled task is lightweight (set a flag and call + * {@link Eval#finish()}). + */ + private static final ScheduledExecutorService TIMEOUT_SCHEDULER = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "sparql-query-timeout"); + t.setDaemon(true); + return t; + }); + private final StorageManager storage; private final SparqlParser parser; private final CoreseAstQueryBuilder queryBuilder; @@ -63,49 +106,360 @@ public NextSparqlPipelineExecutor(StorageManager storage) { this.queryBuilder = Objects.requireNonNull(queryBuilder, "queryBuilder"); } + // ------------------------------------------------------------------------- + // Public API — called by CoreseTupleQuery / CoreseBooleanQuery + // ------------------------------------------------------------------------- + /** - * Evaluates a SELECT query through the next pipeline. + * Evaluates a SELECT query through the next pipeline with no initial bindings, + * no dataset override, and no timeout. * * @param sparql SPARQL query string to parse and evaluate * @return tuple result backed by the KGRAM mappings produced from next storage - * @throws IllegalArgumentException when the query is not a SELECT query + * @throws IllegalArgumentException when the query is not a SELECT query * @throws QueryEvaluationException when KGRAM evaluation fails */ public TupleQueryResult evaluateTuple(String sparql) { + return evaluateTuple(sparql, null, null, 0L); + } + + /** + * Evaluates a SELECT query through the next pipeline. + * + * @param sparql SPARQL query string to parse and evaluate + * @param bindings initial variable bindings to inject, or {@code null} + * @param dataset dataset override (FROM / FROM NAMED), or {@code null} + * @param timeoutMillis maximum evaluation time in milliseconds; 0 means no limit + * @return tuple result backed by the KGRAM mappings produced from next storage + * @throws IllegalArgumentException when the query is not a SELECT query + * @throws QueryEvaluationException when KGRAM evaluation fails + * @throws QueryTimeoutException when the evaluation exceeds {@code timeoutMillis} + */ + public TupleQueryResult evaluateTuple(String sparql, BindingSet bindings, Dataset dataset, long timeoutMillis) { QueryAst ast = parser.parse(sparql); if (!(ast instanceof SelectQueryAst select)) { throw new IllegalArgumentException("Tuple evaluation requires a SELECT query, got: " + ast.getClass().getSimpleName()); } - return new CoreseTupleQueryResult(evaluate(queryBuilder.toNextQuery(select))); + return new CoreseTupleQueryResult( + evaluate(queryBuilder.toNextQuery(select), bindings, dataset, timeoutMillis)); } /** - * Evaluates an ASK query through the next pipeline. + * Evaluates an ASK query through the next pipeline with no initial bindings, + * no dataset override, and no timeout. * * @param sparql SPARQL query string to parse and evaluate * @return {@code true} when at least one mapping matches the ASK pattern - * @throws IllegalArgumentException when the query is not an ASK query + * @throws IllegalArgumentException when the query is not an ASK query * @throws QueryEvaluationException when KGRAM evaluation fails */ public boolean evaluateBoolean(String sparql) { + return evaluateBoolean(sparql, null, null, 0L); + } + + /** + * Evaluates an ASK query through the next pipeline. + * + * @param sparql SPARQL query string to parse and evaluate + * @param bindings initial variable bindings to inject, or {@code null} + * @param dataset dataset override (FROM / FROM NAMED), or {@code null} + * @param timeoutMillis maximum evaluation time in milliseconds; 0 means no limit + * @return {@code true} when at least one mapping matches the ASK pattern + * @throws IllegalArgumentException when the query is not an ASK query + * @throws QueryEvaluationException when KGRAM evaluation fails + * @throws QueryTimeoutException when the evaluation exceeds {@code timeoutMillis} + */ + public boolean evaluateBoolean(String sparql, BindingSet bindings, Dataset dataset, long timeoutMillis) { QueryAst ast = parser.parse(sparql); if (!(ast instanceof AskQueryAst ask)) { throw new IllegalArgumentException("Boolean evaluation requires an ASK query, got: " + ast.getClass().getSimpleName()); } - return evaluate(queryBuilder.toNextQuery(ask)).size() > 0; + return evaluate(queryBuilder.toNextQuery(ask), bindings, dataset, timeoutMillis).size() > 0; } - private Mappings evaluate(Query query) { + /** + * Evaluates a CONSTRUCT or DESCRIBE query through the next pipeline with no initial + * bindings, no dataset override, and no timeout. + * + * @param sparql SPARQL query string to parse and evaluate + * @return graph result containing the constructed statements + * @throws IllegalArgumentException when the query is not a CONSTRUCT or DESCRIBE query + * @throws QueryEvaluationException when KGRAM evaluation fails + */ + public GraphQueryResult evaluateGraph(String sparql) { + return evaluateGraph(sparql, null, null, 0L); + } + + /** + * Evaluates a CONSTRUCT or DESCRIBE query through the next pipeline. + * + *

The WHERE clause is evaluated by KGRAM to produce variable bindings. Each + * binding is then applied to the CONSTRUCT template to materialise the output + * triples. DESCRIBE queries are lowered to a construct-like shape by + * {@link CoreseAstQueryBuilder} before evaluation.

+ * + * @param sparql SPARQL query string to parse and evaluate + * @param bindings initial variable bindings to inject, or {@code null} + * @param dataset dataset override (FROM / FROM NAMED), or {@code null} + * @param timeoutMillis maximum evaluation time in milliseconds; 0 means no limit + * @return graph result containing the constructed statements + * @throws IllegalArgumentException when the query is not a CONSTRUCT or DESCRIBE query + * @throws QueryEvaluationException when KGRAM evaluation fails + * @throws QueryTimeoutException when the evaluation exceeds {@code timeoutMillis} + */ + public GraphQueryResult evaluateGraph(String sparql, BindingSet bindings, Dataset dataset, long timeoutMillis) { + QueryAst ast = parser.parse(sparql); + Query kgramQuery; + if (ast instanceof ConstructQueryAst construct) { + kgramQuery = queryBuilder.toNextQuery(construct); + } else if (ast instanceof DescribeQueryAst describe) { + kgramQuery = queryBuilder.toNextQuery(describe); + } else { + throw new IllegalArgumentException( + "Graph evaluation requires a CONSTRUCT or DESCRIBE query, got: " + + ast.getClass().getSimpleName()); + } + Mappings mappings = evaluate(kgramQuery, bindings, dataset, timeoutMillis); + List statements = buildConstructStatements(kgramQuery, mappings); + return new CoreseGraphQueryResult(statements.iterator()); + } + + // ------------------------------------------------------------------------- + // Internal evaluation pipeline + // ------------------------------------------------------------------------- + + private Mappings evaluate(Query kgramQuery, BindingSet bindings, Dataset dataset, long timeoutMillis) { + Eval eval = Eval.create( + new StorageManagerProducer(storage), + new SparqlKgramEvaluator(), + new RdfTermMatcher()); + + if (dataset != null) { + applyDataset(kgramQuery, dataset); + } + + Mapping initialMapping = buildInitialMapping(bindings); + + if (timeoutMillis > 0) { + return evaluateWithTimeout(eval, kgramQuery, initialMapping, timeoutMillis); + } + return evaluateCore(eval, kgramQuery, initialMapping); + } + + private Mappings evaluateCore(Eval eval, Query kgramQuery, Mapping initialMapping) { try { - Eval eval = Eval.create( - new StorageManagerProducer(storage), - new SparqlKgramEvaluator(), - new RdfTermMatcher()); - return eval.query(query); + return eval.query(null, kgramQuery, initialMapping); } catch (SparqlException e) { - throw new QueryEvaluationException("Failed to evaluate query with the next pipeline: " + e.getMessage(), e); + throw new QueryEvaluationException( + "Failed to evaluate query with the next pipeline: " + e.getMessage(), e); + } + } + + /** + * Runs {@code eval.query()} with a cooperative timeout. + * + *

A daemon-thread scheduler calls {@link Eval#finish()} after the deadline + * to signal the KGRAM engine to stop at the next opportunity. If the evaluation + * completes naturally before the deadline, the scheduler task is cancelled and + * results are returned normally.

+ */ + private Mappings evaluateWithTimeout(Eval eval, Query kgramQuery, Mapping initialMapping, long timeoutMillis) { + AtomicBoolean timedOut = new AtomicBoolean(false); + ScheduledFuture canceller = TIMEOUT_SCHEDULER.schedule(() -> { + timedOut.set(true); + eval.finish(); + }, timeoutMillis, TimeUnit.MILLISECONDS); + + try { + Mappings result = evaluateCore(eval, kgramQuery, initialMapping); + if (timedOut.get()) { + throw new QueryTimeoutException(timeoutMillis); + } + return result; + } finally { + canceller.cancel(false); + } + } + + // ------------------------------------------------------------------------- + // CONSTRUCT template instantiation + // ------------------------------------------------------------------------- + + /** + * Applies the CONSTRUCT template of a KGRAM query to every result mapping and + * returns the materialised statements. + * + *

For each mapping produced by the WHERE clause, each template edge is + * instantiated by substituting variable nodes with their bound target nodes. + * Triples where any component is unbound or cannot be converted to a valid + * RDF term are silently skipped, matching standard SPARQL CONSTRUCT semantics.

+ */ + private List buildConstructStatements(Query kgramQuery, Mappings mappings) { + List templateEdges = new ArrayList<>(); + Exp constructTemplate = kgramQuery.getConstruct(); + if (constructTemplate == null) { + return List.of(); + } + constructTemplate.getEdgeList(templateEdges); + + CoreseValueFactory factory = new CoreseValueFactory(); + List statements = new ArrayList<>(); + + for (Mapping mapping : mappings) { + for (Edge templateEdge : templateEdges) { + Node subjectNode = resolveTemplateNode(templateEdge.getNode(0), mapping); + Node predicateNode = resolveTemplateNode(templateEdge.getProperty(), mapping); + Node objectNode = resolveTemplateNode(templateEdge.getNode(1), mapping); + + if (subjectNode == null || predicateNode == null || objectNode == null) { + continue; + } + + Value subject = kgramNodeToApiValue(subjectNode, factory); + Value predicate = kgramNodeToApiValue(predicateNode, factory); + Value object = kgramNodeToApiValue(objectNode, factory); + + if (subject instanceof Resource s && predicate instanceof IRI p && object != null) { + statements.add(factory.createStatement(s, p, object)); + } + } + } + return statements; + } + + /** + * Resolves a CONSTRUCT template node: if the node is a variable, look it up in + * the current mapping; if it is already a constant, return it directly. + * + * @return the bound or constant node, or {@code null} when a variable is unbound + */ + private Node resolveTemplateNode(Node templateNode, Mapping mapping) { + if (templateNode == null) { + return null; + } + if (templateNode.isVariable()) { + return mapping.getNode(templateNode); + } + return templateNode; + } + + /** + * Converts a KGRAM constant {@link Node} to the corresponding API {@link Value}. + * + * @return the API value, or {@code null} when the datatype kind is not supported + */ + private Value kgramNodeToApiValue(Node node, CoreseValueFactory factory) { + IDatatype dt = node.getDatatypeValue(); + if (dt.isURI()) { + return factory.createIRI(dt.getLabel()); + } + if (dt.isBlank()) { + return factory.createBNode(dt.getLabel()); + } + if (dt.isLiteral()) { + String lang = dt.getLang(); + if (lang != null && !lang.isEmpty()) { + return factory.createLiteral(dt.getLabel(), lang); + } + String datatypeUri = dt.getDatatypeURI(); + if (datatypeUri != null) { + return factory.createLiteral(dt.getLabel(), factory.createIRI(datatypeUri)); + } + return factory.createLiteral(dt.getLabel()); + } + return null; + } + + // ------------------------------------------------------------------------- + // Dataset wiring + // ------------------------------------------------------------------------- + + /** + * Overrides the FROM / FROM NAMED clauses of a KGRAM query with the API dataset. + * + *

A non-empty {@link Dataset#getDefaultGraphs()} replaces the KGRAM {@code from} + * list; a non-empty {@link Dataset#getNamedGraphs()} replaces the {@code named} list. + * Empty sets leave the corresponding KGRAM list untouched so that inline FROM clauses + * in the query string remain effective when the dataset is only partial.

+ */ + private void applyDataset(Query kgramQuery, Dataset dataset) { + List defaultGraphs = new ArrayList<>(dataset.getDefaultGraphs()); + List namedGraphs = new ArrayList<>(dataset.getNamedGraphs()); + + if (!defaultGraphs.isEmpty()) { + kgramQuery.setFrom(urisToKgramNodes(defaultGraphs)); + } + if (!namedGraphs.isEmpty()) { + kgramQuery.setNamed(urisToKgramNodes(namedGraphs)); + } + } + + private List urisToKgramNodes(List uris) { + List nodes = new ArrayList<>(uris.size()); + for (String uri : uris) { + nodes.add(new NodeImpl(Constant.create(DatatypeMap.newResource(uri)))); + } + return nodes; + } + + // ------------------------------------------------------------------------- + // Initial bindings wiring + // ------------------------------------------------------------------------- + + /** + * Converts an API {@link BindingSet} into a KGRAM {@link Mapping} that can be + * passed to {@link Eval#query(Node, Query, Mapping)} as initial variable bindings. + * + *

Each binding entry becomes a (variable-node, value-node) pair. The variable + * node carries the variable name as its label so that + * {@link Query#getExtNode(String)} can look it up by name during evaluation. + * Bindings whose value cannot be converted to a KGRAM node are silently skipped.

+ * + * @return a {@link Mapping} with the converted bindings, or {@code null} when the + * binding set is empty or all values failed to convert + */ + private Mapping buildInitialMapping(BindingSet bindings) { + if (bindings == null || bindings.getBindingNames().isEmpty()) { + return null; + } + List queryNodes = new ArrayList<>(); + List targetNodes = new ArrayList<>(); + for (Binding b : bindings) { + Node targetNode = valueToKgramNode(b.value()); + if (targetNode != null) { + queryNodes.add(new NodeImpl(new Variable(b.name()))); + targetNodes.add(targetNode); + } + } + return queryNodes.isEmpty() ? null : Mapping.create(queryNodes, targetNodes); + } + + /** + * Converts an API {@link Value} into a KGRAM constant {@link Node}. + * + * @return a constant node, or {@code null} when the value type is not supported + */ + private Node valueToKgramNode(Value value) { + IDatatype dt; + if (value instanceof IRI iri) { + dt = DatatypeMap.newResource(iri.stringValue()); + } else if (value instanceof BNode bNode) { + dt = DatatypeMap.createBlank(bNode.getID()); + } else if (value instanceof Literal literal) { + String lang = literal.getLanguage().orElse(null); + if (lang != null && !lang.isEmpty()) { + dt = DatatypeMap.createLiteral(literal.getLabel(), null, lang); + } else { + String datatypeUri = literal.getDatatype() != null + ? literal.getDatatype().stringValue() + : null; + dt = DatatypeMap.createLiteral(literal.getLabel(), datatypeUri, null); + } + } else { + return null; } + return new NodeImpl(Constant.create(dt)); } } diff --git a/src/main/java/fr/inria/corese/core/next/query/kgram/core/Exp.java b/src/main/java/fr/inria/corese/core/next/query/kgram/core/Exp.java index 57b3c064f..b05bf1130 100644 --- a/src/main/java/fr/inria/corese/core/next/query/kgram/core/Exp.java +++ b/src/main/java/fr/inria/corese/core/next/query/kgram/core/Exp.java @@ -465,7 +465,7 @@ public List getExpList() { return args; } - void getEdgeList(List list) { + public void getEdgeList(List list) { for (Exp exp : getExpList()) { if (exp.isEdge()) { list.add(exp.getEdge()); diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnectionTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnectionTest.java new file mode 100644 index 000000000..2923da8a1 --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryConnectionTest.java @@ -0,0 +1,413 @@ +package fr.inria.corese.core.next.query.impl.repository; + +import fr.inria.corese.core.next.data.api.IRI; +import fr.inria.corese.core.next.data.api.Resource; +import fr.inria.corese.core.next.data.api.Value; +import fr.inria.corese.core.next.data.api.ValueFactory; +import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.BooleanQuery; +import fr.inria.corese.core.next.query.api.GraphQuery; +import fr.inria.corese.core.next.query.api.Query; +import fr.inria.corese.core.next.query.api.QueryLanguage; +import fr.inria.corese.core.next.query.api.TupleQuery; +import fr.inria.corese.core.next.query.api.Update; +import fr.inria.corese.core.next.query.api.dataset.Dataset; +import fr.inria.corese.core.next.query.api.exception.QueryEvaluationException; +import fr.inria.corese.core.next.query.api.exception.QuerySyntaxException; +import fr.inria.corese.core.next.query.api.exception.RepositoryException; +import fr.inria.corese.core.next.query.api.result.GraphQueryResult; +import fr.inria.corese.core.next.data.api.Statement; +import fr.inria.corese.core.next.query.api.repository.RepositoryConnection; +import fr.inria.corese.core.next.query.api.result.BindingSet; +import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.query.impl.dataset.CoreseDataset; +import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for the public SPARQL API exposed by {@link CoreseRepositoryConnection}. + * + *

Uses a real {@link MemoryStorageManager} to exercise the full pipeline + * (parser → AST → bridge → KGRAM) through the API without touching any + * internal classes directly.

+ */ +class CoreseRepositoryConnectionTest { + + private static final String ALICE = "http://example.org/alice"; + private static final String BOB = "http://example.org/bob"; + private static final String KNOWS = "http://example.org/knows"; + + private ValueFactory vf; + private MemoryStorageManager storage; + private CoreseRepository repository; + + @BeforeEach + void setUp() throws RepositoryException { + vf = new CoreseValueFactory(); + storage = MemoryStorageManager.builder().build(); + repository = new CoreseRepository(storage); + repository.init(); + + // Insert one triple: alice knows bob + storage.getMutationOperations().insertStatement( + vf.createStatement(iri(ALICE), iri(KNOWS), iri(BOB))); + } + + // ------------------------------------------------------------------------- + // TupleQuery (SELECT) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("prepareTupleQuery") + class PrepareTupleQuery { + + @Test + @DisplayName("Returns TupleQuery for a SELECT query") + void returnsTupleQueryForSelect() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + assertNotNull(q); + assertEquals(Query.QueryType.TUPLE, q.getQueryType()); + } + } + + @Test + @DisplayName("evaluate() returns the matching triples") + void evaluateReturnsMatchingTriples() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + TupleQueryResult result = q.evaluate(); + + assertEquals(List.of("s", "p", "o"), result.getBindingNames()); + assertTrue(result.hasNext()); + BindingSet bs = result.next(); + assertEquals(ALICE, bs.getValue("s").stringValue()); + assertEquals(KNOWS, bs.getValue("p").stringValue()); + assertEquals(BOB, bs.getValue("o").stringValue()); + assertFalse(result.hasNext()); + } + } + + @Test + @DisplayName("Throws QuerySyntaxException for a non-SELECT query string") + void throwsSyntaxExceptionForAsk() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + assertThrows(QuerySyntaxException.class, () -> + conn.prepareTupleQuery(QueryLanguage.SPARQL, "ASK WHERE { ?s ?p ?o }")); + } + } + + @Test + @DisplayName("Throws QuerySyntaxException for invalid SPARQL") + void throwsSyntaxExceptionForInvalidSparql() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + assertThrows(QuerySyntaxException.class, () -> + conn.prepareTupleQuery(QueryLanguage.SPARQL, "THIS IS NOT SPARQL")); + } + } + } + + // ------------------------------------------------------------------------- + // BooleanQuery (ASK) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("prepareBooleanQuery") + class PrepareBooleanQuery { + + @Test + @DisplayName("Returns BooleanQuery for an ASK query") + void returnsBooleanQueryForAsk() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + BooleanQuery q = conn.prepareBooleanQuery(QueryLanguage.SPARQL, "ASK WHERE { ?s ?p ?o }"); + assertNotNull(q); + assertEquals(Query.QueryType.BOOLEAN, q.getQueryType()); + } + } + + @Test + @DisplayName("evaluate() returns true when data matches") + void evaluateReturnsTrueWhenDataExists() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + BooleanQuery q = conn.prepareBooleanQuery(QueryLanguage.SPARQL, "ASK WHERE { ?s ?p ?o }"); + assertTrue(q.evaluate()); + } + } + + @Test + @DisplayName("evaluate() returns false when no data matches") + void evaluateReturnsFalseWhenNoMatch() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + BooleanQuery q = conn.prepareBooleanQuery(QueryLanguage.SPARQL, + "ASK WHERE { ?p ?o }"); + assertFalse(q.evaluate()); + } + } + + @Test + @DisplayName("Throws QuerySyntaxException for a non-ASK query string") + void throwsSyntaxExceptionForSelect() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + assertThrows(QuerySyntaxException.class, () -> + conn.prepareBooleanQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }")); + } + } + } + + // ------------------------------------------------------------------------- + // GraphQuery (CONSTRUCT / DESCRIBE) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("prepareGraphQuery") + class PrepareGraphQuery { + + @Test + @DisplayName("Returns GraphQuery for a CONSTRUCT query") + void returnsGraphQueryForConstruct() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + GraphQuery q = conn.prepareGraphQuery(QueryLanguage.SPARQL, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"); + assertNotNull(q); + assertEquals(Query.QueryType.GRAPH, q.getQueryType()); + } + } + + @Test + @DisplayName("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } returns the stored triple") + void constructSpoReturnsStoredTriple() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + GraphQuery q = conn.prepareGraphQuery(QueryLanguage.SPARQL, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"); + GraphQueryResult result = q.evaluate(); + + assertTrue(result.hasNext(), "Expected at least one constructed statement"); + Statement stmt = result.next(); + assertEquals(ALICE, stmt.getSubject().stringValue()); + assertEquals(KNOWS, stmt.getPredicate().stringValue()); + assertEquals(BOB, stmt.getObject().stringValue()); + assertFalse(result.hasNext(), "Expected exactly one statement"); + } + } + + @Test + @DisplayName("CONSTRUCT returns empty result when WHERE clause matches nothing") + void constructReturnsEmptyWhenNoMatch() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + GraphQuery q = conn.prepareGraphQuery(QueryLanguage.SPARQL, + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?o }"); + GraphQueryResult result = q.evaluate(); + assertFalse(result.hasNext(), "Expected no statements when WHERE has no match"); + } + } + + @Test + @DisplayName("Throws QuerySyntaxException for a non-CONSTRUCT/DESCRIBE query string") + void throwsSyntaxExceptionForSelect() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + assertThrows(QuerySyntaxException.class, () -> + conn.prepareGraphQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }")); + } + } + } + + // ------------------------------------------------------------------------- + // Update (SPARQL UPDATE) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("prepareUpdate") + class PrepareUpdate { + + @Test + @DisplayName("Returns Update for a valid INSERT DATA") + void returnsUpdateForInsertData() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + Update u = conn.prepareUpdate(QueryLanguage.SPARQL, + "INSERT DATA { }"); + assertNotNull(u); + } + } + + @Test + @DisplayName("INSERT DATA inserts a triple into the store") + void insertDataInsertsTriple() throws Exception { + String carol = "http://example.org/carol"; + try (RepositoryConnection conn = repository.getConnection()) { + Update u = conn.prepareUpdate(QueryLanguage.SPARQL, + "INSERT DATA { <" + ALICE + "> <" + KNOWS + "> <" + carol + "> }"); + u.execute(); + } + // verify via SELECT + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, + "SELECT ?o WHERE { <" + ALICE + "> <" + KNOWS + "> ?o }"); + TupleQueryResult result = q.evaluate(); + boolean foundCarol = false; + while (result.hasNext()) { + if (carol.equals(result.next().getValue("o").stringValue())) { + foundCarol = true; + } + } + assertTrue(foundCarol, "INSERT DATA should have added alice→knows→carol"); + } + } + + @Test + @DisplayName("DELETE DATA removes a triple from the store") + void deleteDataRemovesTriple() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + Update u = conn.prepareUpdate(QueryLanguage.SPARQL, + "DELETE DATA { <" + ALICE + "> <" + KNOWS + "> <" + BOB + "> }"); + u.execute(); + } + try (RepositoryConnection conn = repository.getConnection()) { + BooleanQuery q = conn.prepareBooleanQuery(QueryLanguage.SPARQL, + "ASK WHERE { <" + ALICE + "> <" + KNOWS + "> <" + BOB + "> }"); + assertFalse(q.evaluate(), "DELETE DATA should have removed alice→knows→bob"); + } + } + } + + // ------------------------------------------------------------------------- + // Operation API (bindings, dataset, timeout, inferredStatements) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("Operation API") + class OperationApi { + + @Test + @DisplayName("setBinding / getBindings round-trip") + void bindingsRoundTrip() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + Value v = vf.createIRI("http://example.org/x"); + + q.setBinding("x", v); + + assertTrue(q.getBindings().hasBinding("x")); + assertEquals(v, q.getBindings().getValue("x")); + } + } + + @Test + @DisplayName("removeBinding removes the binding") + void removeBindingWorks() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + Value v = vf.createIRI("http://example.org/x"); + + q.setBinding("x", v); + q.removeBinding("x"); + + assertFalse(q.getBindings().hasBinding("x")); + } + } + + @Test + @DisplayName("clearBindings removes all bindings") + void clearBindingsWorks() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + + q.setBinding("x", vf.createIRI("http://example.org/x")); + q.setBinding("y", vf.createIRI("http://example.org/y")); + q.clearBindings(); + + assertFalse(q.getBindings().hasBinding("x")); + assertFalse(q.getBindings().hasBinding("y")); + } + } + + @Test + @DisplayName("setDataset / getDataset round-trip") + void datasetRoundTrip() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + Dataset ds = new CoreseDataset().addDefaultGraph("http://example.org/g"); + + q.setDataset(ds); + + assertSame(ds, q.getDataset()); + } + } + + @Test + @DisplayName("setMaxExecutionTime / getMaxExecutionTime round-trip") + void timeoutRoundTrip() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + + q.setMaxExecutionTime(30); + + assertEquals(30, q.getMaxExecutionTime()); + } + } + + @Test + @DisplayName("setIncludeInferred defaults to true") + void includeInferredDefaultsToTrue() throws Exception { + try (RepositoryConnection conn = repository.getConnection()) { + TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }"); + assertTrue(q.getIncludeInferred()); + } + } + } + + + @Nested + @DisplayName("Connection lifecycle") + class ConnectionLifecycle { + + @Test + @DisplayName("Connection is open after creation") + void openAfterCreation() throws RepositoryException { + try (RepositoryConnection conn = repository.getConnection()) { + assertTrue(conn.isOpen()); + } + } + + @Test + @DisplayName("Connection is closed after close()") + void closedAfterClose() throws RepositoryException { + RepositoryConnection conn = repository.getConnection(); + conn.close(); + assertFalse(conn.isOpen()); + } + + @Test + @DisplayName("prepareTupleQuery on closed connection throws RepositoryException") + void prepareOnClosedConnectionThrows() throws RepositoryException { + RepositoryConnection conn = repository.getConnection(); + conn.close(); + assertThrows(RepositoryException.class, () -> + conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * WHERE { ?s ?p ?o }")); + } + + @Test + @DisplayName("Dataset scoped at connection level") + void connectionDatasetRoundTrip() throws RepositoryException { + try (RepositoryConnection conn = repository.getConnection()) { + Dataset ds = new CoreseDataset().addNamedGraph("http://example.org/g"); + conn.setDataset(ds); + assertSame(ds, conn.getDataset()); + } + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private IRI iri(String iri) { + return vf.createIRI(iri); + } +} 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 new file mode 100644 index 000000000..99d39b87b --- /dev/null +++ b/src/test/java/fr/inria/corese/core/next/query/impl/repository/CoreseRepositoryTest.java @@ -0,0 +1,70 @@ +package fr.inria.corese.core.next.query.impl.repository; + +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.storagemanager.impl.memory.MemoryStorageManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class CoreseRepositoryTest { + + private MemoryStorageManager storage; + private CoreseRepository repository; + + @BeforeEach + void setUp() { + storage = MemoryStorageManager.builder().build(); + repository = new CoreseRepository(storage); + } + + @Nested + @DisplayName("Lifecycle") + class Lifecycle { + + @Test + @DisplayName("Not initialized before init()") + void notInitializedBeforeInit() { + assertFalse(repository.isInitialized()); + } + + @Test + @DisplayName("Initialized after init()") + void initializedAfterInit() throws RepositoryException { + repository.init(); + assertTrue(repository.isInitialized()); + } + + @Test + @DisplayName("init() twice throws IllegalStateException") + void initTwiceThrows() throws RepositoryException { + repository.init(); + assertThrows(IllegalStateException.class, repository::init); + } + + @Test + @DisplayName("getConnection() before init() throws RepositoryException") + void getConnectionBeforeInitThrows() { + assertThrows(RepositoryException.class, repository::getConnection); + } + + @Test + @DisplayName("getConnection() after init() returns open connection") + void getConnectionAfterInit() throws RepositoryException { + repository.init(); + try (RepositoryConnection conn = repository.getConnection()) { + assertTrue(conn.isOpen()); + assertSame(repository, conn.getRepository()); + } + } + + @Test + @DisplayName("getValueFactory() is not null") + void valueFactoryNotNull() { + assertNotNull(repository.getValueFactory()); + } + } +} diff --git a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java index ad02f12eb..6fc6df0b6 100644 --- a/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java +++ b/src/test/java/fr/inria/corese/core/next/query/impl/sparql/execution/NextSparqlPipelineExecutorTest.java @@ -5,13 +5,20 @@ import fr.inria.corese.core.next.data.api.Value; import fr.inria.corese.core.next.data.api.ValueFactory; import fr.inria.corese.core.next.data.impl.adapter.CoreseValueFactory; +import fr.inria.corese.core.next.query.api.dataset.Dataset; +import fr.inria.corese.core.next.query.api.exception.QueryTimeoutException; +import fr.inria.corese.core.next.query.api.result.Binding; +import fr.inria.corese.core.next.query.api.result.BindingSet; import fr.inria.corese.core.next.query.api.result.TupleQueryResult; +import fr.inria.corese.core.next.query.impl.dataset.CoreseDataset; import fr.inria.corese.core.next.storagemanager.impl.memory.MemoryStorageManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.util.Iterator; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -111,11 +118,155 @@ void booleanEvaluationRejectsNonAskQuery() { () -> executor.evaluateBoolean("SELECT * WHERE { ?s ?p ?o }")); } + // ------------------------------------------------------------------------- + // Initial bindings + // ------------------------------------------------------------------------- + + @Test + @DisplayName("SELECT with initial binding filters results to bound variable value") + void selectWithInitialBindingFiltersResults() { + // bob also knows carol — without binding both alice→bob and bob→carol would match ?s + insert(iri(BOB), iri(KNOWS), iri("http://example.org/carol")); + + BindingSet bindings = singleBinding("s", iri(ALICE)); + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?friend WHERE { ?s <" + KNOWS + "> ?friend }", + bindings, null, 0L); + + assertTrue(result.hasNext()); + assertEquals(BOB, result.next().getValue("friend").stringValue()); + assertFalse(result.hasNext(), "Only alice's friends should be returned"); + } + + @Test + @DisplayName("ASK with initial binding returns true when bound triple exists") + void askWithInitialBindingReturnsTrueWhenMatch() { + BindingSet bindings = singleBinding("s", iri(ALICE)); + assertTrue(executor.evaluateBoolean( + "ASK { ?s <" + KNOWS + "> <" + BOB + "> }", + bindings, null, 0L)); + } + + @Test + @DisplayName("ASK with initial binding returns false when bound triple absent") + void askWithInitialBindingReturnsFalseWhenNoMatch() { + BindingSet bindings = singleBinding("s", iri(BOB)); // bob knows nobody + assertFalse(executor.evaluateBoolean( + "ASK { ?s <" + KNOWS + "> ?o }", + bindings, null, 0L)); + } + + // ------------------------------------------------------------------------- + // Dataset restriction + // ------------------------------------------------------------------------- + + @Test + @DisplayName("SELECT with dataset FROM restricts results to the named graph") + void selectWithDatasetFromRestrictsToNamedGraph() { + String graph1 = "http://example.org/graph1"; + String graph2 = "http://example.org/graph2"; + String carol = "http://example.org/carol"; + + // alice→knows→bob in graph1, alice→knows→carol in graph2 + insertInGraph(iri(ALICE), iri(KNOWS), iri(BOB), iri(graph1)); + insertInGraph(iri(ALICE), iri(KNOWS), iri(carol), iri(graph2)); + + Dataset dataset = new CoreseDataset().addDefaultGraph(graph1); + TupleQueryResult result = executor.evaluateTuple( + "SELECT ?o WHERE { <" + ALICE + "> <" + KNOWS + "> ?o }", + null, dataset, 0L); + + assertTrue(result.hasNext()); + assertEquals(BOB, result.next().getValue("o").stringValue()); + assertFalse(result.hasNext(), "Only data from graph1 should be visible"); + } + + @Test + @DisplayName("SELECT with dataset FROM returns nothing when named graph is empty") + void selectWithDatasetFromReturnsNothingForEmptyNamedGraph() { + String emptyGraph = "http://example.org/empty"; + + Dataset dataset = new CoreseDataset().addDefaultGraph(emptyGraph); + TupleQueryResult result = executor.evaluateTuple( + "SELECT * WHERE { ?s ?p ?o }", + null, dataset, 0L); + + assertFalse(result.hasNext(), "No results expected for an empty named graph"); + } + + // ------------------------------------------------------------------------- + // Timeout + // ------------------------------------------------------------------------- + + @Test + @DisplayName("Query with generous timeout completes normally") + void queryWithGenerousTimeoutCompletesNormally() { + TupleQueryResult result = executor.evaluateTuple( + "SELECT * WHERE { ?s ?p ?o }", + null, null, 10_000L); // 10 seconds — far more than needed + + assertTrue(result.hasNext(), "Result should be non-empty"); + } + + @Test + @DisplayName("Query with zero timeout runs without timeout enforcement") + void queryWithZeroTimeoutRunsWithoutEnforcement() { + // timeout = 0 means disabled; must complete normally + TupleQueryResult result = executor.evaluateTuple( + "SELECT * WHERE { ?s ?p ?o }", + null, null, 0L); + + assertTrue(result.hasNext(), "Result should be non-empty"); + } + + @Test + @DisplayName("QueryTimeoutException is thrown when evaluation exceeds deadline") + void queryTimeoutExceptionThrownWhenDeadlineExceeded() { + // Insert many triples to create a cross-product query that takes longer than 1 ms. + for (int i = 0; i < 200; i++) { + insert(iri("http://example.org/s" + i), iri(KNOWS), iri("http://example.org/o" + i)); + } + + // Cross-product of 201 triples × 201 triples = 40 401 combinations — expensive enough + // to reliably exceed a 1 ms timeout on any machine. + assertThrows(QueryTimeoutException.class, () -> + executor.evaluateTuple( + "SELECT * WHERE { ?s1 ?p1 ?o1 . ?s2 ?p2 ?o2 }", + null, null, 1L) // 1 ms + .stream() + .count()); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + private void insert(Resource subject, IRI predicate, Value object) { storage.getMutationOperations().insertStatement(valueFactory.createStatement(subject, predicate, object)); } + private void insertInGraph(Resource subject, IRI predicate, Value object, Resource context) { + storage.getMutationOperations().insertStatement( + valueFactory.createStatement(subject, predicate, object, context)); + } + private IRI iri(String iri) { return valueFactory.createIRI(iri); } + + /** + * Creates a one-entry {@link BindingSet} binding {@code varName} to {@code value}. + */ + private BindingSet singleBinding(String varName, Value value) { + Binding b = new Binding() { + @Override public String name() { return varName; } + @Override public Value value() { return value; } + }; + return new BindingSet() { + @Override public Set getBindingNames() { return Set.of(varName); } + @Override public boolean hasBinding(String name) { return varName.equals(name); } + @Override public Value getValue(String name) { return varName.equals(name) ? value : null; } + @Override public Iterator iterator() { return List.of(b).iterator(); } + }; + } }