diff --git a/driver/clirr-ignored-differences.xml b/driver/clirr-ignored-differences.xml index 424fb74730..e601ce6ce8 100644 --- a/driver/clirr-ignored-differences.xml +++ b/driver/clirr-ignored-differences.xml @@ -409,4 +409,16 @@ org.neo4j.driver.BaseSession session(java.lang.Class, org.neo4j.driver.SessionConfig) + + org/neo4j/driver/Driver + 7012 + org.neo4j.driver.QueryTask queryTask(java.lang.String) + + + + org/neo4j/driver/Driver + 7012 + org.neo4j.driver.BookmarkManager queryBookmarkManager() + + diff --git a/driver/src/main/java/org/neo4j/driver/Config.java b/driver/src/main/java/org/neo4j/driver/Config.java index 45746cbe62..d3ff7c3c25 100644 --- a/driver/src/main/java/org/neo4j/driver/Config.java +++ b/driver/src/main/java/org/neo4j/driver/Config.java @@ -75,6 +75,8 @@ public final class Config implements Serializable { private static final Config EMPTY = builder().build(); + private final BookmarkManager queryBookmarkManager; + /** * User defined logging */ @@ -102,6 +104,7 @@ public final class Config implements Serializable { private final MetricsAdapter metricsAdapter; private Config(ConfigBuilder builder) { + this.queryBookmarkManager = builder.queryBookmarkManager; this.logging = builder.logging; this.logLeakedSessions = builder.logLeakedSessions; @@ -123,6 +126,21 @@ private Config(ConfigBuilder builder) { this.metricsAdapter = builder.metricsAdapter; } + /** + * A {@link BookmarkManager} implementation for the driver to use on + * {@link Driver#queryTask(String)} method and its variants by default. + *

+ * Please note that sessions will not use this automatically, but it is possible to enable it explicitly + * using {@link SessionConfig.Builder#withBookmarkManager(BookmarkManager)}. + * + * @return bookmark manager, must not be {@code null} + * @since 5.5 + */ + @Experimental + public BookmarkManager queryTaskBookmarkManager() { + return queryBookmarkManager; + } + /** * Logging provider * @@ -262,6 +280,8 @@ public String userAgent() { * Used to build new config instances */ public static final class ConfigBuilder { + private BookmarkManager queryBookmarkManager = + BookmarkManagers.defaultManager(BookmarkManagerConfig.builder().build()); private Logging logging = DEV_NULL_LOGGING; private boolean logLeakedSessions; private int maxConnectionPoolSize = PoolSettings.DEFAULT_MAX_CONNECTION_POOL_SIZE; @@ -281,6 +301,24 @@ public static final class ConfigBuilder { private ConfigBuilder() {} + /** + * Sets a {@link BookmarkManager} implementation for the driver to use on + * {@link Driver#queryTask(String)} method and its variants by default. + *

+ * Please note that sessions will not use this automatically, but it is possible to enable it explicitly + * using {@link SessionConfig.Builder#withBookmarkManager(BookmarkManager)}. + * + * @param bookmarkManager bookmark manager, must not be {@code null} + * @return this builder + * @since 5.5 + */ + @Experimental + public ConfigBuilder withQueryTaskBookmarkManager(BookmarkManager bookmarkManager) { + Objects.requireNonNull(bookmarkManager, "bookmarkManager must not be null"); + this.queryBookmarkManager = bookmarkManager; + return this; + } + /** * Provide a logging implementation for the driver to use. Java logging framework {@link java.util.logging} with {@link Level#INFO} is used by default. * Callers are expected to either implement {@link Logging} interface or provide one of the existing implementations available from static factory diff --git a/driver/src/main/java/org/neo4j/driver/Driver.java b/driver/src/main/java/org/neo4j/driver/Driver.java index 3e9a485312..bb4d130cdc 100644 --- a/driver/src/main/java/org/neo4j/driver/Driver.java +++ b/driver/src/main/java/org/neo4j/driver/Driver.java @@ -63,6 +63,26 @@ * @since 1.0 (Modified and Added {@link AsyncSession} and {@link RxSession} since 2.0) */ public interface Driver extends AutoCloseable { + /** + * Creates a new {@link QueryTask} instance that executes an idempotent query in a managed transaction with + * automatic retries on retryable errors. + * + * @param query query string + * @return new query task instance + * @since 5.5 + */ + @Experimental + QueryTask queryTask(String query); + + /** + * Returns an instance of {@link BookmarkManager} used by {@link QueryTask} instances by default. + * + * @return bookmark manager, must not be {@code null} + * @since 5.5 + */ + @Experimental + BookmarkManager queryBookmarkManager(); + /** * Return a flag to indicate whether or not encryption is used for this driver. * @@ -84,6 +104,7 @@ default Session session() { /** * Instantiate a new {@link Session} with a specified {@link SessionConfig session configuration}. * Use {@link SessionConfig#forDatabase(String)} to obtain a general purpose session configuration for the specified database. + * * @param sessionConfig specifies session configurations for this session. * @return a new {@link Session} object. * @see SessionConfig @@ -257,6 +278,7 @@ default AsyncSession asyncSession(SessionConfig sessionConfig) { /** * Returns the driver metrics if metrics reporting is enabled via {@link Config.ConfigBuilder#withDriverMetrics()}. * Otherwise, a {@link ClientException} will be thrown. + * * @return the driver metrics if enabled. * @throws ClientException if the driver metrics reporting is not enabled. */ @@ -281,7 +303,7 @@ default AsyncSession asyncSession(SessionConfig sessionConfig) { /** * This verifies if the driver can connect to a remote server or a cluster * by establishing a network connection with the remote and possibly exchanging a few data before closing the connection. - * + *

* It throws exception if fails to connect. Use the exception to further understand the cause of the connectivity problem. * Note: Even if this method throws an exception, the driver still need to be closed via {@link #close()} to free up all resources. */ @@ -290,7 +312,7 @@ default AsyncSession asyncSession(SessionConfig sessionConfig) { /** * This verifies if the driver can connect to a remote server or cluster * by establishing a network connection with the remote and possibly exchanging a few data before closing the connection. - * + *

* This operation is asynchronous and returns a {@link CompletionStage}. This stage is completed with * {@code null} when the driver connects to the remote server or cluster successfully. * It is completed exceptionally if the driver failed to connect the remote server or cluster. @@ -303,12 +325,14 @@ default AsyncSession asyncSession(SessionConfig sessionConfig) { /** * Returns true if the server or cluster the driver connects to supports multi-databases, otherwise false. + * * @return true if the server or cluster the driver connects to supports multi-databases, otherwise false. */ boolean supportsMultiDb(); /** * Asynchronous check if the server or cluster the driver connects to supports multi-databases. + * * @return a {@link CompletionStage completion stage} that returns true if the server or cluster * the driver connects to supports multi-databases, otherwise false. */ diff --git a/driver/src/main/java/org/neo4j/driver/EagerResult.java b/driver/src/main/java/org/neo4j/driver/EagerResult.java new file mode 100644 index 0000000000..006c67684f --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/EagerResult.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import java.util.List; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.util.Experimental; + +/** + * An in-memory result of executing a Cypher query that has been consumed in full. + * @since 5.5 + */ +@Experimental +public interface EagerResult { + /** + * Returns the keys of the records this result contains. + * + * @return list of keys + */ + List keys(); + + /** + * Returns the list of records this result contains. + * + * @return list of records + */ + List records(); + + /** + * Returns the result summary. + * + * @return result summary + */ + ResultSummary summary(); +} diff --git a/driver/src/main/java/org/neo4j/driver/QueryConfig.java b/driver/src/main/java/org/neo4j/driver/QueryConfig.java new file mode 100644 index 0000000000..71ccb16f93 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/QueryConfig.java @@ -0,0 +1,216 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import static java.util.Objects.requireNonNull; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Objects; +import java.util.Optional; +import org.neo4j.driver.util.Experimental; + +/** + * Query configuration used by {@link Driver#queryTask(String)} and its variants. + * @since 5.5 + */ +@Experimental +public final class QueryConfig implements Serializable { + @Serial + private static final long serialVersionUID = -2632780731598141754L; + + private static final QueryConfig DEFAULT = builder().build(); + + private final RoutingControl routing; + private final String database; + private final String impersonatedUser; + private final BookmarkManager bookmarkManager; + private final boolean useDefaultBookmarkManager; + + /** + * Returns default config value. + * + * @return config value + */ + public static QueryConfig defaultConfig() { + return DEFAULT; + } + + private QueryConfig(Builder builder) { + this.routing = builder.routing; + this.database = builder.database; + this.impersonatedUser = builder.impersonatedUser; + this.bookmarkManager = builder.bookmarkManager; + this.useDefaultBookmarkManager = builder.useDefaultBookmarkManager; + } + + /** + * Creates a new {@link Builder} used to construct a configuration object with default implementation returning + * {@link EagerResult}. + * + * @return a query configuration builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns routing mode for the query. + * + * @return routing mode + */ + public RoutingControl routing() { + return routing; + } + + /** + * Returns target database for the query. + * + * @return target database + */ + public Optional database() { + return Optional.ofNullable(database); + } + + /** + * Returns impersonated user for the query. + * + * @return impersonated user + */ + public Optional impersonatedUser() { + return Optional.ofNullable(impersonatedUser); + } + + /** + * Returns bookmark manager for the query. + * + * @param defaultBookmarkManager default bookmark manager to use when none has been configured explicitly, + * {@link Config#queryTaskBookmarkManager()} as a default value by the driver + * @return bookmark manager + */ + public Optional bookmarkManager(BookmarkManager defaultBookmarkManager) { + requireNonNull(defaultBookmarkManager, "defaultBookmarkManager must not be null"); + return useDefaultBookmarkManager ? Optional.of(defaultBookmarkManager) : Optional.ofNullable(bookmarkManager); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + QueryConfig that = (QueryConfig) o; + return useDefaultBookmarkManager == that.useDefaultBookmarkManager + && routing == that.routing + && Objects.equals(database, that.database) + && Objects.equals(impersonatedUser, that.impersonatedUser) + && Objects.equals(bookmarkManager, that.bookmarkManager); + } + + @Override + public int hashCode() { + return Objects.hash(routing, database, impersonatedUser, bookmarkManager, useDefaultBookmarkManager); + } + + @Override + public String toString() { + return "QueryConfig{" + "routing=" + + routing + ", database='" + + database + '\'' + ", impersonatedUser='" + + impersonatedUser + '\'' + ", bookmarkManager=" + + bookmarkManager + ", useDefaultBookmarkManager=" + + useDefaultBookmarkManager + '}'; + } + + /** + * Builder used to configure {@link QueryConfig} which will be used to execute a query. + */ + public static final class Builder { + private RoutingControl routing = RoutingControl.WRITERS; + private String database; + private String impersonatedUser; + private BookmarkManager bookmarkManager; + private boolean useDefaultBookmarkManager = true; + + private Builder() {} + + /** + * Set routing mode for the query. + * + * @param routing routing mode + * @return this builder + */ + public Builder withRouting(RoutingControl routing) { + requireNonNull(routing, "routing must not be null"); + this.routing = routing; + return this; + } + + /** + * Set target database for the query. + * + * @param database database + * @return this builder + */ + public Builder withDatabase(String database) { + requireNonNull(database, "database must not be null"); + if (database.isEmpty()) { + // Empty string is an illegal database + throw new IllegalArgumentException(String.format("Illegal database '%s'", database)); + } + this.database = database; + return this; + } + + /** + * Set impersonated user for the query. + * + * @param impersonatedUser impersonated user + * @return this builder + */ + public Builder withImpersonatedUser(String impersonatedUser) { + requireNonNull(impersonatedUser, "impersonatedUser must not be null"); + if (impersonatedUser.isEmpty()) { + // Empty string is an illegal user + throw new IllegalArgumentException(String.format("Illegal impersonated user '%s'", impersonatedUser)); + } + this.impersonatedUser = impersonatedUser; + return this; + } + + /** + * Set bookmark manager for the query. + * + * @param bookmarkManager bookmark manager + * @return this builder + */ + public Builder withBookmarkManager(BookmarkManager bookmarkManager) { + useDefaultBookmarkManager = false; + this.bookmarkManager = bookmarkManager; + return this; + } + + /** + * Create a config instance from this builder. + * + * @return a new {@link QueryConfig} instance. + */ + public QueryConfig build() { + return new QueryConfig(this); + } + } +} diff --git a/driver/src/main/java/org/neo4j/driver/QueryTask.java b/driver/src/main/java/org/neo4j/driver/QueryTask.java new file mode 100644 index 0000000000..76f071fcf2 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/QueryTask.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.stream.Collector; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.util.Experimental; + +/** + * A task that executes an idempotent query in a managed transaction with automatic retries on retryable errors. + *

+ * This is a high-level API for executing an idempotent query. There are more advanced APIs available. + * For instance, {@link Session}, {@link Transaction} and transaction functions that are accessible via + * methods like {@link Session#executeWrite(TransactionCallback)}, {@link Session#executeWriteWithoutResult(Consumer)} + * and {@link Session#executeRead(TransactionCallback)} (there are also overloaded options available). + *

+ * Causal consistency is managed via driver's {@link BookmarkManager} that is enabled by default and may + * be replaced using {@link Config.ConfigBuilder#withQueryTaskBookmarkManager(BookmarkManager)}. It is also possible + * to use a different {@link BookmarkManager} or disable it via + * {@link QueryConfig.Builder#withBookmarkManager(BookmarkManager)} on individual basis. + *

+ * Sample usage: + *

+ * {@code
+ * var eagerResult = driver.queryTask("CREATE (n{field: $value}) RETURN n")
+ *         .withParameters(Map.of("$value", "5"))
+ *         .execute();
+ * }
+ * 
+ * The above sample is functionally similar to the following use of the more advanced APIs: + *
+ * {@code
+ * var query = new Query("CREATE (n{field: $value}) RETURN n", Map.of("$value", "5"));
+ * var sessionConfig = SessionConfig.builder()
+ *         .withBookmarkManager(driverConfig.queryBookmarkManager())
+ *         .build();
+ * try (var session = driver.session(sessionConfig)) {
+ *     var eagerResult = session.executeWrite(tx -> {
+ *         var result = tx.run(query);
+ *         return new EagerResultValue(result.keys(), result.stream().toList(), result.consume());
+ *     });
+ * }
+ * }
+ * 
+ * In addition, it is possible to transform query result by using a supplied {@link Collector} implementation. + *

+ * It is strongly recommended to use Cypher query language capabilities where possible. The examples below just + * provide a sample usage of the API. + *

+ * {@code
+ * import static java.util.stream.Collectors.*;
+ *
+ * var averagingLong = driver.queryTask("UNWIND range(0, 5) as N RETURN N")
+ *         .execute(averagingLong(record -> record.get("N").asLong()));
+ *
+ * var filteredValues = driver.queryTask("UNWIND range(0, 5) as N RETURN N")
+ *         .execute(mapping(record -> record.get("N").asLong(), filtering(value -> value > 2, toList())));
+ *
+ * var maxValue = driver.queryTask("UNWIND range(0, 5) as N RETURN N")
+ *         .execute(mapping(record -> record.get("N").asLong(), maxBy(Long::compare)));
+ * }
+ * 
+ * If there is a need to access {@link ResultSummary} value, another method option is available: + *
+ * {@code
+ * import static java.util.stream.Collectors.*;
+ *
+ * private record ResultValue(Set values, ResultSummary summary) {}
+ *
+ * var result = driver.queryTask("UNWIND range(0, 5) as N RETURN N")
+ *                     .execute(Collectors.mapping(record -> record.get("N").asLong(), toSet()), ResultValue::new);
+ * }
+ * 
+ * + * @since 5.5 + */ +@Experimental +public interface QueryTask { + /** + * Sets query parameters. + * + * @param parameters parameters map, must not be {@code null} + * @return a new query task + */ + QueryTask withParameters(Map parameters); + + /** + * Sets {@link QueryConfig}. + *

+ * By default, {@link QueryTask} has {@link QueryConfig#defaultConfig()} value. + * + * @param config query config, must not be {@code null} + * @return a new query task + */ + QueryTask withConfig(QueryConfig config); + + /** + * Executes query, collects all results eagerly and returns a result. + * + * @return an instance of result containing all records, keys and result summary + */ + EagerResult execute(); + + /** + * Executes query, collects {@link Record} values using the provided {@link Collector} and produces a final result. + * + * @param recordCollector collector instance responsible for processing {@link Record} values and producing a + * collected result, the collector may be used multiple times if query is retried + * @param the final result type + * @return the final result value + */ + default T execute(Collector recordCollector) { + return execute(recordCollector, (collectorResult, ignored) -> collectorResult); + } + + /** + * Executes query, collects {@link Record} values using the provided {@link Collector} and produces a final result + * by invoking the provided {@link BiFunction} with the collected result and {@link ResultSummary} values. + * + * @param recordCollector collector instance responsible for processing {@link Record} values and producing a + * collected result, the collector may be used multiple times if query is retried + * @param finisherWithSummary function accepting both the collected result and {@link ResultSummary} values to + * output the final result, the function may be invoked multiple times if query is + * retried + * @param the mutable accumulation type of the collector's reduction operation + * @param the collector's result type + * @param the final result type + * @return the final result value + */ + T execute(Collector recordCollector, BiFunction finisherWithSummary); +} diff --git a/driver/src/main/java/org/neo4j/driver/RoutingControl.java b/driver/src/main/java/org/neo4j/driver/RoutingControl.java new file mode 100644 index 0000000000..e1927cf726 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/RoutingControl.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import org.neo4j.driver.util.Experimental; + +/** + * Defines routing mode for query. + * @since 5.5 + */ +@Experimental +public enum RoutingControl { + /** + * Routes to the leader of the cluster. + */ + WRITERS, + /** + * Routes to the followers in the cluster. + */ + READERS +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java b/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java index c10d586c96..c7acd34b8b 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java +++ b/driver/src/main/java/org/neo4j/driver/internal/DriverFactory.java @@ -269,7 +269,8 @@ protected InternalDriver createRoutingDriver( */ protected InternalDriver createDriver( SecurityPlan securityPlan, SessionFactory sessionFactory, MetricsProvider metricsProvider, Config config) { - return new InternalDriver(securityPlan, sessionFactory, metricsProvider, config.logging()); + return new InternalDriver( + config.queryTaskBookmarkManager(), securityPlan, sessionFactory, metricsProvider, config.logging()); } /** diff --git a/driver/src/main/java/org/neo4j/driver/internal/EagerResultValue.java b/driver/src/main/java/org/neo4j/driver/internal/EagerResultValue.java new file mode 100644 index 0000000000..7e7e2ce8a5 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/EagerResultValue.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal; + +import java.util.List; +import org.neo4j.driver.EagerResult; +import org.neo4j.driver.Record; +import org.neo4j.driver.summary.ResultSummary; + +public record EagerResultValue(List keys, List records, ResultSummary summary) implements EagerResult {} diff --git a/driver/src/main/java/org/neo4j/driver/internal/InternalDriver.java b/driver/src/main/java/org/neo4j/driver/internal/InternalDriver.java index 9bac68e797..dd38245772 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/InternalDriver.java +++ b/driver/src/main/java/org/neo4j/driver/internal/InternalDriver.java @@ -24,10 +24,14 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicBoolean; import org.neo4j.driver.BaseSession; +import org.neo4j.driver.BookmarkManager; import org.neo4j.driver.Driver; import org.neo4j.driver.Logger; import org.neo4j.driver.Logging; import org.neo4j.driver.Metrics; +import org.neo4j.driver.Query; +import org.neo4j.driver.QueryConfig; +import org.neo4j.driver.QueryTask; import org.neo4j.driver.Session; import org.neo4j.driver.SessionConfig; import org.neo4j.driver.async.AsyncSession; @@ -43,6 +47,7 @@ import org.neo4j.driver.types.TypeSystem; public class InternalDriver implements Driver { + private final BookmarkManager queryBookmarkManager; private final SecurityPlan securityPlan; private final SessionFactory sessionFactory; private final Logger log; @@ -51,16 +56,28 @@ public class InternalDriver implements Driver { private final MetricsProvider metricsProvider; InternalDriver( + BookmarkManager queryBookmarkManager, SecurityPlan securityPlan, SessionFactory sessionFactory, MetricsProvider metricsProvider, Logging logging) { + this.queryBookmarkManager = queryBookmarkManager; this.securityPlan = securityPlan; this.sessionFactory = sessionFactory; this.metricsProvider = metricsProvider; this.log = logging.getLog(getClass()); } + @Override + public QueryTask queryTask(String query) { + return new InternalQueryTask(this, new Query(query), QueryConfig.defaultConfig()); + } + + @Override + public BookmarkManager queryBookmarkManager() { + return queryBookmarkManager; + } + @SuppressWarnings({"unchecked", "deprecation"}) @Override public T session(Class sessionClass, SessionConfig sessionConfig) { diff --git a/driver/src/main/java/org/neo4j/driver/internal/InternalQueryTask.java b/driver/src/main/java/org/neo4j/driver/internal/InternalQueryTask.java new file mode 100644 index 0000000000..093e3d1e64 --- /dev/null +++ b/driver/src/main/java/org/neo4j/driver/internal/InternalQueryTask.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal; + +import static java.util.Objects.requireNonNull; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.stream.Collector; +import java.util.stream.Collectors; +import org.neo4j.driver.Driver; +import org.neo4j.driver.EagerResult; +import org.neo4j.driver.Query; +import org.neo4j.driver.QueryConfig; +import org.neo4j.driver.QueryTask; +import org.neo4j.driver.Record; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.TransactionCallback; +import org.neo4j.driver.summary.ResultSummary; + +public class InternalQueryTask implements QueryTask { + private static final BiFunction, ResultSummary, EagerResult> EAGER_RESULT_FINISHER = + (records, summary) -> { + var keys = records.stream().findFirst().map(Record::keys).orElseGet(Collections::emptyList); + return new EagerResultValue(keys, records, summary); + }; + private final Driver driver; + private final Query query; + private final QueryConfig config; + + public InternalQueryTask(Driver driver, Query query, QueryConfig config) { + requireNonNull(driver, "driver must not be null"); + requireNonNull(query, "query must not be null"); + requireNonNull(config, "config must not be null"); + this.driver = driver; + this.query = query; + this.config = config; + } + + @Override + public QueryTask withParameters(Map parameters) { + requireNonNull(parameters, "parameters must not be null"); + return new InternalQueryTask(driver, query.withParameters(parameters), config); + } + + @Override + public QueryTask withConfig(QueryConfig config) { + requireNonNull(config, "config must not be null"); + return new InternalQueryTask(driver, query, config); + } + + @Override + public EagerResult execute() { + return execute(Collectors.toList(), EAGER_RESULT_FINISHER); + } + + @Override + public T execute( + Collector recordCollector, BiFunction finisherWithSummary) { + var sessionConfigBuilder = SessionConfig.builder(); + config.database().ifPresent(sessionConfigBuilder::withDatabase); + config.impersonatedUser().ifPresent(sessionConfigBuilder::withImpersonatedUser); + config.bookmarkManager(driver.queryBookmarkManager()).ifPresent(sessionConfigBuilder::withBookmarkManager); + var supplier = recordCollector.supplier(); + var accumulator = recordCollector.accumulator(); + var finisher = recordCollector.finisher(); + try (var session = driver.session(sessionConfigBuilder.build())) { + TransactionCallback txCallback = tx -> { + var result = tx.run(query); + var container = supplier.get(); + while (result.hasNext()) { + accumulator.accept(container, result.next()); + } + var finishedValue = finisher.apply(container); + var summary = result.consume(); + return finisherWithSummary.apply(finishedValue, summary); + }; + return switch (config.routing()) { + case WRITERS -> session.executeWrite(txCallback); + case READERS -> session.executeRead(txCallback); + }; + } + } + + // For testing only + public Driver driver() { + return driver; + } + + // For testing only + public String query() { + return query.text(); + } + + // For testing only + public Map parameters() { + return query.parameters().asMap(); + } + + // For testing only + public QueryConfig config() { + return config; + } +} diff --git a/driver/src/main/java/org/neo4j/driver/internal/NoOpBookmarkManager.java b/driver/src/main/java/org/neo4j/driver/internal/NoOpBookmarkManager.java index 2671219c3b..a6ec96ae29 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/NoOpBookmarkManager.java +++ b/driver/src/main/java/org/neo4j/driver/internal/NoOpBookmarkManager.java @@ -31,8 +31,12 @@ public class NoOpBookmarkManager implements BookmarkManager { @Serial private static final long serialVersionUID = 7175136719562680362L; + public static final NoOpBookmarkManager INSTANCE = new NoOpBookmarkManager(); + private static final Set EMPTY = Collections.emptySet(); + private NoOpBookmarkManager() {} + @Override public void updateBookmarks(Set previousBookmarks, Set newBookmarks) { // ignored diff --git a/driver/src/main/java/org/neo4j/driver/internal/SessionFactoryImpl.java b/driver/src/main/java/org/neo4j/driver/internal/SessionFactoryImpl.java index aa31e2b5a8..7fd41280ad 100644 --- a/driver/src/main/java/org/neo4j/driver/internal/SessionFactoryImpl.java +++ b/driver/src/main/java/org/neo4j/driver/internal/SessionFactoryImpl.java @@ -61,7 +61,7 @@ public NetworkSession newInstance(SessionConfig sessionConfig) { parseFetchSize(sessionConfig), sessionConfig.impersonatedUser().orElse(null), logging, - sessionConfig.bookmarkManager().orElse(new NoOpBookmarkManager())); + sessionConfig.bookmarkManager().orElse(NoOpBookmarkManager.INSTANCE)); } private Set toDistinctSet(Iterable bookmarks) { diff --git a/driver/src/test/java/org/neo4j/driver/ConfigTest.java b/driver/src/test/java/org/neo4j/driver/ConfigTest.java index e38515650c..098854f655 100644 --- a/driver/src/test/java/org/neo4j/driver/ConfigTest.java +++ b/driver/src/test/java/org/neo4j/driver/ConfigTest.java @@ -51,6 +51,38 @@ import org.neo4j.driver.testutil.TestUtil; class ConfigTest { + @Test + void shouldReturnDefaultBookmarkManager() { + // Given + var config = Config.defaultConfig(); + + // When + var manager = config.queryTaskBookmarkManager(); + + // Then + assertEquals( + BookmarkManagers.defaultManager(BookmarkManagerConfig.builder().build()) + .getClass(), + manager.getClass()); + } + + @Test + void shouldUpdateBookmarkManager() { + // Given + var manager = mock(BookmarkManager.class); + + // When + var config = Config.builder().withQueryTaskBookmarkManager(manager).build(); + + // Then + assertEquals(manager, config.queryTaskBookmarkManager()); + } + + @Test + void shouldNotAllowNullBookmarkManager() { + assertThrows(NullPointerException.class, () -> Config.builder().withQueryTaskBookmarkManager(null)); + } + @Test void shouldDefaultToKnownCerts() { // Given diff --git a/driver/src/test/java/org/neo4j/driver/QueryConfigTest.java b/driver/src/test/java/org/neo4j/driver/QueryConfigTest.java new file mode 100644 index 0000000000..ab4d23a40e --- /dev/null +++ b/driver/src/test/java/org/neo4j/driver/QueryConfigTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.Mockito; +import org.neo4j.driver.summary.ResultSummary; +import org.neo4j.driver.testutil.TestUtil; + +class QueryConfigTest { + @Test + void shouldReturnDefaultValues() { + var config = QueryConfig.defaultConfig(); + var manager = Mockito.mock(BookmarkManager.class); + + assertEquals(RoutingControl.WRITERS, config.routing()); + assertTrue(config.database().isEmpty()); + assertTrue(config.impersonatedUser().isEmpty()); + assertEquals(manager, config.bookmarkManager(manager).get()); + } + + @ParameterizedTest + @EnumSource(RoutingControl.class) + void shouldUpdateRouting(RoutingControl routing) { + var config = QueryConfig.builder().withRouting(routing).build(); + assertEquals(routing, config.routing()); + } + + @Test + void shouldNotAllowNullRouting() { + assertThrows(NullPointerException.class, () -> QueryConfig.builder().withRouting(null)); + } + + @Test + void shouldUpdateDatabaseName() { + var database = "testing"; + var config = QueryConfig.builder().withDatabase(database).build(); + assertTrue(config.database().isPresent()); + assertEquals(database, config.database().get()); + } + + @Test + void shouldNotAllowNullDatabaseName() { + assertThrows(NullPointerException.class, () -> QueryConfig.builder().withDatabase(null)); + } + + @Test + void shouldUpdateImpersonatedUser() { + var user = "testing"; + var config = QueryConfig.builder().withImpersonatedUser(user).build(); + assertTrue(config.impersonatedUser().isPresent()); + assertEquals(user, config.impersonatedUser().get()); + } + + @Test + void shouldAllowNotNullImpersonatedUser() { + assertThrows(NullPointerException.class, () -> QueryConfig.builder().withImpersonatedUser(null)); + } + + @Test + void shouldUpdateBookmarkManager() { + var defaultManager = mock(BookmarkManager.class); + var manager = mock(BookmarkManager.class); + var config = QueryConfig.builder().withBookmarkManager(manager).build(); + assertTrue(config.bookmarkManager(defaultManager).isPresent()); + assertEquals(manager, config.bookmarkManager(defaultManager).get()); + } + + @Test + void shouldAllowNullBookmarkManager() { + var config = QueryConfig.builder().withBookmarkManager(null).build(); + assertTrue(config.bookmarkManager(mock(BookmarkManager.class)).isEmpty()); + } + + @Test + void shouldSerialize() throws Exception { + var originalConfig = QueryConfig.defaultConfig(); + var deserializedConfig = TestUtil.serializeAndReadBack(originalConfig, QueryConfig.class); + var defaultManager = mock(BookmarkManager.class); + + assertEquals(originalConfig.routing(), deserializedConfig.routing()); + assertEquals(originalConfig.database(), deserializedConfig.database()); + assertEquals(originalConfig.impersonatedUser(), deserializedConfig.impersonatedUser()); + assertEquals( + originalConfig.bookmarkManager(defaultManager), deserializedConfig.bookmarkManager(defaultManager)); + } + + record ResultWithSummary(T value, ResultSummary summary) {} +} diff --git a/driver/src/test/java/org/neo4j/driver/internal/InternalDriverTest.java b/driver/src/test/java/org/neo4j/driver/internal/InternalDriverTest.java index 44fefb1077..d528e44fa2 100644 --- a/driver/src/test/java/org/neo4j/driver/internal/InternalDriverTest.java +++ b/driver/src/test/java/org/neo4j/driver/internal/InternalDriverTest.java @@ -18,11 +18,11 @@ */ package org.neo4j.driver.internal; -import static org.junit.Assert.assertTrue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -31,10 +31,14 @@ import static org.neo4j.driver.internal.util.Futures.failedFuture; import static org.neo4j.driver.testutil.TestUtil.await; +import java.util.Collections; import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.Test; +import org.neo4j.driver.BookmarkManagerConfig; +import org.neo4j.driver.BookmarkManagers; import org.neo4j.driver.Config; import org.neo4j.driver.Metrics; +import org.neo4j.driver.QueryConfig; import org.neo4j.driver.exceptions.ClientException; import org.neo4j.driver.exceptions.ServiceUnavailableException; import org.neo4j.driver.internal.metrics.DevNullMetricsProvider; @@ -111,9 +115,30 @@ void shouldReturnMetricsIfMetricsEnabled() { assertNotNull(metrics); } + @Test + void shouldCreateQueryTask() { + // Given + var driver = newDriver(true); + var query = "string"; + + // When + var queryTask = (InternalQueryTask) driver.queryTask(query); + + // Then + assertNotNull(queryTask); + assertEquals(driver, queryTask.driver()); + assertEquals(query, queryTask.query()); + assertEquals(Collections.emptyMap(), queryTask.parameters()); + assertEquals(QueryConfig.defaultConfig(), queryTask.config()); + } + private static InternalDriver newDriver(SessionFactory sessionFactory) { return new InternalDriver( - SecurityPlanImpl.insecure(), sessionFactory, DevNullMetricsProvider.INSTANCE, DEV_NULL_LOGGING); + BookmarkManagers.defaultManager(BookmarkManagerConfig.builder().build()), + SecurityPlanImpl.insecure(), + sessionFactory, + DevNullMetricsProvider.INSTANCE, + DEV_NULL_LOGGING); } private static SessionFactory sessionFactoryMock() { @@ -130,6 +155,11 @@ private static InternalDriver newDriver(boolean isMetricsEnabled) { } MetricsProvider metricsProvider = DriverFactory.getOrCreateMetricsProvider(config, Clock.SYSTEM); - return new InternalDriver(SecurityPlanImpl.insecure(), sessionFactory, metricsProvider, DEV_NULL_LOGGING); + return new InternalDriver( + BookmarkManagers.defaultManager(BookmarkManagerConfig.builder().build()), + SecurityPlanImpl.insecure(), + sessionFactory, + metricsProvider, + DEV_NULL_LOGGING); } } diff --git a/driver/src/test/java/org/neo4j/driver/internal/InternalQueryTaskTest.java b/driver/src/test/java/org/neo4j/driver/internal/InternalQueryTaskTest.java new file mode 100644 index 0000000000..13b13bc540 --- /dev/null +++ b/driver/src/test/java/org/neo4j/driver/internal/InternalQueryTaskTest.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.neo4j.driver.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; + +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collector; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import org.neo4j.driver.BookmarkManager; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Query; +import org.neo4j.driver.QueryConfig; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.RoutingControl; +import org.neo4j.driver.Session; +import org.neo4j.driver.SessionConfig; +import org.neo4j.driver.TransactionCallback; +import org.neo4j.driver.TransactionContext; +import org.neo4j.driver.summary.ResultSummary; + +class InternalQueryTaskTest { + @Test + void shouldNotAcceptNullDriverOnInstantiation() { + assertThrows( + NullPointerException.class, + () -> new InternalQueryTask(null, new Query("string"), QueryConfig.defaultConfig())); + } + + @Test + void shouldNotAcceptNullQueryOnInstantiation() { + assertThrows( + NullPointerException.class, + () -> new InternalQueryTask(mock(Driver.class), null, QueryConfig.defaultConfig())); + } + + @Test + void shouldNotAcceptNullConfigOnInstantiation() { + assertThrows( + NullPointerException.class, () -> new InternalQueryTask(mock(Driver.class), new Query("string"), null)); + } + + @Test + void shouldNotAcceptNullParameters() { + var queryTask = new InternalQueryTask(mock(Driver.class), new Query("string"), QueryConfig.defaultConfig()); + assertThrows(NullPointerException.class, () -> queryTask.withParameters(null)); + } + + @Test + void shouldUpdateParameters() { + // GIVEN + var query = new Query("string"); + var params = Map.of("$param", "value"); + var queryTask = new InternalQueryTask(mock(Driver.class), query, QueryConfig.defaultConfig()); + + // WHEN + queryTask = (InternalQueryTask) queryTask.withParameters(params); + + // THEN + assertEquals(params, queryTask.parameters()); + } + + @Test + void shouldNotAcceptNullConfig() { + var queryTask = new InternalQueryTask(mock(Driver.class), new Query("string"), QueryConfig.defaultConfig()); + assertThrows(NullPointerException.class, () -> queryTask.withConfig(null)); + } + + @Test + void shouldUpdateConfig() { + // GIVEN + var query = new Query("string"); + var queryTask = new InternalQueryTask(mock(Driver.class), query, QueryConfig.defaultConfig()); + var config = QueryConfig.builder().withDatabase("database").build(); + + // WHEN + queryTask = (InternalQueryTask) queryTask.withConfig(config); + + // THEN + assertEquals(config, queryTask.config()); + } + + @ParameterizedTest + @EnumSource(RoutingControl.class) + @SuppressWarnings("unchecked") + void shouldExecuteAndReturnResult(RoutingControl routingControl) { + // GIVEN + var driver = mock(Driver.class); + var bookmarkManager = mock(BookmarkManager.class); + given(driver.queryBookmarkManager()).willReturn(bookmarkManager); + var session = mock(Session.class); + given(driver.session(any(SessionConfig.class))).willReturn(session); + var txContext = mock(TransactionContext.class); + BiFunction, Object> executeMethod = + switch (routingControl) { + case WRITERS -> Session::executeWrite; + case READERS -> Session::executeRead; + }; + given(executeMethod.apply(session, any())).willAnswer(answer -> { + TransactionCallback txCallback = answer.getArgument(0); + return txCallback.execute(txContext); + }); + var result = mock(Result.class); + given(txContext.run(any(Query.class))).willReturn(result); + given(result.hasNext()).willReturn(true, false); + var record = mock(Record.class); + given(result.next()).willReturn(record); + var summary = mock(ResultSummary.class); + given(result.consume()).willReturn(summary); + var query = new Query("string"); + var params = Map.of("$param", "value"); + var config = QueryConfig.builder() + .withDatabase("db") + .withImpersonatedUser("user") + .withRouting(routingControl) + .build(); + Collector recordCollector = mock(Collector.class); + var resultContainer = new Object(); + given(recordCollector.supplier()).willReturn(() -> resultContainer); + BiConsumer accumulator = mock(BiConsumer.class); + given(recordCollector.accumulator()).willReturn(accumulator); + var collectorResult = "0"; + Function finisher = mock(Function.class); + given(finisher.apply(resultContainer)).willReturn(collectorResult); + given(recordCollector.finisher()).willReturn(finisher); + BiFunction finisherWithSummary = mock(BiFunction.class); + var expectedExecuteResult = "1"; + given(finisherWithSummary.apply(any(String.class), any(ResultSummary.class))) + .willReturn(expectedExecuteResult); + var queryTask = new InternalQueryTask(driver, query, config).withParameters(params); + + // WHEN + var executeResult = queryTask.execute(recordCollector, finisherWithSummary); + + // THEN + ArgumentCaptor sessionConfigCapture = ArgumentCaptor.forClass(SessionConfig.class); + then(driver).should().session(sessionConfigCapture.capture()); + var sessionConfig = sessionConfigCapture.getValue(); + var expectedSessionConfig = SessionConfig.builder() + .withDatabase(config.database().get()) + .withImpersonatedUser(config.impersonatedUser().get()) + .withBookmarkManager(bookmarkManager) + .build(); + assertEquals(expectedSessionConfig, sessionConfig); + executeMethod.apply(then(session).should(), any(TransactionCallback.class)); + then(txContext).should().run(query.withParameters(params)); + then(result).should(times(2)).hasNext(); + then(result).should().next(); + then(result).should().consume(); + then(recordCollector).should().supplier(); + then(recordCollector).should().accumulator(); + then(accumulator).should().accept(resultContainer, record); + then(recordCollector).should().finisher(); + then(finisher).should().apply(resultContainer); + then(finisherWithSummary).should().apply(collectorResult, summary); + assertEquals(expectedExecuteResult, executeResult); + } +} diff --git a/driver/src/test/java/org/neo4j/driver/testutil/TestUtil.java b/driver/src/test/java/org/neo4j/driver/testutil/TestUtil.java index a102263adc..a9320edb05 100644 --- a/driver/src/test/java/org/neo4j/driver/testutil/TestUtil.java +++ b/driver/src/test/java/org/neo4j/driver/testutil/TestUtil.java @@ -267,7 +267,7 @@ public static NetworkSession newSession( null, UNLIMITED_FETCH_SIZE, DEV_NULL_LOGGING, - new NoOpBookmarkManager()); + NoOpBookmarkManager.INSTANCE); } public static void verifyRunRx(Connection connection, String query) { diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ExecuteQuery.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ExecuteQuery.java new file mode 100644 index 0000000000..4def2736fa --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ExecuteQuery.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.requests; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletionStage; +import lombok.Getter; +import lombok.Setter; +import neo4j.org.testkit.backend.TestkitState; +import neo4j.org.testkit.backend.messages.requests.deserializer.TestkitCypherParamDeserializer; +import neo4j.org.testkit.backend.messages.responses.EagerResult; +import neo4j.org.testkit.backend.messages.responses.Record; +import neo4j.org.testkit.backend.messages.responses.TestkitResponse; +import org.neo4j.driver.QueryConfig; +import org.neo4j.driver.RoutingControl; +import reactor.core.publisher.Mono; + +@Setter +@Getter +public class ExecuteQuery implements TestkitRequest { + private ExecuteQueryBody data; + + @Override + public TestkitResponse process(TestkitState testkitState) { + var driver = testkitState.getDriverHolder(data.getDriverId()).getDriver(); + var configBuilder = QueryConfig.builder(); + var routing = data.getConfig().getRouting(); + if (data.getConfig().getRouting() != null) { + switch (routing) { + case "w" -> configBuilder.withRouting(RoutingControl.WRITERS); + case "r" -> configBuilder.withRouting(RoutingControl.READERS); + default -> throw new IllegalArgumentException(); + } + } + var database = data.getConfig().getDatabase(); + if (database != null) { + configBuilder.withDatabase(database); + } + var impersonatedUser = data.getConfig().getImpersonatedUser(); + if (impersonatedUser != null) { + configBuilder.withImpersonatedUser(impersonatedUser); + } + var bookmarkManagerId = data.getConfig().getBookmarkManagerId(); + if (bookmarkManagerId != null) { + var bookmarkManager = + bookmarkManagerId.equals("-1") ? null : testkitState.getBookmarkManager(bookmarkManagerId); + configBuilder.withBookmarkManager(bookmarkManager); + } + var params = data.getParams() != null ? data.getParams() : Collections.emptyMap(); + var eagerResult = driver.queryTask(data.getCypher()) + .withParameters(params) + .withConfig(configBuilder.build()) + .execute(); + + return EagerResult.builder() + .data(EagerResult.EagerResultBody.builder() + .keys(eagerResult.keys()) + .records(eagerResult.records().stream() + .map(record -> Record.RecordBody.builder() + .values(record) + .build()) + .toList()) + .summary(SummaryUtil.toSummaryBody(eagerResult.summary())) + .build()) + .build(); + } + + @Override + public CompletionStage processAsync(TestkitState testkitState) { + throw new UnsupportedOperationException("Operation not supported"); + } + + @Override + public Mono processRx(TestkitState testkitState) { + throw new UnsupportedOperationException("Operation not supported"); + } + + @Override + public Mono processReactive(TestkitState testkitState) { + throw new UnsupportedOperationException("Operation not supported"); + } + + @Override + public Mono processReactiveStreams(TestkitState testkitState) { + throw new UnsupportedOperationException("Operation not supported"); + } + + @Setter + @Getter + public static class ExecuteQueryBody { + private String driverId; + private String cypher; + + @JsonDeserialize(using = TestkitCypherParamDeserializer.class) + private Map params; + + private QueryConfigData config; + } + + @Setter + @Getter + public static class QueryConfigData { + private String database; + private String routing; + private String impersonatedUser; + private String bookmarkManagerId; + } +} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java index 035821944a..89919f6214 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/GetFeatures.java @@ -66,7 +66,8 @@ public class GetFeatures implements TestkitRequest { "Feature:API:Result.List", "Feature:API:Result.Peek", "Optimization:ResultListFetchAll", - "Feature:API:Result.Single")); + "Feature:API:Result.Single", + "Feature:API:Driver.ExecuteQuery")); private static final Set ASYNC_FEATURES = new HashSet<>(Arrays.asList( "Feature:Bolt:3.0", diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ResultConsume.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ResultConsume.java index 1f5e81acad..b2575a03cb 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ResultConsume.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/ResultConsume.java @@ -20,27 +20,16 @@ import static reactor.adapter.JdkFlowAdapter.flowPublisherToFlux; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import java.util.concurrent.CompletionStage; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.stream.Collectors; import lombok.Getter; import lombok.Setter; import neo4j.org.testkit.backend.TestkitState; import neo4j.org.testkit.backend.messages.responses.NullRecord; import neo4j.org.testkit.backend.messages.responses.Summary; import neo4j.org.testkit.backend.messages.responses.TestkitResponse; -import org.neo4j.driver.Query; import org.neo4j.driver.Result; import org.neo4j.driver.exceptions.NoSuchRecordException; -import org.neo4j.driver.summary.InputPosition; -import org.neo4j.driver.summary.Plan; -import org.neo4j.driver.summary.ProfiledPlan; -import org.neo4j.driver.summary.QueryType; -import org.neo4j.driver.summary.SummaryCounters; +import org.neo4j.driver.summary.ResultSummary; import reactor.core.publisher.Mono; @Setter @@ -94,62 +83,8 @@ public Mono processReactiveStreams(TestkitState testkitState) { .map(this::createResponse); } - private Summary createResponse(org.neo4j.driver.summary.ResultSummary summary) { - Summary.ServerInfo serverInfo = Summary.ServerInfo.builder() - .address(summary.server().address()) - .protocolVersion(summary.server().protocolVersion()) - .agent(summary.server().agent()) - .build(); - SummaryCounters summaryCounters = summary.counters(); - Summary.Counters counters = Summary.Counters.builder() - .constraintsAdded(summaryCounters.constraintsAdded()) - .constraintsRemoved(summaryCounters.constraintsRemoved()) - .containsSystemUpdates(summaryCounters.containsSystemUpdates()) - .containsUpdates(summaryCounters.containsUpdates()) - .indexesAdded(summaryCounters.indexesAdded()) - .indexesRemoved(summaryCounters.indexesRemoved()) - .labelsAdded(summaryCounters.labelsAdded()) - .labelsRemoved(summaryCounters.labelsRemoved()) - .nodesCreated(summaryCounters.nodesCreated()) - .nodesDeleted(summaryCounters.nodesDeleted()) - .propertiesSet(summaryCounters.propertiesSet()) - .relationshipsCreated(summaryCounters.relationshipsCreated()) - .relationshipsDeleted(summaryCounters.relationshipsDeleted()) - .systemUpdates(summaryCounters.systemUpdates()) - .build(); - Query summaryQuery = summary.query(); - Summary.Query query = Summary.Query.builder() - .text(summaryQuery.text()) - .parameters(summaryQuery.parameters().asMap(Function.identity(), null)) - .build(); - List notifications = summary.notifications().stream() - .map(s -> Summary.Notification.builder() - .code(s.code()) - .title(s.title()) - .description(s.description()) - .position(toInputPosition(s.position())) - .severity(s.severity()) - .build()) - .collect(Collectors.toList()); - Summary.SummaryBody data = Summary.SummaryBody.builder() - .serverInfo(serverInfo) - .counters(counters) - .query(query) - .database(summary.database().name()) - .notifications(notifications) - .plan(toPlan(summary.plan())) - .profile(toProfile(summary.profile())) - .queryType(toQueryType(summary.queryType())) - .resultAvailableAfter( - summary.resultAvailableAfter(TimeUnit.MILLISECONDS) == -1 - ? null - : summary.resultAvailableAfter(TimeUnit.MILLISECONDS)) - .resultConsumedAfter( - summary.resultConsumedAfter(TimeUnit.MILLISECONDS) == -1 - ? null - : summary.resultConsumedAfter(TimeUnit.MILLISECONDS)) - .build(); - return Summary.builder().data(data).build(); + private Summary createResponse(ResultSummary summary) { + return Summary.builder().data(SummaryUtil.toSummaryBody(summary)).build(); } @Setter @@ -157,70 +92,4 @@ private Summary createResponse(org.neo4j.driver.summary.ResultSummary summary) { public static class ResultConsumeBody { private String resultId; } - - private static Summary.InputPosition toInputPosition(InputPosition position) { - if (position == null) { - return null; - } - return Summary.InputPosition.builder() - .offset(position.offset()) - .line(position.line()) - .column(position.column()) - .build(); - } - - private static Summary.Plan toPlan(Plan plan) { - if (plan == null) { - return null; - } - Map args = new HashMap<>(); - plan.arguments().forEach((key, value) -> args.put(key, value.asObject())); - return Summary.Plan.builder() - .operatorType(plan.operatorType()) - .args(args) - .identifiers(plan.identifiers()) - .children(plan.children().stream().map(ResultConsume::toPlan).collect(Collectors.toList())) - .build(); - } - - private static Summary.Profile toProfile(ProfiledPlan plan) { - if (plan == null) { - return null; - } - Map args = new HashMap<>(); - plan.arguments().forEach((key, value) -> args.put(key, value.asObject())); - return Summary.Profile.builder() - .operatorType(plan.operatorType()) - .args(args) - .identifiers(plan.identifiers()) - .dbHits(plan.dbHits()) - .rows(plan.records()) - .hasPageCacheStats(plan.hasPageCacheStats()) - .pageCacheHits(plan.pageCacheHits()) - .pageCacheMisses(plan.pageCacheMisses()) - .pageCacheHitRatio(plan.pageCacheHitRatio()) - .time(plan.time()) - .children(plan.children().stream().map(ResultConsume::toProfile).collect(Collectors.toList())) - .build(); - } - - private static String toQueryType(QueryType type) { - if (type == null) { - return null; - } - - String typeStr; - if (type == QueryType.READ_ONLY) { - typeStr = "r"; - } else if (type == QueryType.READ_WRITE) { - typeStr = "rw"; - } else if (type == QueryType.WRITE_ONLY) { - typeStr = "w"; - } else if (type == QueryType.SCHEMA_WRITE) { - typeStr = "s"; - } else { - throw new IllegalStateException("Unexpected query type"); - } - return typeStr; - } } diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/SummaryUtil.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/SummaryUtil.java new file mode 100644 index 0000000000..16a333d3ec --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/SummaryUtil.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.requests; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import neo4j.org.testkit.backend.messages.responses.Summary; +import org.neo4j.driver.summary.InputPosition; +import org.neo4j.driver.summary.Plan; +import org.neo4j.driver.summary.ProfiledPlan; +import org.neo4j.driver.summary.QueryType; + +public class SummaryUtil { + public static Summary.SummaryBody toSummaryBody(org.neo4j.driver.summary.ResultSummary summary) { + var serverInfo = Summary.ServerInfo.builder() + .address(summary.server().address()) + .protocolVersion(summary.server().protocolVersion()) + .agent(summary.server().agent()) + .build(); + var summaryCounters = summary.counters(); + var counters = Summary.Counters.builder() + .constraintsAdded(summaryCounters.constraintsAdded()) + .constraintsRemoved(summaryCounters.constraintsRemoved()) + .containsSystemUpdates(summaryCounters.containsSystemUpdates()) + .containsUpdates(summaryCounters.containsUpdates()) + .indexesAdded(summaryCounters.indexesAdded()) + .indexesRemoved(summaryCounters.indexesRemoved()) + .labelsAdded(summaryCounters.labelsAdded()) + .labelsRemoved(summaryCounters.labelsRemoved()) + .nodesCreated(summaryCounters.nodesCreated()) + .nodesDeleted(summaryCounters.nodesDeleted()) + .propertiesSet(summaryCounters.propertiesSet()) + .relationshipsCreated(summaryCounters.relationshipsCreated()) + .relationshipsDeleted(summaryCounters.relationshipsDeleted()) + .systemUpdates(summaryCounters.systemUpdates()) + .build(); + var summaryQuery = summary.query(); + var query = Summary.Query.builder() + .text(summaryQuery.text()) + .parameters(summaryQuery.parameters().asMap(Function.identity(), null)) + .build(); + var notifications = summary.notifications().stream() + .map(s -> Summary.Notification.builder() + .code(s.code()) + .title(s.title()) + .description(s.description()) + .position(toInputPosition(s.position())) + .severity(s.severity()) + .build()) + .collect(Collectors.toList()); + return Summary.SummaryBody.builder() + .serverInfo(serverInfo) + .counters(counters) + .query(query) + .database(summary.database().name()) + .notifications(notifications) + .plan(toPlan(summary.plan())) + .profile(toProfile(summary.profile())) + .queryType(toQueryType(summary.queryType())) + .resultAvailableAfter( + summary.resultAvailableAfter(TimeUnit.MILLISECONDS) == -1 + ? null + : summary.resultAvailableAfter(TimeUnit.MILLISECONDS)) + .resultConsumedAfter( + summary.resultConsumedAfter(TimeUnit.MILLISECONDS) == -1 + ? null + : summary.resultConsumedAfter(TimeUnit.MILLISECONDS)) + .build(); + } + + private static Summary.InputPosition toInputPosition(InputPosition position) { + if (position == null) { + return null; + } + return Summary.InputPosition.builder() + .offset(position.offset()) + .line(position.line()) + .column(position.column()) + .build(); + } + + private static Summary.Plan toPlan(Plan plan) { + if (plan == null) { + return null; + } + Map args = new HashMap<>(); + plan.arguments().forEach((key, value) -> args.put(key, value.asObject())); + return Summary.Plan.builder() + .operatorType(plan.operatorType()) + .args(args) + .identifiers(plan.identifiers()) + .children(plan.children().stream().map(SummaryUtil::toPlan).collect(Collectors.toList())) + .build(); + } + + private static Summary.Profile toProfile(ProfiledPlan plan) { + if (plan == null) { + return null; + } + Map args = new HashMap<>(); + plan.arguments().forEach((key, value) -> args.put(key, value.asObject())); + return Summary.Profile.builder() + .operatorType(plan.operatorType()) + .args(args) + .identifiers(plan.identifiers()) + .dbHits(plan.dbHits()) + .rows(plan.records()) + .hasPageCacheStats(plan.hasPageCacheStats()) + .pageCacheHits(plan.pageCacheHits()) + .pageCacheMisses(plan.pageCacheMisses()) + .pageCacheHitRatio(plan.pageCacheHitRatio()) + .time(plan.time()) + .children(plan.children().stream().map(SummaryUtil::toProfile).collect(Collectors.toList())) + .build(); + } + + private static String toQueryType(QueryType type) { + if (type == null) { + return null; + } + + String typeStr; + if (type == QueryType.READ_ONLY) { + typeStr = "r"; + } else if (type == QueryType.READ_WRITE) { + typeStr = "rw"; + } else if (type == QueryType.WRITE_ONLY) { + typeStr = "w"; + } else if (type == QueryType.SCHEMA_WRITE) { + typeStr = "s"; + } else { + throw new IllegalStateException("Unexpected query type"); + } + return typeStr; + } +} diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java index 341894af75..eff8ee1eef 100644 --- a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/requests/TestkitRequest.java @@ -61,7 +61,8 @@ @JsonSubTypes.Type(BookmarksSupplierCompleted.class), @JsonSubTypes.Type(BookmarksConsumerCompleted.class), @JsonSubTypes.Type(NewBookmarkManager.class), - @JsonSubTypes.Type(BookmarkManagerClose.class) + @JsonSubTypes.Type(BookmarkManagerClose.class), + @JsonSubTypes.Type(ExecuteQuery.class) }) public interface TestkitRequest { TestkitResponse process(TestkitState testkitState); diff --git a/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/EagerResult.java b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/EagerResult.java new file mode 100644 index 0000000000..617ea6b6c7 --- /dev/null +++ b/testkit-backend/src/main/java/neo4j/org/testkit/backend/messages/responses/EagerResult.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package neo4j.org.testkit.backend.messages.responses; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class EagerResult implements TestkitResponse { + private EagerResultBody data; + + @Override + public String testkitName() { + return "EagerResult"; + } + + @Getter + @Builder + public static class EagerResultBody { + private final List keys; + private final List records; + private final Summary.SummaryBody summary; + } +}