From ef2d885b1e24a31c822900237e4de9a861f09cec Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 18 Mar 2019 14:52:30 +0100 Subject: [PATCH] #73 - Introduce PreparedOperation. We now encapsulate prepared operations from the StatementFactory within PreparedOperation that renders SQL and provides binding values. StatementFactory supports SELECT/INSERT/UPDATE/DELETE statement creation considering Dialect-specific rendering. StatementFactory replaces String-based statement methods in ReactiveDataAccessStrategy. PreparedOperation operation = accessStrategy.getStatements().update(entity.getTableName(), binder -> { binder.bind("name", "updated value"); binder.filterBy("id", SettableValue.from(42)); }); databaseClient.execute().sql(operation).then(); Original pull request: #82. --- .../data/r2dbc/function/BindIdOperation.java | 32 -- .../data/r2dbc/function/DatabaseClient.java | 5 + .../r2dbc/function/DefaultDatabaseClient.java | 65 ++- .../DefaultReactiveDataAccessStrategy.java | 356 ++----------- .../function/DefaultStatementFactory.java | 474 ++++++++++++++++++ .../r2dbc/function/PreparedOperation.java | 48 ++ .../function/ReactiveDataAccessStrategy.java | 43 +- .../data/r2dbc/function/StatementFactory.java | 102 ++++ .../support/SimpleR2dbcRepository.java | 100 ++-- .../r2dbc/support/StatementRenderUtil.java | 1 + ...ltReactiveDataAccessStrategyUnitTests.java | 103 ---- .../function/StatementFactoryUnitTests.java | 247 +++++++++ 12 files changed, 991 insertions(+), 585 deletions(-) delete mode 100644 src/main/java/org/springframework/data/r2dbc/function/BindIdOperation.java create mode 100644 src/main/java/org/springframework/data/r2dbc/function/DefaultStatementFactory.java create mode 100644 src/main/java/org/springframework/data/r2dbc/function/PreparedOperation.java create mode 100644 src/main/java/org/springframework/data/r2dbc/function/StatementFactory.java delete mode 100644 src/test/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategyUnitTests.java create mode 100644 src/test/java/org/springframework/data/r2dbc/function/StatementFactoryUnitTests.java diff --git a/src/main/java/org/springframework/data/r2dbc/function/BindIdOperation.java b/src/main/java/org/springframework/data/r2dbc/function/BindIdOperation.java deleted file mode 100644 index 71f437c..0000000 --- a/src/main/java/org/springframework/data/r2dbc/function/BindIdOperation.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.springframework.data.r2dbc.function; - -import io.r2dbc.spi.Statement; - -/** - * Extension to {@link BindableOperation} for operations that allow parameter substitution for a single {@code id} - * column that accepts either a single value or multiple values, depending on the underlying operation. - * - * @author Mark Paluch - * @see Statement#bind - * @see Statement#bindNull - */ -public interface BindIdOperation extends BindableOperation { - - /** - * Bind the given {@code value} to the {@link Statement} using the underlying binding strategy. - * - * @param statement the statement to bind the value to. - * @param value the actual value. Must not be {@literal null}. - * @see Statement#bind - */ - void bindId(Statement statement, Object value); - - /** - * Bind the given {@code values} to the {@link Statement} using the underlying binding strategy. - * - * @param statement the statement to bind the value to. - * @param values the actual values. - * @see Statement#bind - */ - void bindIds(Statement statement, Iterable values); -} 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 7062fca..df95db7 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DatabaseClient.java @@ -26,6 +26,7 @@ import java.util.function.Consumer; import java.util.function.Supplier; import org.reactivestreams.Publisher; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.support.R2dbcExceptionTranslator; @@ -137,6 +138,9 @@ public interface DatabaseClient { * 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. + *

+ * Accepts {@link PreparedOperation} as SQL and binding {@link Supplier}. + *

