diff --git a/pom.xml b/pom.xml index 665d56cc..b23ec822 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-r2dbc - 1.0.0.BUILD-SNAPSHOT + 1.0.0.gh-23-SNAPSHOT Spring Data R2DBC Spring Data module for R2DBC. diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index a475570d..b47c2ee7 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -1,10 +1,15 @@ [[new-features]] = New & Noteworthy +[[new-features.1-0-0-M2]] +== What's New in Spring Data R2DBC 1.0.0 M2 + +* Support for named parameters. + [[new-features.1-0-0-M1]] == What's New in Spring Data R2DBC 1.0.0 M1 -* Initial R2DBC support through `DatabaseClient` -* Initial Transaction support through `TransactionalDatabaseClient` -* Initial R2DBC Repository Support through `R2dbcRepository` -* Initial Dialect support for Postgres and Microsoft SQL Server +* Initial R2DBC support through `DatabaseClient`. +* Initial Transaction support through `TransactionalDatabaseClient`. +* Initial R2DBC Repository Support through `R2dbcRepository`. +* Initial Dialect support for Postgres and Microsoft SQL Server. diff --git a/src/main/asciidoc/reference/r2dbc-repositories.adoc b/src/main/asciidoc/reference/r2dbc-repositories.adoc index 7a46cf07..3a243aee 100644 --- a/src/main/asciidoc/reference/r2dbc-repositories.adoc +++ b/src/main/asciidoc/reference/r2dbc-repositories.adoc @@ -104,7 +104,7 @@ Defining such a query is a matter of declaring a method on the repository interf ---- public interface PersonRepository extends ReactiveCrudRepository { - @Query("SELECT * FROM person WHERE lastname = $1") + @Query("SELECT * FROM person WHERE lastname = :lastname") Flux findByLastname(String lastname); <1> @Query("SELECT firstname, lastname FROM person WHERE lastname = $1") @@ -114,10 +114,10 @@ public interface PersonRepository extends ReactiveCrudRepository { ---- <1> The `findByLastname` method shows a query for all people with the given last name. The query is provided as R2DBC repositories do not support query derivation. +<2> A query for a single `Person` entity projecting only `firstname` and `lastname` columns. The annotated query uses native bind markers, which are Postgres bind markers in this example. -<4> A query for a single `Person` entity projecting only `firstname` and `lastname` columns. ==== NOTE: R2DBC repositories do not support query derivation. -NOTE: R2DBC repositories require native parameter bind markers that are bound by index. +NOTE: R2DBC repositories bind internally parameters to placeholders via `Statement.bind(…)` by index. diff --git a/src/main/asciidoc/reference/r2dbc.adoc b/src/main/asciidoc/reference/r2dbc.adoc index b5d47cea..55c737ca 100644 --- a/src/main/asciidoc/reference/r2dbc.adoc +++ b/src/main/asciidoc/reference/r2dbc.adoc @@ -446,20 +446,64 @@ Parameter binding supports various binding strategies: * By Index using zero-based parameter indexes. * By Name using the placeholder name. -The following example shows parameter binding for a PostgreSQL query: +The following example shows parameter binding for a query: [source,java] ---- db.execute() - .sql("INSERT INTO person (id, name, age) VALUES($1, $2, $3)") - .bind(0, "joe") - .bind(1, "Joe") - .bind(2, 34); + .sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)") + .bind("id", "joe") + .bind("name", "Joe") + .bind("age", 34); ---- -NOTE: If you are familiar with JDBC, then you're also familiar with `?` (question mark) bind markers. -JDBC drivers translate question mark bind markers to database-native markers as part of statement execution. -Make sure to use the appropriate bind markers that are supported by your database as R2DBC requires database-native parameter bind markers. +.R2DBC Native Bind Markers +**** +R2DBC uses database-native bind markers that depend on the actual database vendor. +As an example, Postgres uses indexed markers such as `$1`, `$2`, `$n`. +Another example is SQL Server that uses named bind markers prefixed with `@` (at). + +This is different from JDBC which requires `?` (question mark) as bind markers. +In JDBC, the actual drivers translate question mark bind markers to database-native markers as part of their statement execution. + +Spring Data R2DBC allows you to use native bind markers or named bind markers with the `:name` syntax. + +Named parameter support leverages ``Dialect``s to expand named parameters to native bind markers at the time of query execution which gives you a certain degree of query portability across various database vendors. +**** + +The query-preprocessor unrolls named `Collection` parameters into a series of bind markers to remove the need of dynamic query creation based on the number of arguments. +Nested object arrays are expanded to allow usage of e.g. select lists. + +Consider the following query: + +[source,sql] +---- +SELECT id, name, state FROM table WHERE (name, age) IN (('John', 35), ('Ann', 50)) +---- + +This query can be parametrized and executed as: + +[source,java] +---- +List tuples = new ArrayList<>(); +tuples.add(new Object[] {"John", 35}); +tuples.add(new Object[] {"Ann", 50}); + +db.execute() + .sql("SELECT id, name, state FROM table WHERE (name, age) IN (:tuples)") + .bind("tuples", tuples); +---- + +NOTE: Usage of select lists is vendor-dependent. + +A simpler variant using `IN` predicates: + +[source,java] +---- +db.execute() + .sql("SELECT id, name, state FROM table WHERE age IN (:ages)") + .bind("ages", Arrays.asList(35, 50)); +---- [[r2dbc.datbaseclient.transactions]] === Transactions @@ -478,14 +522,14 @@ TransactionalDatabaseClient databaseClient = TransactionalDatabaseClient.create( Flux completion = databaseClient.inTransaction(db -> { - return db.execute().sql("INSERT INTO person (id, name, age) VALUES($1, $2, $3)") // - .bind(0, "joe") // - .bind(1, "Joe") // - .bind(2, 34) // + return db.execute().sql("INSERT INTO person (id, name, age) VALUES(:id, :name, :age)") + .bind("id", "joe") + .bind("name", "Joe") + .bind("age", 34) .fetch().rowsUpdated() - .then(db.execute().sql("INSERT INTO contacts (id, name) VALUES($1, $2)") - .bind(0, "joe") - .bind(1, "Joe") + .then(db.execute().sql("INSERT INTO contacts (id, name) VALUES(:id, :name)") + .bind("id", "joe") + .bind("name", "Joe") .fetch().rowsUpdated()) .then(); }); diff --git a/src/main/java/org/springframework/data/r2dbc/function/BindParameterSource.java b/src/main/java/org/springframework/data/r2dbc/function/BindParameterSource.java new file mode 100644 index 00000000..c89eae8b --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/function/BindParameterSource.java @@ -0,0 +1,61 @@ +/* + * Copyright 2019 the original author or authors. + * + * 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.springframework.data.r2dbc.function; + +import org.springframework.lang.Nullable; + +/** + * Interface that defines common functionality for objects that can offer parameter values for named bind parameters, + * serving as argument for {@link NamedParameterExpander} operations. + *

+ * This interface allows for the specification of the type in addition to parameter values. All parameter values and + * types are identified by specifying the name of the parameter. + *

+ * Intended to wrap various implementations like a {@link java.util.Map} with a consistent interface. + * + * @author Mark Paluch + * @see MapBindParameterSource + */ +public interface BindParameterSource { + + /** + * Determine whether there is a value for the specified named parameter. + * + * @param paramName the name of the parameter. + * @return {@literal true} if there is a value defined; {@literal false} otherwise. + */ + boolean hasValue(String paramName); + + /** + * Return the parameter value for the requested named parameter. + * + * @param paramName the name of the parameter. + * @return the value of the specified parameter, can be {@literal null}. + * @throws IllegalArgumentException if there is no value for the requested parameter. + */ + @Nullable + Object getValue(String paramName) throws IllegalArgumentException; + + /** + * Determine the type for the specified named parameter. + * + * @param paramName the name of the parameter. + * @return the type of the specified parameter, or {@link Object#getClass()} if not known. + */ + default Class getType(String paramName) { + return Object.class; + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/function/DatabaseClient.java b/src/main/java/org/springframework/data/r2dbc/function/DatabaseClient.java index 5893c3c2..fb4f48bc 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DatabaseClient.java @@ -109,6 +109,16 @@ interface Builder { */ Builder dataAccessStrategy(ReactiveDataAccessStrategy accessStrategy); + /** + * Configures {@link NamedParameterExpander}. + * + * @param expander must not be {@literal null}. + * @return {@code this} {@link Builder}. + * @see NamedParameterExpander#enabled() + * @see NamedParameterExpander#disabled() + */ + Builder namedParameters(NamedParameterExpander expander); + /** * Configures a {@link Consumer} to configure this builder. * @@ -124,7 +134,12 @@ interface Builder { } /** - * Contract for specifying a SQL call along with options leading to the exchange. + * Contract for specifying a SQL call along with options leading to the exchange. The SQL string can contain either + * native parameter bind markers (e.g. {@literal $1, $2} for Postgres, {@literal @P0, @P1} for SQL Server) or named + * parameters (e.g. {@literal :foo, :bar}) when {@link NamedParameterExpander} is enabled. + * + * @see NamedParameterExpander + * @see DatabaseClient.Builder#namedParameters(NamedParameterExpander) */ interface SqlSpec { diff --git a/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClient.java b/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClient.java index 34c8e825..27f3c250 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClient.java @@ -66,9 +66,6 @@ */ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { - /** - * Logger available to subclasses - */ private final Log logger = LogFactory.getLog(getClass()); private final ConnectionFactory connector; @@ -77,14 +74,18 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { private final ReactiveDataAccessStrategy dataAccessStrategy; + private final NamedParameterExpander namedParameters; + private final DefaultDatabaseClientBuilder builder; DefaultDatabaseClient(ConnectionFactory connector, R2dbcExceptionTranslator exceptionTranslator, - ReactiveDataAccessStrategy dataAccessStrategy, DefaultDatabaseClientBuilder builder) { + ReactiveDataAccessStrategy dataAccessStrategy, NamedParameterExpander namedParameters, + DefaultDatabaseClientBuilder builder) { this.connector = connector; this.exceptionTranslator = exceptionTranslator; this.dataAccessStrategy = dataAccessStrategy; + this.namedParameters = namedParameters; this.builder = builder; } @@ -253,21 +254,30 @@ protected DefaultGenericExecuteSpec createGenericExecuteSpec(Supplier sq private static void doBind(Statement statement, Map byName, Map byIndex) { - byIndex.forEach((i, o) -> { + bindByIndex(statement, byIndex); + bindByName(statement, byName); + } + + private static void bindByName(Statement statement, Map byName) { + + byName.forEach((name, o) -> { if (o.getValue() != null) { - statement.bind(i.intValue(), o.getValue()); + statement.bind(name, o.getValue()); } else { - statement.bindNull(i.intValue(), o.getType()); + statement.bindNull(name, o.getType()); } }); + } - byName.forEach((name, o) -> { + private static void bindByIndex(Statement statement, Map byIndex) { + + byIndex.forEach((i, o) -> { if (o.getValue() != null) { - statement.bind(name, o.getValue()); + statement.bind(i.intValue(), o.getValue()); } else { - statement.bindNull(name, o.getType()); + statement.bindNull(i.intValue(), o.getType()); } }); } @@ -325,8 +335,21 @@ FetchSpec exchange(String sql, BiFunction mappingFun logger.debug("Executing SQL statement [" + sql + "]"); } - Statement statement = it.createStatement(sql); - doBind(statement, byName, byIndex); + BindableOperation operation = namedParameters.expand(sql, dataAccessStrategy.getBindMarkersFactory(), + new MapBindParameterSource(byName)); + + Statement statement = it.createStatement(operation.toQuery()); + + byName.forEach((name, o) -> { + + if (o.getValue() != null) { + operation.bind(statement, name, o.getValue()); + } else { + operation.bindNull(statement, name, o.getType()); + } + }); + + bindByIndex(statement, byIndex); return statement; }; diff --git a/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClientBuilder.java b/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClientBuilder.java index 5d1beab9..224771d4 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClientBuilder.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClientBuilder.java @@ -38,6 +38,7 @@ class DefaultDatabaseClientBuilder implements DatabaseClient.Builder { private @Nullable ConnectionFactory connectionFactory; private @Nullable R2dbcExceptionTranslator exceptionTranslator; private ReactiveDataAccessStrategy accessStrategy; + private NamedParameterExpander namedParameters; DefaultDatabaseClientBuilder() {} @@ -48,8 +49,13 @@ class DefaultDatabaseClientBuilder implements DatabaseClient.Builder { this.connectionFactory = other.connectionFactory; this.exceptionTranslator = other.exceptionTranslator; this.accessStrategy = other.accessStrategy; + this.namedParameters = other.namedParameters; } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#connectionFactory(io.r2dbc.spi.ConnectionFactory) + */ @Override public Builder connectionFactory(ConnectionFactory factory) { @@ -59,6 +65,10 @@ public Builder connectionFactory(ConnectionFactory factory) { return this; } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#exceptionTranslator(org.springframework.data.r2dbc.support.R2dbcExceptionTranslator) + */ @Override public Builder exceptionTranslator(R2dbcExceptionTranslator exceptionTranslator) { @@ -68,6 +78,10 @@ public Builder exceptionTranslator(R2dbcExceptionTranslator exceptionTranslator) return this; } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#dataAccessStrategy(org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy) + */ @Override public Builder dataAccessStrategy(ReactiveDataAccessStrategy accessStrategy) { @@ -77,6 +91,23 @@ public Builder dataAccessStrategy(ReactiveDataAccessStrategy accessStrategy) { return this; } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#namedParameters(org.springframework.data.r2dbc.function.NamedParameterExpander) + */ + @Override + public Builder namedParameters(NamedParameterExpander expander) { + + Assert.notNull(expander, "NamedParameterExpander must not be null!"); + + this.namedParameters = expander; + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#build() + */ @Override public DatabaseClient build() { @@ -97,19 +128,35 @@ public DatabaseClient build() { accessStrategy = new DefaultReactiveDataAccessStrategy(dialect); } - return doBuild(this.connectionFactory, exceptionTranslator, accessStrategy, new DefaultDatabaseClientBuilder(this)); + NamedParameterExpander namedParameters = this.namedParameters; + + if (namedParameters == null) { + namedParameters = NamedParameterExpander.enabled(); + } + + return doBuild(this.connectionFactory, exceptionTranslator, accessStrategy, namedParameters, + new DefaultDatabaseClientBuilder(this)); } protected DatabaseClient doBuild(ConnectionFactory connector, R2dbcExceptionTranslator exceptionTranslator, - ReactiveDataAccessStrategy accessStrategy, DefaultDatabaseClientBuilder builder) { - return new DefaultDatabaseClient(connector, exceptionTranslator, accessStrategy, builder); + ReactiveDataAccessStrategy accessStrategy, NamedParameterExpander namedParameters, + DefaultDatabaseClientBuilder builder) { + return new DefaultDatabaseClient(connector, exceptionTranslator, accessStrategy, namedParameters, builder); } + /* + * (non-Javadoc) + * @see java.lang.Object#clone() + */ @Override public DatabaseClient.Builder clone() { return new DefaultDatabaseClientBuilder(this); } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DatabaseClient.Builder#apply(java.util.function.Consumer) + */ @Override public DatabaseClient.Builder apply(Consumer builderConsumer) { Assert.notNull(builderConsumer, "BuilderConsumer must not be null"); diff --git a/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java b/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java index 72059aae..6a56cea2 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java @@ -41,6 +41,7 @@ import org.springframework.data.r2dbc.dialect.ArrayColumns; import org.springframework.data.r2dbc.dialect.BindMarker; import org.springframework.data.r2dbc.dialect.BindMarkers; +import org.springframework.data.r2dbc.dialect.BindMarkersFactory; import org.springframework.data.r2dbc.dialect.Dialect; import org.springframework.data.r2dbc.dialect.LimitClause; import org.springframework.data.r2dbc.dialect.LimitClause.Position; @@ -238,6 +239,15 @@ public String getTableName(Class type) { return getRequiredPersistentEntity(type).getTableName(); } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#getBindMarkersFactory() + */ + @Override + public BindMarkersFactory getBindMarkersFactory() { + return dialect.getBindMarkersFactory(); + } + private RelationalPersistentEntity getRequiredPersistentEntity(Class typeToRead) { return mappingContext.getRequiredPersistentEntity(typeToRead); } diff --git a/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClient.java b/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClient.java index 1edd16d1..99f93129 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClient.java @@ -39,8 +39,9 @@ class DefaultTransactionalDatabaseClient extends DefaultDatabaseClient implements TransactionalDatabaseClient { DefaultTransactionalDatabaseClient(ConnectionFactory connector, R2dbcExceptionTranslator exceptionTranslator, - ReactiveDataAccessStrategy dataAccessStrategy, DefaultDatabaseClientBuilder builder) { - super(connector, exceptionTranslator, dataAccessStrategy, builder); + ReactiveDataAccessStrategy dataAccessStrategy, NamedParameterExpander namedParameters, + DefaultDatabaseClientBuilder builder) { + super(connector, exceptionTranslator, dataAccessStrategy, namedParameters, builder); } @Override diff --git a/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClientBuilder.java b/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClientBuilder.java index c25e2c82..3c827e4a 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClientBuilder.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultTransactionalDatabaseClientBuilder.java @@ -69,6 +69,15 @@ public TransactionalDatabaseClient.Builder dataAccessStrategy(ReactiveDataAccess return this; } + /* (non-Javadoc) + * @see org.springframework.data.r2dbc.function.DefaultDatabaseClientBuilder#dataAccessStrategy(org.springframework.data.r2dbc.function.NamedParameterSupport) + */ + @Override + public TransactionalDatabaseClient.Builder namedParameters(NamedParameterExpander expander) { + super.namedParameters(expander); + return this; + } + /* (non-Javadoc) * @see org.springframework.data.r2dbc.function.DefaultDatabaseClientBuilder#apply(java.util.function.Consumer) */ @@ -86,12 +95,11 @@ public TransactionalDatabaseClient build() { return (TransactionalDatabaseClient) super.build(); } - /* (non-Javadoc) - * @see org.springframework.data.r2dbc.function.DefaultDatabaseClientBuilder#doBuild(io.r2dbc.spi.ConnectionFactory, org.springframework.data.r2dbc.support.R2dbcExceptionTranslator, org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy, org.springframework.data.r2dbc.function.DefaultDatabaseClientBuilder) - */ @Override protected DatabaseClient doBuild(ConnectionFactory connector, R2dbcExceptionTranslator exceptionTranslator, - ReactiveDataAccessStrategy accessStrategy, DefaultDatabaseClientBuilder builder) { - return new DefaultTransactionalDatabaseClient(connector, exceptionTranslator, accessStrategy, builder); + ReactiveDataAccessStrategy accessStrategy, NamedParameterExpander namedParameters, + DefaultDatabaseClientBuilder builder) { + return new DefaultTransactionalDatabaseClient(connector, exceptionTranslator, accessStrategy, namedParameters, + builder); } } diff --git a/src/main/java/org/springframework/data/r2dbc/function/MapBindParameterSource.java b/src/main/java/org/springframework/data/r2dbc/function/MapBindParameterSource.java new file mode 100644 index 00000000..09de5d88 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/function/MapBindParameterSource.java @@ -0,0 +1,114 @@ +/* + * Copyright 2019 the original author or authors. + * + * 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.springframework.data.r2dbc.function; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.data.r2dbc.function.convert.SettableValue; +import org.springframework.util.Assert; + +/** + * {@link BindParameterSource} implementation that holds a given {@link Map} of parameters encapsulated as + * {@link SettableValue}. + *

+ * This class is intended for passing in a simple Map of parameter values to the methods of the + * {@link NamedParameterExpander} class. + * + * @author Mark Paluch + */ +class MapBindParameterSource implements BindParameterSource { + + private final Map values; + + /** + * Creates a new empty {@link MapBindParameterSource}. + */ + MapBindParameterSource() { + this(new LinkedHashMap<>()); + } + + /** + * Creates a new {@link MapBindParameterSource} given {@link Map} of {@link SettableValue}. + * + * @param values the parameter mapping. + */ + MapBindParameterSource(Map values) { + + Assert.notNull(values, "Values must not be null"); + + this.values = values; + } + + /** + * Add a key-value pair to the {@link MapBindParameterSource}. The value must not be {@literal null}. + * + * @param paramName must not be {@literal null}. + * @param value must not be {@literal null}. + * @return {@code this} {@link MapBindParameterSource} + */ + MapBindParameterSource addValue(String paramName, Object value) { + + Assert.notNull(paramName, "Parameter name must not be null!"); + Assert.notNull(value, "Value must not be null!"); + + this.values.put(paramName, new SettableValue(paramName, value, value.getClass())); + return this; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.SqlParameterSource#hasValue(java.lang.String) + */ + @Override + public boolean hasValue(String paramName) { + + Assert.notNull(paramName, "Parameter name must not be null!"); + + return values.containsKey(paramName); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.SqlParameterSource#getType(java.lang.String) + */ + @Override + public Class getType(String paramName) { + + Assert.notNull(paramName, "Parameter name must not be null!"); + + SettableValue settableValue = this.values.get(paramName); + if (settableValue != null) { + return settableValue.getType(); + } + + return Object.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.SqlParameterSource#getValue(java.lang.String) + */ + @Override + public Object getValue(String paramName) throws IllegalArgumentException { + + if (!hasValue(paramName)) { + throw new IllegalArgumentException("No value registered for key '" + paramName + "'"); + } + + return this.values.get(paramName).getValue(); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/function/NamedParameterExpander.java b/src/main/java/org/springframework/data/r2dbc/function/NamedParameterExpander.java new file mode 100644 index 00000000..69699a85 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/function/NamedParameterExpander.java @@ -0,0 +1,166 @@ +/* + * Copyright 2019 the original author or authors. + * + * 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.springframework.data.r2dbc.function; + +import io.r2dbc.spi.Statement; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.data.r2dbc.dialect.BindMarkersFactory; + +/** + * SQL translation support allowing the use of named parameters rather than native placeholders. + *

+ * This class expands SQL from named parameters to native style placeholders at execution time. It also allows for + * expanding a {@link java.util.List} of values to the appropriate number of placeholders. + *

+ * NOTE: An instance of this class is thread-safe once configured. + * + * @author Mark Paluch + */ +public class NamedParameterExpander { + + /** + * Default maximum number of entries for the SQL cache: 256. + */ + public static final int DEFAULT_CACHE_LIMIT = 256; + + private volatile int cacheLimit = DEFAULT_CACHE_LIMIT; + + private final Log logger = LogFactory.getLog(getClass()); + + /** + * Cache of original SQL String to ParsedSql representation. + */ + @SuppressWarnings("serial") private final Map parsedSqlCache = new LinkedHashMap( + DEFAULT_CACHE_LIMIT, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > getCacheLimit(); + } + }; + + private NamedParameterExpander() {} + + /** + * Creates a disabled instance of {@link NamedParameterExpander}. + * + * @return a disabled instance of {@link NamedParameterExpander}. + */ + public static NamedParameterExpander disabled() { + return Disabled.INSTANCE; + } + + /** + * Creates a new enabled instance of {@link NamedParameterExpander}. + * + * @return a new enabled instance of {@link NamedParameterExpander}. + */ + public static NamedParameterExpander enabled() { + return new NamedParameterExpander(); + } + + /** + * Specify the maximum number of entries for the SQL cache. Default is 256. + */ + public void setCacheLimit(int cacheLimit) { + this.cacheLimit = cacheLimit; + } + + /** + * Return the maximum number of entries for the SQL cache. + */ + public int getCacheLimit() { + return this.cacheLimit; + } + + /** + * Obtain a parsed representation of the given SQL statement. + *

+ * The default implementation uses an LRU cache with an upper limit of 256 entries. + * + * @param sql the original SQL statement + * @return a representation of the parsed SQL statement + */ + protected ParsedSql getParsedSql(String sql) { + + if (getCacheLimit() <= 0) { + return NamedParameterUtils.parseSqlStatement(sql); + } + + synchronized (this.parsedSqlCache) { + + ParsedSql parsedSql = this.parsedSqlCache.get(sql); + if (parsedSql == null) { + + parsedSql = NamedParameterUtils.parseSqlStatement(sql); + this.parsedSqlCache.put(sql, parsedSql); + } + return parsedSql; + } + } + + BindableOperation expand(String sql, BindMarkersFactory bindMarkersFactory, BindParameterSource paramSource) { + + ParsedSql parsedSql = getParsedSql(sql); + + BindableOperation expanded = NamedParameterUtils.substituteNamedParameters(parsedSql, bindMarkersFactory, + paramSource); + + if (logger.isDebugEnabled()) { + logger.debug(String.format("Expanding SQL statement [%s] to [%s]", sql, expanded.toQuery())); + } + + return expanded; + } + + /** + * Disabled named parameter support. + */ + static class Disabled extends NamedParameterExpander { + + private static final Disabled INSTANCE = new Disabled(); + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.NamedParameterSupport#expand(java.lang.String, org.springframework.data.r2dbc.dialect.BindMarkersFactory, org.springframework.data.r2dbc.function.SqlParameterSource) + */ + @Override + BindableOperation expand(String sql, BindMarkersFactory bindMarkersFactory, BindParameterSource paramSource) { + + return new BindableOperation() { + + @Override + public void bind(Statement statement, String identifier, Object value) { + statement.bind(identifier, value); + } + + @Override + public void bindNull(Statement statement, String identifier, Class valueType) { + statement.bindNull(identifier, valueType); + } + + @Override + public String toQuery() { + return sql; + } + }; + } + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/function/NamedParameterUtils.java b/src/main/java/org/springframework/data/r2dbc/function/NamedParameterUtils.java new file mode 100644 index 00000000..07513339 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/function/NamedParameterUtils.java @@ -0,0 +1,475 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * 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.springframework.data.r2dbc.function; + +import io.r2dbc.spi.Statement; +import lombok.Value; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.r2dbc.dialect.BindMarker; +import org.springframework.data.r2dbc.dialect.BindMarkers; +import org.springframework.data.r2dbc.dialect.BindMarkersFactory; +import org.springframework.util.Assert; + +/** + * Helper methods for named parameter parsing. + *

+ * Only intended for internal use within Spring's Data's R2DBC framework. Partially extracted from Spring's JDBC named + * parameter support. + *

+ * This is a subset of Spring Frameworks's {@code org.springframework.jdbc.core.namedparam.NamedParameterUtils}. + * + * @author Thomas Risberg + * @author Juergen Hoeller + * @author Mark Paluch + */ +abstract class NamedParameterUtils { + + /** + * Set of characters that qualify as comment or quotes starting characters. + */ + private static final String[] START_SKIP = new String[] { "'", "\"", "--", "/*" }; + + /** + * Set of characters that at are the corresponding comment or quotes ending characters. + */ + private static final String[] STOP_SKIP = new String[] { "'", "\"", "\n", "*/" }; + + /** + * Set of characters that qualify as parameter separators, indicating that a parameter name in a SQL String has ended. + */ + private static final String PARAMETER_SEPARATORS = "\"':&,;()|=+-*%/\\<>^"; + + /** + * An index with separator flags per character code. Technically only needed between 34 and 124 at this point. + */ + private static final boolean[] separatorIndex = new boolean[128]; + + static { + for (char c : PARAMETER_SEPARATORS.toCharArray()) { + separatorIndex[c] = true; + } + } + + // ------------------------------------------------------------------------- + // Core methods used by NamedParameterSupport. + // ------------------------------------------------------------------------- + + /** + * Parse the SQL statement and locate any placeholders or named parameters. Named parameters are substituted for a + * placeholder. + * + * @param sql the SQL statement + * @return the parsed statement, represented as {@link ParsedSql} instance. + */ + public static ParsedSql parseSqlStatement(String sql) { + + Assert.notNull(sql, "SQL must not be null"); + + Set namedParameters = new HashSet<>(); + String sqlToUse = sql; + List parameterList = new ArrayList<>(); + + char[] statement = sql.toCharArray(); + int namedParameterCount = 0; + int unnamedParameterCount = 0; + int totalParameterCount = 0; + + int escapes = 0; + int i = 0; + while (i < statement.length) { + int skipToPosition = i; + while (i < statement.length) { + skipToPosition = skipCommentsAndQuotes(statement, i); + if (i == skipToPosition) { + break; + } else { + i = skipToPosition; + } + } + if (i >= statement.length) { + break; + } + char c = statement[i]; + if (c == ':' || c == '&') { + int j = i + 1; + if (c == ':' && j < statement.length && statement[j] == ':') { + // Postgres-style "::" casting operator should be skipped + i = i + 2; + continue; + } + String parameter = null; + if (c == ':' && j < statement.length && statement[j] == '{') { + // :{x} style parameter + while (statement[j] != '}') { + j++; + if (j >= statement.length) { + throw new InvalidDataAccessApiUsageException( + "Non-terminated named parameter declaration " + "at position " + i + " in statement: " + sql); + } + if (statement[j] == ':' || statement[j] == '{') { + throw new InvalidDataAccessApiUsageException("Parameter name contains invalid character '" + statement[j] + + "' at position " + i + " in statement: " + sql); + } + } + if (j - i > 2) { + parameter = sql.substring(i + 2, j); + namedParameterCount = addNewNamedParameter(namedParameters, namedParameterCount, parameter); + totalParameterCount = addNamedParameter(parameterList, totalParameterCount, escapes, i, j + 1, parameter); + } + j++; + } else { + while (j < statement.length && !isParameterSeparator(statement[j])) { + j++; + } + if (j - i > 1) { + parameter = sql.substring(i + 1, j); + namedParameterCount = addNewNamedParameter(namedParameters, namedParameterCount, parameter); + totalParameterCount = addNamedParameter(parameterList, totalParameterCount, escapes, i, j, parameter); + } + } + i = j - 1; + } else { + if (c == '\\') { + int j = i + 1; + if (j < statement.length && statement[j] == ':') { + // escaped ":" should be skipped + sqlToUse = sqlToUse.substring(0, i - escapes) + sqlToUse.substring(i - escapes + 1); + escapes++; + i = i + 2; + continue; + } + } + } + i++; + } + ParsedSql parsedSql = new ParsedSql(sqlToUse); + for (ParameterHolder ph : parameterList) { + parsedSql.addNamedParameter(ph.getParameterName(), ph.getStartIndex(), ph.getEndIndex()); + } + parsedSql.setNamedParameterCount(namedParameterCount); + parsedSql.setUnnamedParameterCount(unnamedParameterCount); + parsedSql.setTotalParameterCount(totalParameterCount); + return parsedSql; + } + + private static int addNamedParameter(List parameterList, int totalParameterCount, int escapes, int i, + int j, String parameter) { + + parameterList.add(new ParameterHolder(parameter, i - escapes, j - escapes)); + totalParameterCount++; + return totalParameterCount; + } + + private static int addNewNamedParameter(Set namedParameters, int namedParameterCount, String parameter) { + if (!namedParameters.contains(parameter)) { + namedParameters.add(parameter); + namedParameterCount++; + } + return namedParameterCount; + } + + /** + * Skip over comments and quoted names present in an SQL statement. + * + * @param statement character array containing SQL statement. + * @param position current position of statement. + * @return next position to process after any comments or quotes are skipped. + */ + private static int skipCommentsAndQuotes(char[] statement, int position) { + + for (int i = 0; i < START_SKIP.length; i++) { + if (statement[position] == START_SKIP[i].charAt(0)) { + boolean match = true; + for (int j = 1; j < START_SKIP[i].length(); j++) { + if (statement[position + j] != START_SKIP[i].charAt(j)) { + match = false; + break; + } + } + if (match) { + int offset = START_SKIP[i].length(); + for (int m = position + offset; m < statement.length; m++) { + if (statement[m] == STOP_SKIP[i].charAt(0)) { + boolean endMatch = true; + int endPos = m; + for (int n = 1; n < STOP_SKIP[i].length(); n++) { + if (m + n >= statement.length) { + // last comment not closed properly + return statement.length; + } + if (statement[m + n] != STOP_SKIP[i].charAt(n)) { + endMatch = false; + break; + } + endPos = m + n; + } + if (endMatch) { + // found character sequence ending comment or quote + return endPos + 1; + } + } + } + // character sequence ending comment or quote not found + return statement.length; + } + } + } + return position; + } + + /** + * Parse the SQL statement and locate any placeholders or named parameters. Named parameters are substituted for a + * native placeholder, and any select list is expanded to the required number of placeholders. Select lists may + * contain an array of objects, and in that case the placeholders will be grouped and enclosed with parentheses. This + * allows for the use of "expression lists" in the SQL statement like:
+ *
+ * {@code select id, name, state from table where (name, age) in (('John', 35), ('Ann', 50))} + *

+ * The parameter values passed in are used to determine the number of placeholders to be used for a select list. + * Select lists should be limited to 100 or fewer elements. A larger number of elements is not guaranteed to be + * supported by the database and is strictly vendor-dependent. + * + * @param parsedSql the parsed representation of the SQL statement. + * @param bindMarkersFactory the bind marker factory. + * @param paramSource the source for named parameters. + * @return the expanded query that accepts bind parameters and allows for execution without further translation. + * @see #parseSqlStatement + */ + public static BindableOperation substituteNamedParameters(ParsedSql parsedSql, BindMarkersFactory bindMarkersFactory, + BindParameterSource paramSource) { + + BindMarkerHolder markerHolder = new BindMarkerHolder(bindMarkersFactory.create()); + + String originalSql = parsedSql.getOriginalSql(); + List paramNames = parsedSql.getParameterNames(); + if (paramNames.isEmpty()) { + return new ExpandedQuery(originalSql, markerHolder); + } + + StringBuilder actualSql = new StringBuilder(originalSql.length()); + int lastIndex = 0; + for (int i = 0; i < paramNames.size(); i++) { + String paramName = paramNames.get(i); + int[] indexes = parsedSql.getParameterIndexes(i); + int startIndex = indexes[0]; + int endIndex = indexes[1]; + actualSql.append(originalSql, lastIndex, startIndex); + if (paramSource.hasValue(paramName)) { + Object value = paramSource.getValue(paramName); + if (value instanceof Collection) { + Iterator entryIter = ((Collection) value).iterator(); + int k = 0; + while (entryIter.hasNext()) { + if (k > 0) { + actualSql.append(", "); + } + k++; + Object entryItem = entryIter.next(); + if (entryItem instanceof Object[]) { + Object[] expressionList = (Object[]) entryItem; + actualSql.append('('); + for (int m = 0; m < expressionList.length; m++) { + if (m > 0) { + actualSql.append(", "); + } + actualSql.append(markerHolder.addMarker(paramName)); + } + actualSql.append(')'); + } else { + actualSql.append(markerHolder.addMarker(paramName)); + } + + } + } else { + actualSql.append(markerHolder.addMarker(paramName)); + } + } else { + actualSql.append(markerHolder.addMarker(paramName)); + } + lastIndex = endIndex; + } + actualSql.append(originalSql, lastIndex, originalSql.length()); + + return new ExpandedQuery(actualSql.toString(), markerHolder); + } + + /** + * Determine whether a parameter name ends at the current position, that is, whether the given character qualifies as + * a separator. + */ + private static boolean isParameterSeparator(char c) { + return (c < 128 && separatorIndex[c]) || Character.isWhitespace(c); + } + + // ------------------------------------------------------------------------- + // Convenience methods operating on a plain SQL String + // ------------------------------------------------------------------------- + + /** + * Parse the SQL statement and locate any placeholders or named parameters. Named parameters are substituted for a + * native placeholder and any select list is expanded to the required number of placeholders. + *

+ * + * @param sql the SQL statement. + * @param bindMarkersFactory the bind marker factory. + * @param paramSource the source for named parameters. + * @return the expanded query that accepts bind parameters and allows for execution without further translation. + */ + public static BindableOperation substituteNamedParameters(String sql, BindMarkersFactory bindMarkersFactory, + BindParameterSource paramSource) { + ParsedSql parsedSql = parseSqlStatement(sql); + return substituteNamedParameters(parsedSql, bindMarkersFactory, paramSource); + } + + @Value + private static class ParameterHolder { + + String parameterName; + + int startIndex; + + int endIndex; + } + + /** + * Holder for bind marker progress. + */ + private static class BindMarkerHolder { + + private final BindMarkers bindMarkers; + private final Map> markers = new TreeMap<>(); + + BindMarkerHolder(BindMarkers bindMarkers) { + this.bindMarkers = bindMarkers; + } + + String addMarker(String name) { + + BindMarker bindMarker = bindMarkers.next(name); + markers.computeIfAbsent(name, ignore -> new ArrayList<>()).add(bindMarker); + return bindMarker.getPlaceholder(); + } + } + + /** + * Expanded query that allows binding of parameters using parameter names that were used to expand the query. Binding + * unrolls {@link Collection}s and nested arrays. + */ + private static class ExpandedQuery implements BindableOperation { + + private final String expandedSql; + + private final Map> markers; + + ExpandedQuery(String expandedSql, BindMarkerHolder bindMarkerHolder) { + this.expandedSql = expandedSql; + this.markers = bindMarkerHolder.markers; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.BindableOperation#bind(io.r2dbc.spi.Statement, java.lang.String, java.lang.Object) + */ + @Override + @SuppressWarnings("unchecked") + public void bind(Statement statement, String identifier, Object value) { + + List bindMarkers = getBindMarkers(identifier); + + if (bindMarkers.size() == 1) { + bindMarkers.get(0).bind(statement, value); + } else { + + Assert.isInstanceOf(Collection.class, value, + () -> String.format("Value [%s] must be an Collection with a size of [%d]", value, bindMarkers.size())); + + Collection collection = (Collection) value; + + Iterator iterator = collection.iterator(); + Iterator markers = bindMarkers.iterator(); + + while (iterator.hasNext()) { + + Object valueToBind = iterator.next(); + + if (valueToBind instanceof Object[]) { + Object[] objects = (Object[]) valueToBind; + for (Object object : objects) { + bind(statement, markers, object); + } + } else { + bind(statement, markers, valueToBind); + } + } + } + } + + private void bind(Statement statement, Iterator markers, Object valueToBind) { + + Assert.isTrue(markers.hasNext(), + () -> String.format( + "No bind marker for value [%s] in SQL [%s]. Check that the query was expanded using the same arguments.", + valueToBind, toQuery())); + + markers.next().bind(statement, valueToBind); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.BindableOperation#bindNull(io.r2dbc.spi.Statement, java.lang.String, java.lang.Class) + */ + @Override + public void bindNull(Statement statement, String identifier, Class valueType) { + + List bindMarkers = getBindMarkers(identifier); + + if (bindMarkers.size() == 1) { + bindMarkers.get(0).bindNull(statement, valueType); + return; + } + + throw new UnsupportedOperationException("bindNull(…) can bind only singular values"); + } + + private List getBindMarkers(String identifier) { + + List bindMarkers = markers.get(identifier); + + Assert.notNull(bindMarkers, () -> String.format("Parameter name [%s] is unknown. Known parameters names are: %s", + identifier, markers.keySet())); + return bindMarkers; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.QueryOperation#toQuery() + */ + @Override + public String toQuery() { + return expandedSql; + } + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/function/ParsedSql.java b/src/main/java/org/springframework/data/r2dbc/function/ParsedSql.java new file mode 100644 index 00000000..706931ea --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/function/ParsedSql.java @@ -0,0 +1,143 @@ +/* + * Copyright 2002-2019 the original author or authors. + * + * 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.springframework.data.r2dbc.function; + +import java.util.ArrayList; +import java.util.List; + +/** + * Holds information about a parsed SQL statement. + *

+ * This is a copy of Spring Frameworks's {@code org.springframework.jdbc.core.namedparam.ParsedSql}. + * + * @author Thomas Risberg + * @author Juergen Hoeller + */ +class ParsedSql { + + private String originalSql; + + private List parameterNames = new ArrayList<>(); + + private List parameterIndexes = new ArrayList<>(); + + private int namedParameterCount; + + private int unnamedParameterCount; + + private int totalParameterCount; + + /** + * Create a new instance of the {@link ParsedSql} class. + * + * @param originalSql the SQL statement that is being (or is to be) parsed + */ + ParsedSql(String originalSql) { + this.originalSql = originalSql; + } + + /** + * Return the SQL statement that is being parsed. + */ + String getOriginalSql() { + return this.originalSql; + } + + /** + * Add a named parameter parsed from this SQL statement. + * + * @param parameterName the name of the parameter + * @param startIndex the start index in the original SQL String + * @param endIndex the end index in the original SQL String + */ + void addNamedParameter(String parameterName, int startIndex, int endIndex) { + this.parameterNames.add(parameterName); + this.parameterIndexes.add(new int[] { startIndex, endIndex }); + } + + /** + * Return all of the parameters (bind variables) in the parsed SQL statement. Repeated occurrences of the same + * parameter name are included here. + */ + List getParameterNames() { + return this.parameterNames; + } + + /** + * Return the parameter indexes for the specified parameter. + * + * @param parameterPosition the position of the parameter (as index in the parameter names List) + * @return the start index and end index, combined into a int array of length 2 + */ + int[] getParameterIndexes(int parameterPosition) { + return this.parameterIndexes.get(parameterPosition); + } + + /** + * Set the count of named parameters in the SQL statement. Each parameter name counts once; repeated occurrences do + * not count here. + */ + void setNamedParameterCount(int namedParameterCount) { + this.namedParameterCount = namedParameterCount; + } + + /** + * Return the count of named parameters in the SQL statement. Each parameter name counts once; repeated occurrences do + * not count here. + */ + int getNamedParameterCount() { + return this.namedParameterCount; + } + + /** + * Set the count of all of the unnamed parameters in the SQL statement. + */ + void setUnnamedParameterCount(int unnamedParameterCount) { + this.unnamedParameterCount = unnamedParameterCount; + } + + /** + * Return the count of all of the unnamed parameters in the SQL statement. + */ + int getUnnamedParameterCount() { + return this.unnamedParameterCount; + } + + /** + * Set the total count of all of the parameters in the SQL statement. Repeated occurrences of the same parameter name + * do count here. + */ + void setTotalParameterCount(int totalParameterCount) { + this.totalParameterCount = totalParameterCount; + } + + /** + * Return the total count of all of the parameters in the SQL statement. Repeated occurrences of the same parameter + * name do count here. + */ + int getTotalParameterCount() { + return this.totalParameterCount; + } + + /** + * Exposes the original SQL String. + */ + @Override + public String toString() { + return this.originalSql; + } + +} diff --git a/src/main/java/org/springframework/data/r2dbc/function/ReactiveDataAccessStrategy.java b/src/main/java/org/springframework/data/r2dbc/function/ReactiveDataAccessStrategy.java index 8670961b..f715e6e4 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/ReactiveDataAccessStrategy.java +++ b/src/main/java/org/springframework/data/r2dbc/function/ReactiveDataAccessStrategy.java @@ -26,6 +26,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.r2dbc.dialect.BindMarkersFactory; import org.springframework.data.r2dbc.function.convert.SettableValue; /** @@ -76,6 +77,13 @@ public interface ReactiveDataAccessStrategy { */ String getTableName(Class type); + /** + * Returns the configured {@link BindMarkersFactory} to create native parameter placeholder markers. + * + * @return the configured {@link BindMarkersFactory}. + */ + BindMarkersFactory getBindMarkersFactory(); + // ------------------------------------------------------------------------- // Methods creating SQL operations. // Subject to be moved into a SQL creation DSL. diff --git a/src/main/java/org/springframework/data/r2dbc/function/TransactionalDatabaseClient.java b/src/main/java/org/springframework/data/r2dbc/function/TransactionalDatabaseClient.java index b596d8d0..3acc780c 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/TransactionalDatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/function/TransactionalDatabaseClient.java @@ -40,10 +40,10 @@ *

  * Flux transactionalFlux = databaseClient.inTransaction(db -> {
  *
- * 	return db.execute().sql("INSERT INTO person (id, firstname, lastname) VALUES($1, $2, $3)") //
- * 			.bind(0, 1) //
- * 			.bind(1, "Walter") //
- * 			.bind(2, "White") //
+ * 	return db.execute().sql("INSERT INTO person (id, firstname, lastname) VALUES(:id, :firstname, :lastname)") //
+ * 			.bind("id", 1) //
+ * 			.bind("firstname", "Walter") //
+ * 			.bind("lastname", "White") //
  * 			.fetch().rowsUpdated();
  * });
  * 
@@ -54,10 +54,11 @@ * *
  * Mono mono = databaseClient.beginTransaction()
- * 		.then(databaseClient.execute().sql("INSERT INTO person (id, firstname, lastname) VALUES($1, $2, $3)") //
- * 				.bind(0, 1) //
- * 				.bind(1, "Walter") //
- * 				.bind(2, "White") //
+ * 		.then(databaseClient.execute()
+ * 				.sql("INSERT INTO person (id, firstname, lastname) VALUES(:id, :firstname, :lastname)") //
+ * 				.bind("id", 1) //
+ * 				.bind("firstname", "Walter") //
+ * 				.bind("lastname", "White") //
  * 				.fetch().rowsUpdated())
  * 		.then(databaseClient.commitTransaction());
  *
@@ -168,7 +169,7 @@ interface Builder extends DatabaseClient.Builder {
 		 * Configures the {@link ConnectionFactory R2DBC connector}.
 		 *
 		 * @param factory must not be {@literal null}.
-		 * @return {@code this} {@link DatabaseClient.Builder}.
+		 * @return {@code this} {@link Builder}.
 		 */
 		Builder connectionFactory(ConnectionFactory factory);
 
@@ -176,7 +177,7 @@ interface Builder extends DatabaseClient.Builder {
 		 * Configures a {@link R2dbcExceptionTranslator}.
 		 *
 		 * @param exceptionTranslator must not be {@literal null}.
-		 * @return {@code this} {@link DatabaseClient.Builder}.
+		 * @return {@code this} {@link Builder}.
 		 */
 		Builder exceptionTranslator(R2dbcExceptionTranslator exceptionTranslator);
 
@@ -184,15 +185,25 @@ interface Builder extends DatabaseClient.Builder {
 		 * Configures a {@link ReactiveDataAccessStrategy}.
 		 *
 		 * @param accessStrategy must not be {@literal null}.
-		 * @return {@code this} {@link DatabaseClient.Builder}.
+		 * @return {@code this} {@link Builder}.
 		 */
 		Builder dataAccessStrategy(ReactiveDataAccessStrategy accessStrategy);
 
+		/**
+		 * Configures {@link NamedParameterExpander}.
+		 *
+		 * @param expander must not be {@literal null}.
+		 * @return {@code this} {@link Builder}.
+		 * @see NamedParameterExpander#enabled()
+		 * @see NamedParameterExpander#disabled()
+		 */
+		Builder namedParameters(NamedParameterExpander expander);
+
 		/**
 		 * Configures a {@link Consumer} to configure this builder.
 		 *
 		 * @param builderConsumer must not be {@literal null}.
-		 * @return {@code this} {@link DatabaseClient.Builder}.
+		 * @return {@code this} {@link Builder}.
 		 */
 		Builder apply(Consumer builderConsumer);
 
diff --git a/src/test/java/org/springframework/data/r2dbc/function/AbstractDatabaseClientIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/function/AbstractDatabaseClientIntegrationTests.java
index 9a5f3136..8700e4a9 100644
--- a/src/test/java/org/springframework/data/r2dbc/function/AbstractDatabaseClientIntegrationTests.java
+++ b/src/test/java/org/springframework/data/r2dbc/function/AbstractDatabaseClientIntegrationTests.java
@@ -90,7 +90,9 @@ public void before() {
 	/**
 	 * Get a parameterized {@code INSERT INTO legoset} statement setting id, name, and manual values.
 	 */
-	protected abstract String getInsertIntoLegosetStatement();
+	protected String getInsertIntoLegosetStatement() {
+		return "INSERT INTO legoset (id, name, manual) VALUES(:id, :name, :manual)";
+	}
 
 	@Test // gh-2
 	public void executeInsert() {
@@ -98,9 +100,9 @@ public void executeInsert() {
 		DatabaseClient databaseClient = DatabaseClient.create(connectionFactory);
 
 		databaseClient.execute().sql(getInsertIntoLegosetStatement()) //
-				.bind(0, 42055) //
-				.bind(1, "SCHAUFELRADBAGGER") //
-				.bindNull(2, Integer.class) //
+				.bind("id", 42055) //
+				.bind("name", "SCHAUFELRADBAGGER") //
+				.bindNull("manual", Integer.class) //
 				.fetch().rowsUpdated() //
 				.as(StepVerifier::create) //
 				.expectNext(1) //
diff --git a/src/test/java/org/springframework/data/r2dbc/function/AbstractTransactionalDatabaseClientIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/function/AbstractTransactionalDatabaseClientIntegrationTests.java
index 52d267d8..fc406a21 100644
--- a/src/test/java/org/springframework/data/r2dbc/function/AbstractTransactionalDatabaseClientIntegrationTests.java
+++ b/src/test/java/org/springframework/data/r2dbc/function/AbstractTransactionalDatabaseClientIntegrationTests.java
@@ -15,25 +15,27 @@
  */
 package org.springframework.data.r2dbc.function;
 
+import static org.assertj.core.api.Assertions.*;
+
 import io.r2dbc.spi.ConnectionFactory;
-import org.junit.Before;
-import org.junit.Test;
-import org.springframework.dao.DataAccessException;
-import org.springframework.data.r2dbc.testing.R2dbcIntegrationTestSupport;
-import org.springframework.jdbc.core.JdbcTemplate;
-import org.springframework.transaction.NoTransactionException;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Hooks;
 import reactor.core.publisher.Mono;
 import reactor.test.StepVerifier;
 
-import javax.sql.DataSource;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Queue;
 import java.util.concurrent.ArrayBlockingQueue;
 
-import static org.assertj.core.api.Assertions.*;
+import javax.sql.DataSource;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.dao.DataAccessException;
+import org.springframework.data.r2dbc.testing.R2dbcIntegrationTestSupport;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.transaction.NoTransactionException;
 
 /**
  * Abstract base class for integration tests for {@link TransactionalDatabaseClient}.
@@ -56,8 +58,7 @@ public void before() {
 		jdbc = createJdbcTemplate(createDataSource());
 		try {
 			jdbc.execute("DROP TABLE legoset");
-		} catch (DataAccessException e) {
-		}
+		} catch (DataAccessException e) {}
 		jdbc.execute(getCreateTableStatement());
 		jdbc.execute("DELETE FROM legoset");
 	}
@@ -91,7 +92,9 @@ public void before() {
 	/**
 	 * Get a parameterized {@code INSERT INTO legoset} statement setting id, name, and manual values.
 	 */
-	protected abstract String getInsertIntoLegosetStatement();
+	protected String getInsertIntoLegosetStatement() {
+		return "INSERT INTO legoset (id, name, manual) VALUES(:id, :name, :manual)";
+	}
 
 	/**
 	 * Get a statement that returns the current transactionId.
diff --git a/src/test/java/org/springframework/data/r2dbc/function/NamedParameterUtilsUnitTests.java b/src/test/java/org/springframework/data/r2dbc/function/NamedParameterUtilsUnitTests.java
new file mode 100644
index 00000000..89176550
--- /dev/null
+++ b/src/test/java/org/springframework/data/r2dbc/function/NamedParameterUtilsUnitTests.java
@@ -0,0 +1,293 @@
+/*
+ * Copyright 2019 the original author or authors.
+ *
+ * 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.springframework.data.r2dbc.function;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import io.r2dbc.spi.Statement;
+
+import java.util.Arrays;
+import java.util.HashMap;
+
+import org.junit.Test;
+import org.springframework.data.r2dbc.dialect.BindMarkersFactory;
+import org.springframework.data.r2dbc.dialect.PostgresDialect;
+import org.springframework.data.r2dbc.dialect.SqlServerDialect;
+
+/**
+ * Unit tests for {@link NamedParameterUtils}.
+ *
+ * @author Mark Paluch
+ * @author Jens Schauder
+ */
+public class NamedParameterUtilsUnitTests {
+
+	private final BindMarkersFactory BIND_MARKERS = PostgresDialect.INSTANCE.getBindMarkersFactory();
+
+	@Test // gh-23
+	public void shouldParseSql() {
+
+		String sql = "xxx :a yyyy :b :c :a zzzzz";
+		ParsedSql psql = NamedParameterUtils.parseSqlStatement(sql);
+		assertThat(psql.getParameterNames()).containsExactly("a", "b", "c", "a");
+		assertThat(psql.getTotalParameterCount()).isEqualTo(4);
+		assertThat(psql.getNamedParameterCount()).isEqualTo(3);
+
+		String sql2 = "xxx &a yyyy ? zzzzz";
+		ParsedSql psql2 = NamedParameterUtils.parseSqlStatement(sql2);
+		assertThat(psql2.getParameterNames()).containsExactly("a");
+		assertThat(psql2.getTotalParameterCount()).isEqualTo(1);
+		assertThat(psql2.getNamedParameterCount()).isEqualTo(1);
+
+		String sql3 = "xxx &ä+:ö" + '\t' + ":ü%10 yyyy ? zzzzz";
+		ParsedSql psql3 = NamedParameterUtils.parseSqlStatement(sql3);
+		assertThat(psql3.getParameterNames()).containsExactly("ä", "ö", "ü");
+	}
+
+	@Test // gh-23
+	public void substituteNamedParameters() {
+
+		MapBindParameterSource namedParams = new MapBindParameterSource(new HashMap<>());
+		namedParams.addValue("a", "a").addValue("b", "b").addValue("c", "c");
+
+		BindableOperation operation = NamedParameterUtils.substituteNamedParameters("xxx :a :b :c",
+				PostgresDialect.INSTANCE.getBindMarkersFactory(), namedParams);
+
+		assertThat(operation.toQuery()).isEqualTo("xxx $1 $2 $3");
+
+		BindableOperation operation2 = NamedParameterUtils.substituteNamedParameters("xxx :a :b :c",
+				SqlServerDialect.INSTANCE.getBindMarkersFactory(), namedParams);
+
+		assertThat(operation2.toQuery()).isEqualTo("xxx @P0_a @P1_b @P2_c");
+	}
+
+	@Test // gh-23
+	public void substituteObjectArray() {
+
+		MapBindParameterSource namedParams = new MapBindParameterSource(new HashMap<>());
+		namedParams.addValue("a",
+				Arrays.asList(new Object[] { "Walter", "Heisenberg" }, new Object[] { "Walt Jr.", "Flynn" }));
+
+		BindableOperation operation = NamedParameterUtils.substituteNamedParameters("xxx :a", BIND_MARKERS, namedParams);
+
+		assertThat(operation.toQuery()).isEqualTo("xxx ($1, $2), ($3, $4)");
+	}
+
+	@Test // gh-23
+	public void shouldBindObjectArray() {
+
+		MapBindParameterSource namedParams = new MapBindParameterSource(new HashMap<>());
+		namedParams.addValue("a",
+				Arrays.asList(new Object[] { "Walter", "Heisenberg" }, new Object[] { "Walt Jr.", "Flynn" }));
+
+		Statement mockStatement = mock(Statement.class);
+
+		BindableOperation operation = NamedParameterUtils.substituteNamedParameters("xxx :a", BIND_MARKERS, namedParams);
+		operation.bind(mockStatement, "a", namedParams.getValue("a"));
+
+		verify(mockStatement).bind(0, "Walter");
+		verify(mockStatement).bind(1, "Heisenberg");
+		verify(mockStatement).bind(2, "Walt Jr.");
+		verify(mockStatement).bind(3, "Flynn");
+	}
+
+	@Test // gh-23
+	public void parseSqlContainingComments() {
+
+		String sql1 = "/*+ HINT */ xxx /* comment ? */ :a yyyy :b :c :a zzzzz -- :xx XX\n";
+
+		ParsedSql psql1 = NamedParameterUtils.parseSqlStatement(sql1);
+		assertThat(expand(psql1)).isEqualTo("/*+ HINT */ xxx /* comment ? */ $1 yyyy $2 $3 $4 zzzzz -- :xx XX\n");
+
+		MapBindParameterSource paramMap = new MapBindParameterSource(new HashMap<>());
+		paramMap.addValue("a", "a");
+		paramMap.addValue("b", "b");
+		paramMap.addValue("c", "c");
+
+		String sql2 = "/*+ HINT */ xxx /* comment ? */ :a yyyy :b :c :a zzzzz -- :xx XX";
+		ParsedSql psql2 = NamedParameterUtils.parseSqlStatement(sql2);
+		assertThat(expand(psql2)).isEqualTo("/*+ HINT */ xxx /* comment ? */ $1 yyyy $2 $3 $4 zzzzz -- :xx XX");
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithPostgresCasting() {
+
+		String expectedSql = "select 'first name' from artists where id = $1 and birth_date=$2::timestamp";
+		String sql = "select 'first name' from artists where id = :id and birth_date=:birthDate::timestamp";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+		BindableOperation operation = NamedParameterUtils.substituteNamedParameters(parsedSql, BIND_MARKERS,
+				new MapBindParameterSource());
+
+		assertThat(operation.toQuery()).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithPostgresContainedOperator() {
+
+		String expectedSql = "select 'first name' from artists where info->'stat'->'albums' = ?? $1 and '[\"1\",\"2\",\"3\"]'::jsonb ?? '4'";
+		String sql = "select 'first name' from artists where info->'stat'->'albums' = ?? :album and '[\"1\",\"2\",\"3\"]'::jsonb ?? '4'";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+
+		assertThat(parsedSql.getTotalParameterCount()).isEqualTo(1);
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithPostgresAnyArrayStringsExistsOperator() {
+
+		String expectedSql = "select '[\"3\", \"11\"]'::jsonb ?| '{1,3,11,12,17}'::text[]";
+		String sql = "select '[\"3\", \"11\"]'::jsonb ?| '{1,3,11,12,17}'::text[]";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+
+		assertThat(parsedSql.getTotalParameterCount()).isEqualTo(0);
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithPostgresAllArrayStringsExistsOperator() {
+
+		String expectedSql = "select '[\"3\", \"11\"]'::jsonb ?& '{1,3,11,12,17}'::text[] AND $1 = 'Back in Black'";
+		String sql = "select '[\"3\", \"11\"]'::jsonb ?& '{1,3,11,12,17}'::text[] AND :album = 'Back in Black'";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+		assertThat(parsedSql.getTotalParameterCount()).isEqualTo(1);
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithEscapedColon() {
+
+		String expectedSql = "select '0\\:0' as a, foo from bar where baz < DATE($1 23:59:59) and baz = $2";
+		String sql = "select '0\\:0' as a, foo from bar where baz < DATE(:p1 23\\:59\\:59) and baz = :p2";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+
+		assertThat(parsedSql.getParameterNames()).containsExactly("p1", "p2");
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithBracketDelimitedParameterNames() {
+
+		String expectedSql = "select foo from bar where baz = b$1$2z";
+		String sql = "select foo from bar where baz = b:{p1}:{p2}z";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+		assertThat(parsedSql.getParameterNames()).containsExactly("p1", "p2");
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithEmptyBracketsOrBracketsInQuotes() {
+
+		String expectedSql = "select foo from bar where baz = b:{}z";
+		String sql = "select foo from bar where baz = b:{}z";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+
+		assertThat(parsedSql.getParameterNames()).isEmpty();
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+
+		String expectedSql2 = "select foo from bar where baz = 'b:{p1}z'";
+		String sql2 = "select foo from bar where baz = 'b:{p1}z'";
+
+		ParsedSql parsedSql2 = NamedParameterUtils.parseSqlStatement(sql2);
+		assertThat(parsedSql2.getParameterNames()).isEmpty();
+		assertThat(expand(parsedSql2)).isEqualTo(expectedSql2);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithSingleLetterInBrackets() {
+
+		String expectedSql = "select foo from bar where baz = b$1z";
+		String sql = "select foo from bar where baz = b:{p}z";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(sql);
+		assertThat(parsedSql.getParameterNames()).containsExactly("p");
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithLogicalAnd() {
+
+		String expectedSql = "xxx & yyyy";
+
+		ParsedSql parsedSql = NamedParameterUtils.parseSqlStatement(expectedSql);
+
+		assertThat(expand(parsedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void substituteNamedParametersWithLogicalAnd() {
+
+		String expectedSql = "xxx & yyyy";
+
+		assertThat(expand(expectedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void variableAssignmentOperator() {
+
+		String expectedSql = "x := 1";
+
+		assertThat(expand(expectedSql)).isEqualTo(expectedSql);
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithQuotedSingleQuote() {
+
+		String sql = "SELECT ':foo'':doo', :xxx FROM DUAL";
+
+		ParsedSql psql = NamedParameterUtils.parseSqlStatement(sql);
+
+		assertThat(psql.getTotalParameterCount()).isEqualTo(1);
+		assertThat(psql.getParameterNames()).containsExactly("xxx");
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithQuotesAndCommentBefore() {
+
+		String sql = "SELECT /*:doo*/':foo', :xxx FROM DUAL";
+
+		ParsedSql psql = NamedParameterUtils.parseSqlStatement(sql);
+
+		assertThat(psql.getTotalParameterCount()).isEqualTo(1);
+		assertThat(psql.getParameterNames()).containsExactly("xxx");
+	}
+
+	@Test // gh-23
+	public void parseSqlStatementWithQuotesAndCommentAfter() {
+
+		String sql2 = "SELECT ':foo'/*:doo*/, :xxx FROM DUAL";
+
+		ParsedSql psql2 = NamedParameterUtils.parseSqlStatement(sql2);
+
+		assertThat(psql2.getTotalParameterCount()).isEqualTo(1);
+		assertThat(psql2.getParameterNames()).containsExactly("xxx");
+	}
+
+	private String expand(ParsedSql sql) {
+		return NamedParameterUtils.substituteNamedParameters(sql, BIND_MARKERS, new MapBindParameterSource()).toQuery();
+	}
+
+	private String expand(String sql) {
+		return NamedParameterUtils.substituteNamedParameters(sql, BIND_MARKERS, new MapBindParameterSource()).toQuery();
+	}
+}
diff --git a/src/test/java/org/springframework/data/r2dbc/function/PostgresDatabaseClientIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/function/PostgresDatabaseClientIntegrationTests.java
index d5ec20b9..264e8dba 100644
--- a/src/test/java/org/springframework/data/r2dbc/function/PostgresDatabaseClientIntegrationTests.java
+++ b/src/test/java/org/springframework/data/r2dbc/function/PostgresDatabaseClientIntegrationTests.java
@@ -21,6 +21,7 @@
 
 import org.junit.ClassRule;
 import org.junit.Ignore;
+import org.junit.Test;
 import org.springframework.data.r2dbc.testing.ExternalDatabase;
 import org.springframework.data.r2dbc.testing.PostgresTestSupport;
 
@@ -48,16 +49,13 @@ protected String getCreateTableStatement() {
 		return PostgresTestSupport.CREATE_TABLE_LEGOSET;
 	}
 
-	@Override
-	protected String getInsertIntoLegosetStatement() {
-		return PostgresTestSupport.INSERT_INTO_LEGOSET;
-	}
-
 	@Ignore("Adding RETURNING * lets Postgres report 0 affected rows.")
+	@Test
 	@Override
 	public void insert() {}
 
 	@Ignore("Adding RETURNING * lets Postgres report 0 affected rows.")
+	@Test
 	@Override
 	public void insertTypedObject() {}
 }
diff --git a/src/test/java/org/springframework/data/r2dbc/function/PostgresTransactionalDatabaseClientIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/function/PostgresTransactionalDatabaseClientIntegrationTests.java
index cfd88545..039df349 100644
--- a/src/test/java/org/springframework/data/r2dbc/function/PostgresTransactionalDatabaseClientIntegrationTests.java
+++ b/src/test/java/org/springframework/data/r2dbc/function/PostgresTransactionalDatabaseClientIntegrationTests.java
@@ -33,11 +33,6 @@ protected String getCreateTableStatement() {
 		return PostgresTestSupport.CREATE_TABLE_LEGOSET;
 	}
 
-	@Override
-	protected String getInsertIntoLegosetStatement() {
-		return PostgresTestSupport.INSERT_INTO_LEGOSET;
-	}
-
 	@Override
 	protected String getCurrentTransactionIdStatement() {
 		return "SELECT txid_current();";
diff --git a/src/test/java/org/springframework/data/r2dbc/function/SqlServerDatabaseClientIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/function/SqlServerDatabaseClientIntegrationTests.java
index 9e77bee0..296fff03 100644
--- a/src/test/java/org/springframework/data/r2dbc/function/SqlServerDatabaseClientIntegrationTests.java
+++ b/src/test/java/org/springframework/data/r2dbc/function/SqlServerDatabaseClientIntegrationTests.java
@@ -46,9 +46,4 @@ protected ConnectionFactory createConnectionFactory() {
 	protected String getCreateTableStatement() {
 		return SqlServerTestSupport.CREATE_TABLE_LEGOSET;
 	}
-
-	@Override
-	protected String getInsertIntoLegosetStatement() {
-		return SqlServerTestSupport.INSERT_INTO_LEGOSET;
-	}
 }
diff --git a/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java
index 290160e8..37ec7fce 100644
--- a/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java
+++ b/src/test/java/org/springframework/data/r2dbc/repository/PostgresR2dbcRepositoryIntegrationTests.java
@@ -88,7 +88,7 @@ interface PostgresLegoSetRepository extends LegoSetRepository {
 		Flux findAsProjection();
 
 		@Override
-		@Query("SELECT * FROM legoset WHERE manual = $1")
+		@Query("SELECT * FROM legoset WHERE manual = :manual")
 		Mono findByManual(int manual);
 	}
 }
diff --git a/src/test/java/org/springframework/data/r2dbc/repository/SqlServerR2dbcRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/repository/SqlServerR2dbcRepositoryIntegrationTests.java
index 8bea7d50..7a537540 100644
--- a/src/test/java/org/springframework/data/r2dbc/repository/SqlServerR2dbcRepositoryIntegrationTests.java
+++ b/src/test/java/org/springframework/data/r2dbc/repository/SqlServerR2dbcRepositoryIntegrationTests.java
@@ -93,7 +93,7 @@ interface SqlServerLegoSetRepository extends LegoSetRepository {
 		Flux findAsProjection();
 
 		@Override
-		@Query("SELECT * FROM legoset WHERE manual = @P0")
+		@Query("SELECT * FROM legoset WHERE manual = :manual")
 		Mono findByManual(int manual);
 	}
 }
diff --git a/src/test/java/org/springframework/data/r2dbc/testing/PostgresTestSupport.java b/src/test/java/org/springframework/data/r2dbc/testing/PostgresTestSupport.java
index d7608f09..7c91e486 100644
--- a/src/test/java/org/springframework/data/r2dbc/testing/PostgresTestSupport.java
+++ b/src/test/java/org/springframework/data/r2dbc/testing/PostgresTestSupport.java
@@ -28,8 +28,6 @@ public class PostgresTestSupport {
 			+ "    manual      integer NULL\n" //
 			+ ");";
 
-	public static String INSERT_INTO_LEGOSET = "INSERT INTO legoset (id, name, manual) VALUES($1, $2, $3)";
-
 	/**
 	 * Returns a locally provided database at {@code postgres:@localhost:5432/postgres}.
 	 *