* * @see NamedParameterExpander * @see DatabaseClient.Builder#namedParameters(NamedParameterExpander) @@ -156,6 +160,7 @@ public interface DatabaseClient { * * @param sqlSupplier must not be {@literal null}. * @return a new {@link GenericExecuteSpec}. + * @see PreparedOperation */ GenericExecuteSpec sql(Supplier sqlSupplier); } 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 0ccfd57..e837816 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClient.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultDatabaseClient.java @@ -37,7 +37,6 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiFunction; import java.util.function.Function; @@ -49,6 +48,7 @@ import org.apache.commons.logging.LogFactory; import org.reactivestreams.Publisher; import org.springframework.dao.DataAccessException; +import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.r2dbc.UncategorizedR2dbcException; @@ -57,6 +57,7 @@ import org.springframework.data.r2dbc.domain.SettableValue; import org.springframework.data.r2dbc.function.connectionfactory.ConnectionProxy; import org.springframework.data.r2dbc.function.convert.ColumnMapRowMapper; import org.springframework.data.r2dbc.support.R2dbcExceptionTranslator; +import org.springframework.data.relational.core.sql.Insert; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -336,9 +337,17 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { logger.debug("Executing SQL statement [" + sql + "]"); } + if (sqlSupplier instanceof PreparedOperation) { + return ((PreparedOperation) sqlSupplier).bind(it.createStatement(sql)); + } + BindableOperation operation = namedParameters.expand(sql, dataAccessStrategy.getBindMarkersFactory(), new MapBindParameterSource(byName)); + if (logger.isTraceEnabled()) { + logger.trace("Expanded SQL [" + operation.toQuery() + "]"); + } + Statement statement = it.createStatement(operation.toQuery()); byName.forEach((name, o) -> { @@ -366,6 +375,7 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { public ExecuteSpecSupport bind(int index, Object value) { + assertNotPreparedOperation(); Assert.notNull(value, () -> String.format("Value at index %d must not be null. Use bindNull(…) instead.", index)); Map byIndex = new LinkedHashMap<>(this.byIndex); @@ -376,6 +386,8 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { public ExecuteSpecSupport bindNull(int index, Class type) { + assertNotPreparedOperation(); + Map byIndex = new LinkedHashMap<>(this.byIndex); byIndex.put(index, SettableValue.empty(type)); @@ -384,6 +396,8 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { public ExecuteSpecSupport bind(String name, Object value) { + assertNotPreparedOperation(); + Assert.hasText(name, "Parameter name must not be null or empty!"); Assert.notNull(value, () -> String.format("Value for parameter %s must not be null. Use bindNull(…) instead.", name)); @@ -396,6 +410,7 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { public ExecuteSpecSupport bindNull(String name, Class type) { + assertNotPreparedOperation(); Assert.hasText(name, "Parameter name must not be null or empty!"); Map byName = new LinkedHashMap<>(this.byName); @@ -404,6 +419,12 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { return createInstance(this.byIndex, byName, this.sqlSupplier); } + private void assertNotPreparedOperation() { + if (sqlSupplier instanceof PreparedOperation) { + throw new InvalidDataAccessApiUsageException("Cannot add bindings to a PreparedOperation"); + } + } + protected ExecuteSpecSupport createInstance(Map byIndex, Map byName, Supplier sqlSupplier) { return new ExecuteSpecSupport(byIndex, byName, sqlSupplier); @@ -881,20 +902,19 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { throw new IllegalStateException("Insert fields is empty!"); } - BindableOperation bindableInsert = dataAccessStrategy.insertAndReturnGeneratedKeys(table, byName.keySet()); + PreparedOperation operation = dataAccessStrategy.getStatements().insert(table, Collections.emptyList(), + it -> { + byName.forEach(it::bind); + }); - String sql = bindableInsert.toQuery(); + String sql = operation.toQuery(); Function insertFunction = it -> { if (logger.isDebugEnabled()) { logger.debug("Executing SQL statement [" + sql + "]"); } - Statement statement = it.createStatement(sql).returnGeneratedValues(); - - byName.forEach((k, v) -> bindableInsert.bind(statement, k, v)); - - return statement; + return operation.bind(it.createStatement(sql)); }; Function> resultFunction = it -> Flux.from(insertFunction.apply(it).execute()); @@ -998,18 +1018,17 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { OutboundRow outboundRow = dataAccessStrategy.getOutboundRow(toInsert); - Set columns = new LinkedHashSet<>(); + PreparedOperation operation = dataAccessStrategy.getStatements().insert(table, Collections.emptyList(), + it -> { + outboundRow.forEach((k, v) -> { - outboundRow.forEach((k, v) -> { + if (v.hasValue()) { + it.bind(k, v); + } + }); + }); - if (v.hasValue()) { - columns.add(k); - } - }); - - BindableOperation bindableInsert = dataAccessStrategy.insertAndReturnGeneratedKeys(table, columns); - - String sql = bindableInsert.toQuery(); + String sql = operation.toQuery(); Function insertFunction = it -> { @@ -1017,15 +1036,7 @@ class DefaultDatabaseClient implements DatabaseClient, ConnectionAccessor { logger.debug("Executing SQL statement [" + sql + "]"); } - Statement statement = it.createStatement(sql).returnGeneratedValues(); - - outboundRow.forEach((k, v) -> { - if (v.hasValue()) { - bindableInsert.bind(statement, k, v); - } - }); - - return statement; + return operation.bind(it.createStatement(sql)); }; Function> resultFunction = it -> Flux.from(insertFunction.apply(it).execute()); 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 d36ea69..17f3ddb 100644 --- a/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategy.java @@ -17,14 +17,11 @@ package org.springframework.data.r2dbc.function; import io.r2dbc.spi.Row; import io.r2dbc.spi.RowMetadata; -import io.r2dbc.spi.Statement; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.OptionalLong; import java.util.Set; import java.util.function.BiFunction; @@ -37,8 +34,6 @@ import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; import org.springframework.data.mapping.context.MappingContext; 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.domain.OutboundRow; @@ -53,13 +48,17 @@ import org.springframework.data.relational.core.mapping.RelationalPersistentEnti import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.OrderByField; +import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.SelectBuilder.SelectFromAndOrderBy; import org.springframework.data.relational.core.sql.StatementBuilder; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.render.NamingStrategies; +import org.springframework.data.relational.core.sql.render.RenderContext; +import org.springframework.data.relational.core.sql.render.RenderNamingStrategy; +import org.springframework.data.relational.core.sql.render.SelectRenderContext; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; /** * Default {@link ReactiveDataAccessStrategy} implementation. @@ -71,6 +70,7 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra private final Dialect dialect; private final R2dbcConverter converter; private final MappingContext, ? extends RelationalPersistentProperty> mappingContext; + private final StatementFactory statements; /** * Creates a new {@link DefaultReactiveDataAccessStrategy} given {@link Dialect}. @@ -118,6 +118,30 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra this.mappingContext = (MappingContext, ? extends RelationalPersistentProperty>) this.converter .getMappingContext(); this.dialect = dialect; + + RenderContext renderContext = new RenderContext() { + @Override + public RenderNamingStrategy getNamingStrategy() { + return NamingStrategies.asIs(); + } + + @Override + public SelectRenderContext getSelect() { + return new SelectRenderContext() { + @Override + public Function afterSelectList() { + return it -> ""; + } + + @Override + public Function afterOrderBy(boolean hasOrderBy) { + return it -> ""; + } + }; + } + }; + + this.statements = new DefaultStatementFactory(this.dialect, renderContext); } /* @@ -218,7 +242,6 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra * (non-Javadoc) * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#getRowMapper(java.lang.Class) */ - @SuppressWarnings("unchecked") @Override public BiFunction getRowMapper(Class typeToRead) { return new EntityRowMapper<>(typeToRead, converter); @@ -233,6 +256,15 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra return getRequiredPersistentEntity(type).getTableName(); } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#getStatements() + */ + @Override + public StatementFactory getStatements() { + return this.statements; + } + /* * (non-Javadoc) * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#getBindMarkersFactory() @@ -251,15 +283,6 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra return mappingContext.getPersistentEntity(typeToRead); } - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#insertAndReturnGeneratedKeys(java.lang.String, java.util.Set) - */ - @Override - public BindableOperation insertAndReturnGeneratedKeys(String table, Set columns) { - return new DefaultBindableInsert(dialect.getBindMarkersFactory().create(), table, columns); - } - /* * (non-Javadoc) * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#select(java.lang.String, java.util.Set, org.springframework.data.domain.Sort, org.springframework.data.domain.Pageable) @@ -290,6 +313,7 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra offset = OptionalLong.of(page.getOffset()); } + // See https://github.com/spring-projects/spring-data-r2dbc/issues/55 return StatementRenderUtil.render(selectBuilder.build(), limit, offset, this.dialect); } @@ -310,304 +334,4 @@ public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStra return fields; } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#updateById(java.lang.String, java.util.Set, java.lang.String) - */ - @Override - public BindIdOperation updateById(String table, Set columns, String idColumn) { - return new DefaultBindableUpdate(dialect.getBindMarkersFactory().create(), table, columns, idColumn); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#deleteById(java.lang.String, java.lang.String) - */ - @Override - public BindIdOperation deleteById(String table, String idColumn) { - - return new DefaultBindIdOperation(dialect.getBindMarkersFactory().create(), - marker -> String.format("DELETE FROM %s WHERE %s = %s", table, idColumn, marker.getPlaceholder())); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy#deleteByIdIn(java.lang.String, java.lang.String) - */ - @Override - public BindIdOperation deleteByIdIn(String table, String idColumn) { - - String query = String.format("DELETE FROM %s", table); - return new DefaultBindIdIn(dialect.getBindMarkersFactory().create(), query, idColumn); - } - - /** - * Default {@link BindableOperation} implementation for a {@code INSERT} operation. - */ - static class DefaultBindableInsert implements BindableOperation { - - private final Map markers = new LinkedHashMap<>(); - private final String query; - - DefaultBindableInsert(BindMarkers bindMarkers, String table, Collection columns) { - - StringBuilder builder = new StringBuilder(); - List placeholders = new ArrayList<>(columns.size()); - - for (String column : columns) { - BindMarker marker = markers.computeIfAbsent(column, bindMarkers::next); - placeholders.add(marker.getPlaceholder()); - } - - String columnsString = StringUtils.collectionToDelimitedString(columns, ", "); - String placeholdersString = StringUtils.collectionToDelimitedString(placeholders, ", "); - - builder.append("INSERT INTO ").append(table).append(" (").append(columnsString).append(")").append(" VALUES(") - .append(placeholdersString).append(")"); - - this.query = builder.toString(); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindableOperation#bind(io.r2dbc.spi.Statement, java.lang.String, java.lang.Object) - */ - @Override - public void bind(Statement statement, String identifier, Object value) { - markers.get(identifier).bind(statement, value); - } - - /* - * (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) { - markers.get(identifier).bindNull(statement, valueType); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.QueryOperation#toQuery() - */ - @Override - public String toQuery() { - return this.query; - } - } - - /** - * Default {@link BindIdOperation} implementation for a {@code UPDATE} operation using a single key. - */ - static class DefaultBindableUpdate implements BindIdOperation { - - private final Map markers = new LinkedHashMap<>(); - private final BindMarker idMarker; - private final String query; - - DefaultBindableUpdate(BindMarkers bindMarkers, String tableName, Set columns, String idColumnName) { - - this.idMarker = bindMarkers.next(); - - StringBuilder setClause = new StringBuilder(); - - for (String column : columns) { - - BindMarker marker = markers.computeIfAbsent(column, bindMarkers::next); - - if (setClause.length() != 0) { - setClause.append(", "); - } - - setClause.append(column).append(" = ").append(marker.getPlaceholder()); - } - - this.query = String.format("UPDATE %s SET %s WHERE %s = %s", tableName, setClause, idColumnName, - idMarker.getPlaceholder()); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindableOperation#bind(io.r2dbc.spi.Statement, java.lang.String, java.lang.Object) - */ - @Override - public void bind(Statement statement, String identifier, Object value) { - markers.get(identifier).bind(statement, value); - } - - /* - * (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) { - markers.get(identifier).bindNull(statement, valueType); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindIdOperation#bindId(io.r2dbc.spi.Statement, java.lang.Object) - */ - @Override - public void bindId(Statement statement, Object value) { - idMarker.bind(statement, value); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindIdOperation#bindIds(io.r2dbc.spi.Statement, java.lang.Iterable) - */ - @Override - public void bindIds(Statement statement, Iterable values) { - throw new UnsupportedOperationException(); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.QueryOperation#toQuery() - */ - @Override - public String toQuery() { - return this.query; - } - } - - /** - * Default {@link BindIdOperation} implementation for a {@code SELECT} or {@code DELETE} operation using a single key - * in the {@code WHERE} predicate. - */ - static class DefaultBindIdOperation implements BindIdOperation { - - private final BindMarker idMarker; - private final String query; - - DefaultBindIdOperation(BindMarkers bindMarkers, Function queryFunction) { - - this.idMarker = bindMarkers.next(); - this.query = queryFunction.apply(this.idMarker); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindableOperation#bind(io.r2dbc.spi.Statement, java.lang.String, java.lang.Object) - */ - @Override - public void bind(Statement statement, String identifier, Object value) { - throw new UnsupportedOperationException(); - } - - /* - * (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) { - throw new UnsupportedOperationException(); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindIdOperation#bindId(io.r2dbc.spi.Statement, java.lang.Object) - */ - @Override - public void bindId(Statement statement, Object value) { - idMarker.bind(statement, value); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindIdOperation#bindIds(io.r2dbc.spi.Statement, java.lang.Iterable) - */ - @Override - public void bindIds(Statement statement, Iterable values) { - throw new UnsupportedOperationException(); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.QueryOperation#toQuery() - */ - @Override - public String toQuery() { - return this.query; - } - } - - /** - * Default {@link BindIdOperation} implementation for a {@code SELECT … WHERE id IN (…)} or - * {@code DELETE … WHERE id IN (…)}. - */ - static class DefaultBindIdIn implements BindIdOperation { - - private final List markers = new ArrayList<>(); - private final BindMarkers bindMarkers; - private final String baseQuery; - private final String idColumnName; - - DefaultBindIdIn(BindMarkers bindMarkers, String baseQuery, String idColumnName) { - - this.bindMarkers = bindMarkers; - this.baseQuery = baseQuery; - this.idColumnName = idColumnName; - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindableOperation#bind(io.r2dbc.spi.Statement, java.lang.String, java.lang.Object) - */ - @Override - public void bind(Statement statement, String identifier, Object value) { - throw new UnsupportedOperationException(); - } - - /* - * (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) { - throw new UnsupportedOperationException(); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindIdOperation#bindId(io.r2dbc.spi.Statement, java.lang.Object) - */ - @Override - public void bindId(Statement statement, Object value) { - - BindMarker bindMarker = bindMarkers.next(); - markers.add(bindMarker.getPlaceholder()); - bindMarker.bind(statement, value); - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.BindIdOperation#bindIds(io.r2dbc.spi.Statement, java.lang.Iterable) - */ - @Override - public void bindIds(Statement statement, Iterable values) { - - for (Object value : values) { - bindId(statement, value); - } - } - - /* - * (non-Javadoc) - * @see org.springframework.data.r2dbc.function.QueryOperation#toQuery() - */ - @Override - public String toQuery() { - - if (this.markers.isEmpty()) { - throw new UnsupportedOperationException(); - } - - String in = StringUtils.collectionToDelimitedString(this.markers, ", "); - - return String.format("%s WHERE %s IN (%s)", this.baseQuery, this.idColumnName, in); - } - } } diff --git a/src/main/java/org/springframework/data/r2dbc/function/DefaultStatementFactory.java b/src/main/java/org/springframework/data/r2dbc/function/DefaultStatementFactory.java new file mode 100644 index 0000000..6028fd1 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/function/DefaultStatementFactory.java @@ -0,0 +1,474 @@ +/* + * 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 + * + * https://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.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Consumer; + +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.Dialect; +import org.springframework.data.r2dbc.domain.SettableValue; +import org.springframework.data.relational.core.sql.AssignValue; +import org.springframework.data.relational.core.sql.Assignment; +import org.springframework.data.relational.core.sql.Column; +import org.springframework.data.relational.core.sql.Condition; +import org.springframework.data.relational.core.sql.Delete; +import org.springframework.data.relational.core.sql.DeleteBuilder; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Insert; +import org.springframework.data.relational.core.sql.SQL; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.SelectBuilder; +import org.springframework.data.relational.core.sql.StatementBuilder; +import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Update; +import org.springframework.data.relational.core.sql.UpdateBuilder; +import org.springframework.data.relational.core.sql.render.RenderContext; +import org.springframework.data.relational.core.sql.render.SqlRenderer; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Default {@link StatementFactory} implementation. + * + * @author Mark Paluch + */ +@RequiredArgsConstructor +class DefaultStatementFactory implements StatementFactory { + + private final Dialect dialect; + private final RenderContext renderContext; + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.StatementFactory#select(java.lang.String, java.util.Collection, java.util.function.Consumer) + */ + @Override + public PreparedOperation select(String tableName, Collection columnNames, + Consumer binderConsumer); + + /** + * Creates a {@link Insert} statement. + * + * @param tableName must not be {@literal null} or empty. + * @param generatedKeysNames must not be {@literal null}. + * @param binderConsumer customizer for bindings. Supports only + * {@link StatementBinderBuilder#bind(String, SettableValue)} bindings. + * @return the {@link PreparedOperation} to update values in {@code tableName} assigning bound values. + */ + PreparedOperation insert(String tableName, Collection generatedKeysNames, + Consumer binderConsumer); + + /** + * Creates a {@link Update} statement. + * + * @param tableName must not be {@literal null} or empty. + * @param binderConsumer customizer for bindings. + * @return the {@link PreparedOperation} to update values in {@code tableName} assigning bound values. + */ + PreparedOperation update(String tableName, Consumer binderConsumer); + + /** + * Creates a {@link Delete} statement. + * + * @param tableName must not be {@literal null} or empty. + * @param binderConsumer customizer for bindings. Supports only + * {@link StatementBinderBuilder#filterBy(String, SettableValue)} bindings. + * @return the {@link PreparedOperation} to delete rows from {@code tableName}. + */ + PreparedOperation delete(String tableName, Consumer binderConsumer); + + /** + * Binder to specify parameter bindings by name. Bindings match to equals comparisons. + */ + interface StatementBinderBuilder { + + /** + * Bind the given Id {@code value} to this builder using the underlying binding strategy to express a filter + * condition. {@link Collection} type values translate to {@code IN} matching. + * + * @param identifier named identifier that is considered by the underlying binding strategy. + * @param settable must not be {@literal null}. Use {@link SettableValue#empty(Class)} for {@code NULL} values. + */ + void filterBy(String identifier, SettableValue settable); + + /** + * Bind the given {@code value} to this builder using the underlying binding strategy. + * + * @param identifier named identifier that is considered by the underlying binding strategy. + * @param settable must not be {@literal null}. Use {@link SettableValue#empty(Class)} for {@code NULL} values. + */ + void bind(String identifier, SettableValue settable); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java b/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java index 1963616..cda23d9 100644 --- a/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java +++ b/src/main/java/org/springframework/data/r2dbc/repository/support/SimpleR2dbcRepository.java @@ -20,29 +20,25 @@ import lombok.RequiredArgsConstructor; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashSet; -import java.util.List; import java.util.Map; import java.util.Set; import org.reactivestreams.Publisher; -import org.springframework.data.r2dbc.dialect.BindMarker; -import org.springframework.data.r2dbc.dialect.BindMarkers; import org.springframework.data.r2dbc.domain.SettableValue; -import org.springframework.data.r2dbc.function.BindIdOperation; import org.springframework.data.r2dbc.function.DatabaseClient; -import org.springframework.data.r2dbc.function.DatabaseClient.GenericExecuteSpec; +import org.springframework.data.r2dbc.function.PreparedOperation; import org.springframework.data.r2dbc.function.ReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.function.StatementFactory; import org.springframework.data.r2dbc.function.convert.R2dbcConverter; -import org.springframework.data.relational.core.sql.Conditions; -import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.relational.core.sql.Delete; import org.springframework.data.relational.core.sql.Functions; -import org.springframework.data.relational.core.sql.SQL; import org.springframework.data.relational.core.sql.Select; import org.springframework.data.relational.core.sql.StatementBuilder; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.Update; import org.springframework.data.relational.core.sql.render.SqlRenderer; import org.springframework.data.relational.repository.query.RelationalEntityInformation; import org.springframework.data.repository.reactive.ReactiveCrudRepository; @@ -83,15 +79,13 @@ public class SimpleR2dbcRepository implements ReactiveCrudRepository columns = accessStrategy.getOutboundRow(objectToSave); columns.remove(getIdColumnName()); // do not update the Id column. String idColumnName = getIdColumnName(); - BindIdOperation update = accessStrategy.updateById(entity.getTableName(), columns.keySet(), idColumnName); - GenericExecuteSpec exec = databaseClient.execute().sql(update); + PreparedOperation operation = accessStrategy.getStatements().update(entity.getTableName(), binder -> { + columns.forEach(binder::bind); + binder.filterBy(idColumnName, SettableValue.from(id)); + }); - BindSpecAdapter wrapper = BindSpecAdapter.create(exec); - columns.forEach((k, v) -> update.bind(wrapper, k, v)); - update.bindId(wrapper, id); - - return wrapper.getBoundOperation().as(entity.getJavaType()) // + return databaseClient.execute().sql(operation).as(entity.getJavaType()) // .then() // .thenReturn(objectToSave); } @@ -129,18 +123,14 @@ public class SimpleR2dbcRepository implements ReactiveCrudRepository columns = new LinkedHashSet<>(accessStrategy.getAllColumns(entity.getJavaType())); String idColumnName = getIdColumnName(); - BindMarkers bindMarkers = accessStrategy.getBindMarkersFactory().create(); - BindMarker bindMarker = bindMarkers.next("id"); + StatementFactory statements; - Table table = Table.create(entity.getTableName()); - Select select = StatementBuilder // - .select(table.columns(columns)) // - .from(table) // - .where(Conditions.isEqual(table.column(idColumnName), SQL.bindMarker(bindMarker.getPlaceholder()))) // - .build(); + PreparedOperation operation = accessStrategy.getStatements().select(entity.getTableName(), columns, + binder -> { + binder.filterBy(idColumnName, SettableValue.from(ids)); + }); - List markers = new ArrayList<>(); - - for (int i = 0; i < ids.size(); i++) { - markers.add(SQL.bindMarker(bindMarkers.next("id").getPlaceholder())); - } - - Table table = Table.create(entity.getTableName()); - Select select = StatementBuilder.select(table.columns(columns)).from(table) - .where(Conditions.in(table.column(idColumnName), markers)).build(); - - GenericExecuteSpec executeSpec = databaseClient.execute().sql(SqlRenderer.toString(select)); - - for (int i = 0; i < ids.size(); i++) { - executeSpec = executeSpec.bind(i, ids.get(i)); - } - - return executeSpec.as(entity.getJavaType()).fetch().all(); + return databaseClient.execute().sql(operation).as(entity.getJavaType()).fetch().all(); }); } @@ -274,12 +245,11 @@ public class SimpleR2dbcRepository implements ReactiveCrudRepository wrapper = BindSpecAdapter.create(databaseClient.execute().sql(delete)); + PreparedOperation delete = accessStrategy.getStatements().delete(entity.getTableName(), binder -> { + binder.filterBy(getIdColumnName(), SettableValue.from(id)); + }); - delete.bindId(wrapper, id); - - return wrapper.getBoundOperation() // + return databaseClient.execute().sql(delete) // .fetch() // .rowsUpdated() // .then(); @@ -299,13 +269,11 @@ public class SimpleR2dbcRepository implements ReactiveCrudRepository delete = accessStrategy.getStatements().delete(entity.getTableName(), binder -> { + binder.filterBy(getIdColumnName(), SettableValue.from(ids)); + }); - BindSpecAdapter wrapper = BindSpecAdapter.create(databaseClient.execute().sql(delete)); - delete.bindIds(wrapper, ids); - - return wrapper.getBoundOperation().as(entity.getJavaType()).fetch().rowsUpdated(); + return databaseClient.execute().sql(delete).then(); }).then(); } diff --git a/src/main/java/org/springframework/data/r2dbc/support/StatementRenderUtil.java b/src/main/java/org/springframework/data/r2dbc/support/StatementRenderUtil.java index ae8c7dd..b0fb55e 100644 --- a/src/main/java/org/springframework/data/r2dbc/support/StatementRenderUtil.java +++ b/src/main/java/org/springframework/data/r2dbc/support/StatementRenderUtil.java @@ -43,6 +43,7 @@ public abstract class StatementRenderUtil { String sql = SqlRenderer.toString(select); // TODO: Replace with proper {@link Dialect} rendering for limit/offset. + // See https://github.com/spring-projects/spring-data-r2dbc/issues/55 if (limit.isPresent()) { LimitClause limitClause = dialect.limit(); diff --git a/src/test/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategyUnitTests.java b/src/test/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategyUnitTests.java deleted file mode 100644 index 2d6c221..0000000 --- a/src/test/java/org/springframework/data/r2dbc/function/DefaultReactiveDataAccessStrategyUnitTests.java +++ /dev/null @@ -1,103 +0,0 @@ -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.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Map; - -import org.junit.Test; - -import org.springframework.data.r2dbc.dialect.PostgresDialect; -import org.springframework.data.r2dbc.domain.SettableValue; - -/** - * Unit tests for {@link DefaultReactiveDataAccessStrategy}. - * - * @author Mark Paluch - */ -public class DefaultReactiveDataAccessStrategyUnitTests { - - DefaultReactiveDataAccessStrategy strategy = new DefaultReactiveDataAccessStrategy(PostgresDialect.INSTANCE); - - @Test // gh-20 - public void shouldRenderInsertAndReturnGeneratedKeysQuery() { - - BindableOperation operation = strategy.insertAndReturnGeneratedKeys("table", - new HashSet<>(Arrays.asList("firstname", "lastname"))); - - assertThat(operation.toQuery()).isEqualTo("INSERT INTO table (firstname, lastname) VALUES($1, $2)"); - } - - @Test // gh-20 - public void shouldRenderUpdateByIdQuery() { - - BindableOperation operation = strategy.updateById("table", new HashSet<>(Arrays.asList("firstname", "lastname")), - "id"); - - assertThat(operation.toQuery()).isEqualTo("UPDATE table SET firstname = $2, lastname = $3 WHERE id = $1"); - } - - @Test // gh-20 - public void shouldRenderDeleteByIdQuery() { - - BindableOperation operation = strategy.deleteById("table", "id"); - - assertThat(operation.toQuery()).isEqualTo("DELETE FROM table WHERE id = $1"); - } - - @Test // gh-20 - public void shouldRenderDeleteByIdInQuery() { - - Statement statement = mock(Statement.class); - BindIdOperation operation = strategy.deleteByIdIn("table", "id"); - - operation.bindId(statement, Collections.singleton("foo")); - assertThat(operation.toQuery()).isEqualTo("DELETE FROM table WHERE id IN ($1)"); - - operation.bindId(statement, "bar"); - assertThat(operation.toQuery()).isEqualTo("DELETE FROM table WHERE id IN ($1, $2)"); - } - - @Test // gh-22 - public void shouldUpdateArray() { - - Map columnsToUpdate = strategy - .getOutboundRow(new WithCollectionTypes(new String[] { "one", "two" }, null)); - - Object stringArray = columnsToUpdate.get("string_array").getValue(); - - assertThat(stringArray).isInstanceOf(String[].class); - assertThat((String[]) stringArray).hasSize(2).contains("one", "two"); - } - - @Test // gh-22 - public void shouldConvertListToArray() { - - Map columnsToUpdate = strategy - .getOutboundRow(new WithCollectionTypes(null, Arrays.asList("one", "two"))); - - Object stringArray = columnsToUpdate.get("string_collection").getValue(); - - assertThat(stringArray).isInstanceOf(String[].class); - assertThat((String[]) stringArray).hasSize(2).contains("one", "two"); - } - - static class WithCollectionTypes { - - String[] stringArray; - - List stringCollection; - - WithCollectionTypes(String[] stringArray, List stringCollection) { - - this.stringArray = stringArray; - this.stringCollection = stringCollection; - } - } -} diff --git a/src/test/java/org/springframework/data/r2dbc/function/StatementFactoryUnitTests.java b/src/test/java/org/springframework/data/r2dbc/function/StatementFactoryUnitTests.java new file mode 100644 index 0000000..b658745 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/function/StatementFactoryUnitTests.java @@ -0,0 +1,247 @@ +/* + * 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 + * + * https://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.Collections; + +import org.junit.Test; + +import org.springframework.data.r2dbc.dialect.PostgresDialect; +import org.springframework.data.r2dbc.domain.SettableValue; +import org.springframework.data.relational.core.dialect.RenderContextFactory; +import org.springframework.data.relational.core.sql.Delete; +import org.springframework.data.relational.core.sql.Insert; +import org.springframework.data.relational.core.sql.Select; +import org.springframework.data.relational.core.sql.Update; + +/** + * Unit tests for {@link StatementFactory}. + * + * @author Mark Paluch + */ +public class StatementFactoryUnitTests { + + // See https://github.com/spring-projects/spring-data-r2dbc/issues/55 + DefaultStatementFactory statements = new DefaultStatementFactory(PostgresDialect.INSTANCE, + new RenderContextFactory(org.springframework.data.relational.core.dialect.PostgresDialect.INSTANCE) + .createRenderContext()); + + Statement statementMock = mock(Statement.class); + + @Test + public void shouldToQuerySimpleSelectWithoutBindings() { + + PreparedOperation select = statements.select("foo", Arrays.asList("bar", "baz"), it -> { + it.filterBy("doe", SettableValue.from("John")); + }); + + assertThat(select.getSource()).isInstanceOf(Select.class); + assertThat(select.toQuery()).isEqualTo("SELECT foo.bar, foo.baz FROM foo WHERE foo.doe = $1"); + + select.bind(statementMock); + verify(statementMock).bind(0, "John"); + verifyNoMoreInteractions(statementMock); + } + + @Test + public void shouldToQuerySimpleSelectWithMultipleFilters() { + + PreparedOperation select = statements.select("foo", Arrays.asList("bar", "baz"), it -> { + it.filterBy("doe", SettableValue.empty(String.class)); + }); + + assertThat(select.getSource()).isInstanceOf(Select.class); + assertThat(select.toQuery()).isEqualTo("SELECT foo.bar, foo.baz FROM foo WHERE foo.doe IS NULL"); + + select.bind(statementMock); + verifyZeroInteractions(statementMock); + } + + @Test + public void shouldToQuerySimpleSelectWithIterableFilter() { + + PreparedOperation