diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..90ffd65 --- /dev/null +++ b/pom.xml @@ -0,0 +1,411 @@ + + + + 4.0.0 + + org.springframework.data + spring-data-jdbc + 1.1.0.r2dbc-SNAPSHOT + + Spring Data JDBC + Spring Data module for JDBC repositories. + http://projects.spring.io/spring-data-jdbc + + + org.springframework.data.build + spring-data-parent + 2.2.0.BUILD-SNAPSHOT + + + + + DATAJDBC + + 2.2.0.BUILD-SNAPSHOT + spring.data.jdbc + reuseReports + + 3.6.2 + 0.1.4 + 2.2.8 + 3.4.6 + 1.3.2 + 5.1.41 + 42.0.0 + 2.2.3 + 1.0.0.BUILD-SNAPSHOT + 1.0.0.BUILD-SNAPSHOT + 1.7.3 + + + + 2017 + + + + schauder + Jens Schauder + jschauder(at)pivotal.io + Pivotal Software, Inc. + https://pivotal.io + + Project Lead + + +1 + + + gregturn + Greg L. Turnquist + gturnquist(at)pivotal.io + Pivotal Software, Inc. + https://pivotal.io + + Project Contributor + + -6 + + + + + + release + + + + org.jfrog.buildinfo + artifactory-maven-plugin + false + + + + + + + no-jacoco + + + + org.jacoco + jacoco-maven-plugin + + + jacoco-initialize + none + + + + + + + + + + all-dbs + + + + org.apache.maven.plugins + maven-surefire-plugin + + + mysql-test + test + + test + + + + **/*IntegrationTests.java + + + **/*HsqlIntegrationTests.java + + + mysql + + + + + postgres-test + test + + test + + + + **/*IntegrationTests.java + + + **/*HsqlIntegrationTests.java + + + postgres + + + + + mariadb-test + test + + test + + + + **/*IntegrationTests.java + + + **/*HsqlIntegrationTests.java + + + mariadb + + + + + + + + + + + + + + ${project.groupId} + spring-data-commons + ${springdata.commons} + + + + org.springframework + spring-tx + + + + org.springframework + spring-context + + + + org.springframework + spring-beans + + + + org.springframework + spring-jdbc + + + + org.springframework + spring-core + + + + io.r2dbc + r2dbc-spi + ${r2dbc-spi.version} + true + + + + io.projectreactor + reactor-core + true + + + + org.mybatis + mybatis-spring + ${mybatis-spring.version} + true + + + + org.mybatis + mybatis + ${mybatis.version} + true + + + + org.hsqldb + hsqldb + ${hsqldb.version} + test + + + + org.assertj + assertj-core + ${assertj-core.version} + test + + + + io.projectreactor + reactor-test + test + + + + mysql + mysql-connector-java + ${mysql-connector-java.version} + test + + + + org.postgresql + postgresql + ${postgresql.version} + test + + + + org.mariadb.jdbc + mariadb-java-client + ${mariadb-java-client.version} + test + + + + io.r2dbc + r2dbc-postgresql + ${r2dbc-postgresql.version} + test + + + + de.schauderhaft.degraph + degraph-check + ${degraph-check.version} + test + + + + org.testcontainers + mysql + ${testcontainers.version} + test + + + org.slf4j + jcl-over-slf4j + + + + + + org.testcontainers + postgresql + ${testcontainers.version} + test + + + + org.testcontainers + mariadb + ${testcontainers.version} + test + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.12 + + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.0 + + + + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco} + + ${jacoco.destfile} + + + + jacoco-initialize + + prepare-agent + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + default-test + + + **/*Tests.java + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + + org.codehaus.mojo + wagon-maven-plugin + + + org.asciidoctor + asciidoctor-maven-plugin + + + + + + + spring-libs-snapshot + https://repo.spring.io/libs-snapshot + + + nebhale-snapshots + https://raw.githubusercontent.com/nebhale/r2dbc/maven/snapshot + + true + + + + nebhale-milestones + https://raw.githubusercontent.com/nebhale/r2dbc/maven/milestone + + false + + + + + + + spring-plugins-snapshot + https://repo.spring.io/plugins-snapshot + + + + diff --git a/src/main/java/org/springframework/data/jdbc/core/function/ColumnMapRowMapper.java b/src/main/java/org/springframework/data/jdbc/core/function/ColumnMapRowMapper.java new file mode 100644 index 0000000..663d88f --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/ColumnMapRowMapper.java @@ -0,0 +1,102 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import io.r2dbc.spi.ColumnMetadata; +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; + +import java.util.Collection; +import java.util.Map; +import java.util.function.BiFunction; + +import org.springframework.lang.Nullable; +import org.springframework.util.LinkedCaseInsensitiveMap; + +/** + * {@link RowMapper} implementation that creates a {@link Map} for each row, representing all columns as key-value + * pairs: one entry for each column, with the column name as key. + *

+ * The {@link Map} implementation to use and the key to use for each column in the column Map can be customized through + * overriding {@link #createColumnMap} and {@link #getColumnKey}, respectively. + *

+ * Note: By default, {@link ColumnMapRowMapper} will try to build a linked {@link Map} with case-insensitive + * keys, to preserve column order as well as allow any casing to be used for column names. This requires Commons + * Collections on the classpath (which will be autodetected). Else, the fallback is a standard linked + * {@link java.util.HashMap}, which will still preserve column order but requires the application to specify the column + * names in the same casing as exposed by the driver. + * + * @author Mark Paluch + */ +public class ColumnMapRowMapper implements BiFunction> { + + public final static ColumnMapRowMapper INSTANCE = new ColumnMapRowMapper(); + + @Override + public Map apply(Row row, RowMetadata rowMetadata) { + + Collection columns = IterableUtils.toCollection(rowMetadata.getColumnMetadatas()); + int columnCount = columns.size(); + Map mapOfColValues = createColumnMap(columnCount); + + int index = 0; + for (ColumnMetadata column : columns) { + + String key = getColumnKey(column.getName()); + Object obj = getColumnValue(row, index++); + mapOfColValues.put(key, obj); + } + return mapOfColValues; + } + + /** + * Create a {@link Map} instance to be used as column map. + *

+ * By default, a linked case-insensitive Map will be created. + * + * @param columnCount the column count, to be used as initial capacity for the Map. + * @return the new {@link Map} instance. + * @see LinkedCaseInsensitiveMap + */ + protected Map createColumnMap(int columnCount) { + return new LinkedCaseInsensitiveMap<>(columnCount); + } + + /** + * Determine the key to use for the given column in the column {@link Map}. + * + * @param columnName the column name as returned by the {@link Row}. + * @return the column key to use. + * @see ColumnMetadata#getName() + */ + protected String getColumnKey(String columnName) { + return columnName; + } + + /** + * Retrieve a R2DBC object value for the specified column. + *

+ * The default implementation uses the {@link Row#get(Object, Class)} method. + * + * @param row is the {@link Row} holding the data. + * @param index is the column index. + * @return the Object returned. + */ + @Nullable + protected Object getColumnValue(Row row, int index) { + return row.get(index, Object.class); + } +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/DatabaseClient.java b/src/main/java/org/springframework/data/jdbc/core/function/DatabaseClient.java new file mode 100644 index 0000000..5926592 --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/DatabaseClient.java @@ -0,0 +1,451 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.function.BiFunction; +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.jdbc.support.SQLExceptionTranslator; + +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; + +/** + * A non-blocking, reactive client for performing database calls requests with Reactive Streams back pressure. Provides + * a higher level, common API over R2DBC client libraries. + *

+ * Use one of the static factory methods {@link #create(ConnectionFactory)} or obtain a {@link DatabaseClient#builder()} + * to create an instance. + * + * @author Mark Paluch + */ +public interface DatabaseClient { + + /** + * Prepare an SQL call returning a result. + */ + SqlSpec execute(); + + /** + * Prepare an SQL SELECT call. + */ + SelectFromSpec select(); + + /** + * Prepare an SQL INSERT call. + */ + InsertIntoSpec insert(); + + /** + * Return a builder to mutate properties of this database client. + */ + DatabaseClient.Builder mutate(); + + // Static, factory methods + + /** + * A variant of {@link #create()} that accepts a {@link io.r2dbc.spi.ConnectionFactory} + */ + static DatabaseClient create(ConnectionFactory factory) { + return new DefaultDatabaseClientBuilder().connectionFactory(factory).build(); + } + + /** + * Obtain a {@code WebClient} builder. + */ + static DatabaseClient.Builder builder() { + return new DefaultDatabaseClientBuilder(); + } + + /** + * A mutable builder for creating a {@link DatabaseClient}. + */ + interface Builder { + + /** + * Configures the {@link ConnectionFactory R2DBC connector}. + * + * @param factory must not be {@literal null}. + * @return {@code this} {@link Builder}. + */ + Builder connectionFactory(ConnectionFactory factory); + + /** + * Configures a {@link SQLExceptionTranslator}. + * + * @param exceptionTranslator must not be {@literal null}. + * @return {@code this} {@link Builder}. + */ + Builder exceptionTranslator(SQLExceptionTranslator exceptionTranslator); + + /** + * Configures a {@link ReactiveDataAccessStrategy}. + * + * @param accessStrategy must not be {@literal null}. + * @return {@code this} {@link Builder}. + */ + Builder dataAccessStrategy(ReactiveDataAccessStrategy accessStrategy); + + /** + * Configures a {@link Consumer} to configure this builder. + * + * @param builderConsumer must not be {@literal null}. + * @return {@code this} {@link Builder}. + */ + Builder apply(Consumer builderConsumer); + + /** + * Builder the {@link DatabaseClient} instance. + */ + DatabaseClient build(); + } + + /** + * Contract for specifying a SQL call along with options leading to the exchange. + */ + interface SqlSpec { + + /** + * Specify a static {@code sql} string to execute. + * + * @param sql must not be {@literal null} or empty. + * @return a new {@link GenericExecuteSpec}. + */ + GenericExecuteSpec sql(String sql); + + /** + * Specify a static {@link Supplier SQL supplier} that provides SQL to execute. + * + * @param sqlSupplier must not be {@literal null}. + * @return a new {@link GenericExecuteSpec}. + */ + GenericExecuteSpec sql(Supplier sqlSupplier); + } + + /** + * Contract for specifying a SQL call along with options leading to the exchange. + */ + interface GenericExecuteSpec extends BindSpec { + + /** + * Define the target type the result should be mapped to.
+ * Skip this step if you are anyway fine with the default conversion. + * + * @param resultType must not be {@literal null}. + * @param result type. + */ + TypedExecuteSpec as(Class resultType); + + /** + * Perform the SQL call and retrieve the result. + */ + FetchSpec> fetch(); + + /** + * Perform the SQL request and return a {@link SqlResult}. + * + * @return a {@code Mono} for the result + */ + Mono>> exchange(); + } + + /** + * Contract for specifying a SQL call along with options leading to the exchange. + */ + interface TypedExecuteSpec extends BindSpec> { + + /** + * Define the target type the result should be mapped to.
+ * Skip this step if you are anyway fine with the default conversion. + * + * @param resultType must not be {@literal null}. + * @param result type. + */ + TypedExecuteSpec as(Class resultType); + + /** + * Perform the SQL call and retrieve the result. + */ + FetchSpec fetch(); + + /** + * Perform the SQL request and return a {@link SqlResult}. + * + * @return a {@code Mono} for the result + */ + Mono> exchange(); + } + + /** + * Contract for specifying {@code SELECT} options leading to the exchange. + */ + interface SelectFromSpec { + + GenericSelectSpec from(String table); + + TypedSelectSpec from(Class table); + } + + /** + * Contract for specifying {@code SELECT} options leading to the exchange. + */ + interface InsertIntoSpec { + + /** + * Specify the target {@literal table} to insert into. + * + * @param table must not be {@literal null} or empty. + * @return + */ + GenericInsertSpec into(String table); + + TypedInsertSpec into(Class table); + } + + /** + * Contract for specifying {@code SELECT} options leading to the exchange. + */ + interface GenericSelectSpec extends SelectSpec { + + /** + * Define the target type the result should be mapped to.
+ * Skip this step if you are anyway fine with the default conversion. + * + * @param resultType must not be {@literal null}. + * @param result type. + */ + TypedSelectSpec as(Class resultType); + + /** + * Perform the SQL call and retrieve the result. + */ + FetchSpec> fetch(); + + /** + * Perform the SQL request and return a {@link SqlResult}. + * + * @return a {@code Mono} for the result + */ + Mono>> exchange(); + } + + /** + * Contract for specifying {@code SELECT} options leading to the exchange. + */ + interface TypedSelectSpec extends SelectSpec> { + + /** + * Define the target type the result should be mapped to.
+ * Skip this step if you are anyway fine with the default conversion. + * + * @param resultType must not be {@literal null}. + * @param result type. + */ + TypedSelectSpec as(Class resultType); + + /** + * Configure a result mapping {@link java.util.function.Function}. + * + * @param mappingFunction must not be {@literal null}. + * @param result type. + * @return + */ + TypedSelectSpec extract(BiFunction mappingFunction); + + /** + * Perform the SQL call and retrieve the result. + */ + FetchSpec fetch(); + + /** + * Perform the SQL request and return a {@link SqlResult}. + * + * @return a {@code Mono} for the result + */ + Mono> exchange(); + } + + /** + * Contract for specifying {@code SELECT} options leading to the exchange. + */ + interface SelectSpec> { + + S project(String... selectedFields); + + S where(Object criteriaDefinition); + + S orderBy(Sort sort); + + S page(Pageable page); + } + + /** + * Contract for specifying {@code INSERT} options leading to the exchange. + */ + interface GenericInsertSpec extends InsertSpec { + + /** + * Specify a field and non-{@literal null} value to insert. + * + * @param field must not be {@literal null} or empty. + * @param value must not be {@literal null} + */ + GenericInsertSpec value(String field, Object value); + + /** + * Specify a {@literal null} value to insert. + * + * @param field must not be {@literal null} or empty. + */ + GenericInsertSpec nullValue(String field); + } + + /** + * Contract for specifying {@code SELECT} options leading the exchange. + */ + interface TypedInsertSpec { + + /** + * Insert the given {@code objectToInsert}. + * + * @param objectToInsert + * @return + */ + InsertSpec using(T objectToInsert); + + /** + * Use the given {@code tableName} as insert target. + * + * @param tableName must not be {@literal null} or empty. + * @return + */ + TypedInsertSpec table(String tableName); + + /** + * Insert the given {@link Publisher} to insert one or more objects. + * + * @param objectToInsert + * @return + */ + InsertSpec using(Publisher objectToInsert); + } + + /** + * Contract for specifying {@code INSERT} options leading to the exchange. + */ + interface InsertSpec { + + /** + * Perform the SQL call. + */ + Mono then(); + + /** + * Perform the SQL request and return a {@link SqlResult}. + * + * @return a {@code Mono} for the result + */ + Mono>> exchange(); + } + + /** + * Contract for specifying parameter bindings. + */ + interface BindSpec> { + + /** + * Bind a non-{@literal null} value to a parameter identified by its {@code index}. + * + * @param index + * @param value must not be {@literal null}. + */ + S bind(int index, Object value); + + /** + * Bind a {@literal null} value to a parameter identified by its {@code index}. + * + * @param index + */ + S bindNull(int index); + + /** + * Bind a non-{@literal null} value to a parameter identified by its {@code name}. + * + * @param name must not be {@literal null} or empty. + * @param value must not be {@literal null}. + */ + S bind(String name, Object value); + + /** + * Bind a {@literal null} value to a parameter identified by its {@code name}. + * + * @param name must not be {@literal null} or empty. + */ + S bindNull(String name); + + /** + * Bind a bean according to Java {@link java.beans.BeanInfo Beans} using property names. + * + * @param bean must not be {@literal null}. + */ + S bind(Object bean); + } + + /** + * Contract for fetching results. + */ + interface FetchSpec { + + /** + * Get exactly zero or one result. + * + * @return {@link Mono#empty()} if no match found. Never {@literal null}. + * @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one match found. + */ + Mono one(); + + /** + * Get the first or no result. + * + * @return {@link Mono#empty()} if no match found. Never {@literal null}. + */ + Mono first(); + + /** + * Get all matching elements. + * + * @return never {@literal null}. + */ + Flux all(); + + /** + * Get the number of updated rows. + * + * @return {@link Mono} emitting the number of updated rows. Never {@literal null}. + */ + Mono rowsUpdated(); + } + +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/DefaultDatabaseClient.java b/src/main/java/org/springframework/data/jdbc/core/function/DefaultDatabaseClient.java new file mode 100644 index 0000000..de640ca --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/DefaultDatabaseClient.java @@ -0,0 +1,793 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.Result; +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; +import io.r2dbc.spi.Statement; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.sql.SQLException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.dao.UncategorizedDataAccessException; +import org.springframework.data.jdbc.core.function.connectionfactory.ConnectionProxy; +import org.springframework.data.util.Pair; +import org.springframework.jdbc.core.SqlProvider; +import org.springframework.jdbc.support.SQLExceptionTranslator; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Default implementation of {@link DatabaseClient}. + * + * @author Mark Paluch + */ +class DefaultDatabaseClient implements DatabaseClient { + + /** Logger available to subclasses */ + protected final Log logger = LogFactory.getLog(getClass()); + + private final ConnectionFactory connector; + + private final SQLExceptionTranslator exceptionTranslator; + + private final ReactiveDataAccessStrategy dataAccessStrategy; + + private final DefaultDatabaseClientBuilder builder; + + public DefaultDatabaseClient(ConnectionFactory connector, SQLExceptionTranslator exceptionTranslator, + ReactiveDataAccessStrategy dataAccessStrategy, DefaultDatabaseClientBuilder builder) { + + this.connector = connector; + this.exceptionTranslator = exceptionTranslator; + this.dataAccessStrategy = dataAccessStrategy; + this.builder = builder; + } + + @Override + public Builder mutate() { + return builder; + } + + @Override + public SqlSpec execute() { + return new DefaultSqlSpec(); + } + + @Override + public SelectFromSpec select() { + throw new UnsupportedOperationException("Implement me"); + } + + @Override + public InsertIntoSpec insert() { + return new DefaultInsertIntoSpec(); + } + + public Flux execute(Function> action) throws DataAccessException { + + Assert.notNull(action, "Callback object must not be null"); + + Mono connectionMono = Mono.from(obtainConnectionFactory().create()); + // Create close-suppressing Connection proxy, also preparing returned Statements. + + return connectionMono.flatMapMany(connection -> { + + Connection connectionToUse = createConnectionProxy(connection); + + // TODO: Release connection + return doInConnection(action, connectionToUse); + + }).onErrorMap(SQLException.class, ex -> { + + String sql = getSql(action); + return translateException("ConnectionCallback", sql, ex); + }); + } + + /** + * Obtain the {@link ConnectionFactory} for actual use. + * + * @return the ConnectionFactory (never {@code null}) + * @throws IllegalStateException in case of no DataSource set + */ + protected ConnectionFactory obtainConnectionFactory() { + return connector; + } + + /** + * Create a close-suppressing proxy for the given R2DBC Connection. Called by the {@code execute} method. + * + * @param con the R2DBC Connection to create a proxy for + * @return the Connection proxy + */ + protected Connection createConnectionProxy(Connection con) { + return (Connection) Proxy.newProxyInstance(ConnectionProxy.class.getClassLoader(), + new Class[] { ConnectionProxy.class }, new CloseSuppressingInvocationHandler(con)); + } + + /** + * Translate the given {@link SQLException} into a generic {@link DataAccessException}. + * + * @param task readable text describing the task being attempted + * @param sql SQL query or update that caused the problem (may be {@code null}) + * @param ex the offending {@code SQLException} + * @return a DataAccessException wrapping the {@code SQLException} (never {@code null}) + */ + protected DataAccessException translateException(String task, @Nullable String sql, SQLException ex) { + DataAccessException dae = exceptionTranslator.translate(task, sql, ex); + return (dae != null ? dae : new UncategorizedSQLException(task, sql, ex)); + } + + static void doBind(Statement statement, Map> byName, + Map> byIndex) { + + byIndex.forEach((i, o) -> { + + if (o.isPresent()) { + o.ifPresent(v -> statement.bind(i, v)); + } else { + statement.bindNull(i, 0); // TODO: What is type? + } + }); + + byName.forEach((name, o) -> { + + if (o.isPresent()) { + o.ifPresent(v -> statement.bind(name, v)); + } else { + statement.bindNull(name, 0); // TODO: What is type? + } + }); + + } + + /** + * Default {@link org.springframework.data.jdbc.core.function.DatabaseClient.SqlSpec} implementation. + */ + private class DefaultSqlSpec implements SqlSpec { + + @Override + public GenericExecuteSpec sql(String sql) { + + Assert.hasText(sql, "SQL must not be null or empty!"); + return sql(() -> sql); + } + + @Override + public GenericExecuteSpec sql(Supplier sqlSupplier) { + + Assert.notNull(sqlSupplier, "SQL Supplier must not be null!"); + + return new DefaultGenericExecuteSpec(sqlSupplier); + } + } + + /** + * Default {@link org.springframework.data.jdbc.core.function.DatabaseClient.GenericExecuteSpec} implementation. + */ + @RequiredArgsConstructor + private class GenericExecuteSpecSupport { + + final Map> byIndex; + final Map> byName; + final Supplier sqlSupplier; + + GenericExecuteSpecSupport(Supplier sqlSupplier) { + + this.byIndex = Collections.emptyMap(); + this.byName = Collections.emptyMap(); + this.sqlSupplier = sqlSupplier; + } + + protected String getSql() { + + String sql = sqlSupplier.get(); + Assert.state(sql != null, "SQL supplier returned null!"); + return sql; + } + + Mono> exchange(String sql, BiFunction mappingFunction) { + + return execute(it -> { + + Statement statement = it.createStatement(sql); + doBind(statement, byName, byIndex); + + return Flux + .just((SqlResult) new DefaultSqlResult<>(sql, Flux.from(statement.add().execute()), mappingFunction)); + }).next(); + } + + public GenericExecuteSpecSupport bind(int index, Object value) { + + Map> byIndex = new LinkedHashMap<>(this.byIndex); + byIndex.put(index, Optional.of(value)); + + return createInstance(byIndex, this.byName, this.sqlSupplier); + } + + public GenericExecuteSpecSupport bindNull(int index) { + + Map> byIndex = new LinkedHashMap<>(this.byIndex); + byIndex.put(index, Optional.empty()); + + return createInstance(byIndex, this.byName, this.sqlSupplier); + } + + public GenericExecuteSpecSupport bind(String name, Object value) { + + Assert.hasText(name, "Parameter name must not be null or empty!"); + + Map> byName = new LinkedHashMap<>(this.byName); + byName.put(name, Optional.of(value)); + + return createInstance(this.byIndex, byName, this.sqlSupplier); + } + + public GenericExecuteSpecSupport bindNull(String name) { + + Assert.hasText(name, "Parameter name must not be null or empty!"); + + Map> byName = new LinkedHashMap<>(this.byName); + byName.put(name, Optional.empty()); + + return createInstance(this.byIndex, byName, this.sqlSupplier); + } + + protected GenericExecuteSpecSupport createInstance(Map> byIndex, + Map> byName, Supplier sqlSupplier) { + return new GenericExecuteSpecSupport(byIndex, byName, sqlSupplier); + } + + public GenericExecuteSpecSupport bind(Object bean) { + + Assert.notNull(bean, "Bean must not be null!"); + + throw new UnsupportedOperationException("Implement me!"); + } + } + + /** + * Default {@link org.springframework.data.jdbc.core.function.DatabaseClient.GenericExecuteSpec} implementation. + */ + private class DefaultGenericExecuteSpec extends GenericExecuteSpecSupport implements GenericExecuteSpec { + + DefaultGenericExecuteSpec(Map> byIndex, Map> byName, + Supplier sqlSupplier) { + super(byIndex, byName, sqlSupplier); + } + + DefaultGenericExecuteSpec(Supplier sqlSupplier) { + super(sqlSupplier); + } + + @Override + public TypedExecuteSpec as(Class resultType) { + + Assert.notNull(resultType, "Result type must not be null!"); + + return new DefaultTypedGenericExecuteSpec<>(this.byIndex, this.byName, this.sqlSupplier, resultType); + } + + @Override + public FetchSpec> fetch() { + + String sql = getSql(); + return new DefaultFetchSpec<>(sql, exchange(sql, ColumnMapRowMapper.INSTANCE).flatMapMany(SqlResult::all), + exchange(sql, ColumnMapRowMapper.INSTANCE).flatMap(FetchSpec::rowsUpdated)); + } + + @Override + public Mono>> exchange() { + return exchange(getSql(), ColumnMapRowMapper.INSTANCE); + } + + @Override + public DefaultGenericExecuteSpec bind(int index, Object value) { + return (DefaultGenericExecuteSpec) super.bind(index, value); + } + + @Override + public DefaultGenericExecuteSpec bindNull(int index) { + return (DefaultGenericExecuteSpec) super.bindNull(index); + } + + @Override + public DefaultGenericExecuteSpec bind(String name, Object value) { + return (DefaultGenericExecuteSpec) super.bind(name, value); + } + + @Override + public DefaultGenericExecuteSpec bindNull(String name) { + return (DefaultGenericExecuteSpec) super.bindNull(name); + } + + @Override + public DefaultGenericExecuteSpec bind(Object bean) { + return (DefaultGenericExecuteSpec) super.bind(bean); + } + + @Override + protected GenericExecuteSpecSupport createInstance(Map> byIndex, + Map> byName, Supplier sqlSupplier) { + return new DefaultGenericExecuteSpec(byIndex, byName, sqlSupplier); + } + } + + /** + * Default {@link org.springframework.data.jdbc.core.function.DatabaseClient.GenericExecuteSpec} implementation. + */ + @SuppressWarnings("unchecked") + private class DefaultTypedGenericExecuteSpec extends GenericExecuteSpecSupport implements TypedExecuteSpec { + + private final Class typeToRead; + private final BiFunction mappingFunction; + + DefaultTypedGenericExecuteSpec(Map> byIndex, Map> byName, + Supplier sqlSupplier, Class typeToRead) { + + super(byIndex, byName, sqlSupplier); + + this.typeToRead = typeToRead; + this.mappingFunction = dataAccessStrategy.getRowMapper(typeToRead); + } + + @Override + public TypedExecuteSpec as(Class resultType) { + + Assert.notNull(resultType, "Result type must not be null!"); + + return new DefaultTypedGenericExecuteSpec<>(this.byIndex, this.byName, this.sqlSupplier, resultType); + } + + @Override + public FetchSpec fetch() { + String sql = getSql(); + return new DefaultFetchSpec<>(sql, exchange(sql, mappingFunction).flatMapMany(SqlResult::all), + exchange(sql, mappingFunction).flatMap(FetchSpec::rowsUpdated)); + } + + @Override + public Mono> exchange() { + return exchange(getSql(), mappingFunction); + } + + @Override + public DefaultTypedGenericExecuteSpec bind(int index, Object value) { + return (DefaultTypedGenericExecuteSpec) super.bind(index, value); + } + + @Override + public DefaultTypedGenericExecuteSpec bindNull(int index) { + return (DefaultTypedGenericExecuteSpec) super.bindNull(index); + } + + @Override + public DefaultTypedGenericExecuteSpec bind(String name, Object value) { + return (DefaultTypedGenericExecuteSpec) super.bind(name, value); + } + + @Override + public DefaultTypedGenericExecuteSpec bindNull(String name) { + return (DefaultTypedGenericExecuteSpec) super.bindNull(name); + } + + @Override + public DefaultTypedGenericExecuteSpec bind(Object bean) { + return (DefaultTypedGenericExecuteSpec) super.bind(bean); + } + + @Override + protected DefaultTypedGenericExecuteSpec createInstance(Map> byIndex, + Map> byName, Supplier sqlSupplier) { + return new DefaultTypedGenericExecuteSpec<>(byIndex, byName, sqlSupplier, typeToRead); + } + } + + /** + * Default {@link org.springframework.data.jdbc.core.function.DatabaseClient.InsertIntoSpec} implementation. + */ + class DefaultInsertIntoSpec implements InsertIntoSpec { + + @Override + public GenericInsertSpec into(String table) { + return new DefaultGenericInsertSpec(table, Collections.emptyMap()); + } + + @Override + public TypedInsertSpec into(Class table) { + return new DefaultTypedInsertSpec<>(table); + } + } + + /** + * Default implementation of {@link org.springframework.data.jdbc.core.function.DatabaseClient.GenericInsertSpec}. + */ + @RequiredArgsConstructor + class DefaultGenericInsertSpec implements GenericInsertSpec { + + private final String table; + private final Map> byName; + + @Override + public GenericInsertSpec value(String field, Object value) { + + Assert.notNull(field, "Field must not be null!"); + + Map> byName = new LinkedHashMap<>(this.byName); + byName.put(field, Optional.of(value)); + + return new DefaultGenericInsertSpec(this.table, byName); + } + + @Override + public GenericInsertSpec nullValue(String field) { + + Assert.notNull(field, "Field must not be null!"); + + Map> byName = new LinkedHashMap<>(this.byName); + byName.put(field, Optional.empty()); + + return new DefaultGenericInsertSpec(this.table, byName); + } + + @Override + public Mono then() { + return exchange().flatMapMany(FetchSpec::all).then(); + } + + @Override + public Mono>> exchange() { + + if (byName.isEmpty()) { + throw new IllegalStateException("Insert fields is empty!"); + } + + StringBuilder builder = new StringBuilder(); + String fieldNames = byName.keySet().stream().collect(Collectors.joining(",")); + String placeholders = IntStream.range(0, byName.size()).mapToObj(i -> "$" + (i + 1)) + .collect(Collectors.joining(",")); + + builder.append("INSERT INTO ").append(table).append(" (").append(fieldNames).append(") ").append(" VALUES(") + .append(placeholders).append(")"); + + return execute(it -> { + + String sql = builder.toString(); + Statement statement = it.createStatement(sql); + + AtomicInteger index = new AtomicInteger(); + for (Optional o : byName.values()) { + + if (o.isPresent()) { + o.ifPresent(v -> statement.bind(index.getAndIncrement(), v)); + } else { + statement.bindNull("$" + (index.getAndIncrement() + 1), 0); // TODO: What is type? + } + } + + SqlResult> result = new DefaultSqlResult<>(sql, + Flux.from(statement.executeReturningGeneratedKeys()), ColumnMapRowMapper.INSTANCE); + return Flux.just(result); + + }).next(); + } + } + + /** + * Default implementation of {@link org.springframework.data.jdbc.core.function.DatabaseClient.TypedInsertSpec}. + */ + @RequiredArgsConstructor + class DefaultTypedInsertSpec implements TypedInsertSpec, InsertSpec { + + private final Class typeToInsert; + private final String table; + private final Publisher objectToInsert; + + public DefaultTypedInsertSpec(Class typeToInsert) { + + this.typeToInsert = typeToInsert; + this.table = dataAccessStrategy.getTableName(typeToInsert); + this.objectToInsert = Mono.empty(); + } + + @Override + public TypedInsertSpec table(String tableName) { + + Assert.hasText(tableName, "Table name must not be null or empty!"); + + return new DefaultTypedInsertSpec<>(typeToInsert, tableName, objectToInsert); + } + + @Override + public InsertSpec using(T objectToInsert) { + + Assert.notNull(objectToInsert, "Object to insert must not be null!"); + + return new DefaultTypedInsertSpec<>(typeToInsert, table, Mono.just(objectToInsert)); + } + + @Override + public InsertSpec using(Publisher objectToInsert) { + + Assert.notNull(objectToInsert, "Publisher to insert must not be null!"); + + return new DefaultTypedInsertSpec<>(typeToInsert, table, objectToInsert); + } + + @Override + public Mono then() { + return exchange().flatMapMany(FetchSpec::all).then(); + } + + @Override + public Mono>> exchange() { + + return Mono.from(objectToInsert).flatMap(toInsert -> { + + StringBuilder builder = new StringBuilder(); + + List> insertValues = dataAccessStrategy.getInsert(toInsert); + String fieldNames = insertValues.stream().map(Pair::getFirst).collect(Collectors.joining(",")); + String placeholders = IntStream.range(0, insertValues.size()).mapToObj(i -> "$" + (i + 1)) + .collect(Collectors.joining(",")); + + builder.append("INSERT INTO ").append(table).append(" (").append(fieldNames).append(") ").append(" VALUES(") + .append(placeholders).append(")"); + + return execute(it -> { + + String sql = builder.toString(); + Statement statement = it.createStatement(sql); + + AtomicInteger index = new AtomicInteger(); + + for (Pair pair : insertValues) { + + if (pair.getSecond() != null) { // TODO: Better type to transport null values. + statement.bind(index.getAndIncrement(), pair.getSecond()); + } else { + statement.bindNull("$" + (index.getAndIncrement() + 1), 0); // TODO: What is type? + } + } + + SqlResult> result = new DefaultSqlResult<>(sql, + Flux.from(statement.executeReturningGeneratedKeys()), ColumnMapRowMapper.INSTANCE); + return Flux.just(result); + + }).next(); + }); + } + } + + /** + * Default {@link org.springframework.data.jdbc.core.function.DatabaseClient.SqlResult} implementation. + */ + static class DefaultSqlResult implements SqlResult { + + private final String sql; + private final Flux result; + private final FetchSpec fetchSpec; + + DefaultSqlResult(String sql, Flux result, BiFunction mappingFunction) { + + this.sql = sql; + this.result = result; + this.fetchSpec = new DefaultFetchSpec<>(sql, result.flatMap(it -> it.map(mappingFunction)), + result.flatMap(Result::getRowsUpdated).next()); + } + + @Override + public SqlResult extract(BiFunction mappingFunction) { + return new DefaultSqlResult<>(sql, result, mappingFunction); + } + + @Override + public Mono one() { + return fetchSpec.one(); + } + + @Override + public Mono first() { + return fetchSpec.first(); + } + + @Override + public Flux all() { + return fetchSpec.all(); + } + + @Override + public Mono rowsUpdated() { + return fetchSpec.rowsUpdated(); + } + } + + @RequiredArgsConstructor + static class DefaultFetchSpec implements FetchSpec { + + private final String sql; + private final Flux result; + private final Mono updatedRows; + + @Override + public Mono one() { + + return all().buffer(2) // + .flatMap(it -> { + + if (it.isEmpty()) { + return Mono.empty(); + } + + if (it.size() > 1) { + return Mono.error(new IncorrectResultSizeDataAccessException( + String.format("Query [%s] returned non unique result.", this.sql), 1)); + } + + return Mono.just(it.get(0)); + }).next(); + } + + @Override + public Mono first() { + return all().next(); + } + + @Override + public Flux all() { + return result; + } + + @Override + public Mono rowsUpdated() { + return updatedRows; + } + } + + private static Flux doInConnection(Function> action, Connection it) { + + try { + return action.apply(it); + } catch (RuntimeException e) { + + String sql = getSql(action); + return Flux.error(new DefaultDatabaseClient.UncategorizedSQLException("ConnectionCallback", sql, e) {}); + } + } + + /** + * Determine SQL from potential provider object. + * + * @param sqlProvider object that's potentially a SqlProvider + * @return the SQL string, or {@code null} + * @see SqlProvider + */ + @Nullable + private static String getSql(Object sqlProvider) { + + if (sqlProvider instanceof SqlProvider) { + return ((SqlProvider) sqlProvider).getSql(); + } else { + return null; + } + } + + /** + * Invocation handler that suppresses close calls on R2DBC Connections. Also prepares returned Statement + * (Prepared/CallbackStatement) objects. + * + * @see Connection#close() + */ + private class CloseSuppressingInvocationHandler implements InvocationHandler { + + private final Connection target; + + CloseSuppressingInvocationHandler(Connection target) { + this.target = target; + } + + @Override + @Nullable + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // Invocation on ConnectionProxy interface coming in... + + if (method.getName().equals("equals")) { + // Only consider equal when proxies are identical. + return (proxy == args[0]); + } else if (method.getName().equals("hashCode")) { + // Use hashCode of PersistenceManager proxy. + return System.identityHashCode(proxy); + } else if (method.getName().equals("unwrap")) { + if (((Class) args[0]).isInstance(proxy)) { + return proxy; + } + } else if (method.getName().equals("isWrapperFor")) { + if (((Class) args[0]).isInstance(proxy)) { + return true; + } + } else if (method.getName().equals("close")) { + // Handle close method: suppress, not valid. + return Mono.error(new UnsupportedOperationException("Close is not supported!")); + } else if (method.getName().equals("getTargetConnection")) { + // Handle getTargetConnection method: return underlying Connection. + return this.target; + } + + // Invoke method on target Connection. + try { + Object retVal = method.invoke(this.target, args); + + return retVal; + } catch (InvocationTargetException ex) { + throw ex.getTargetException(); + } + } + } + + private static class UncategorizedSQLException extends UncategorizedDataAccessException { + + /** SQL that led to the problem */ + @Nullable private final String sql; + + /** + * Constructor for UncategorizedSQLException. + * + * @param task name of current task + * @param sql the offending SQL statement + * @param ex the root cause + */ + public UncategorizedSQLException(String task, @Nullable String sql, Exception ex) { + super(String.format("%s; uncategorized SQLException%s; %s", task, sql != null ? " for SQL [" + sql + "]" : "", + ex.getMessage()), ex); + this.sql = sql; + } + + /** + * Return the SQL that led to the problem (if known). + */ + @Nullable + public String getSql() { + return this.sql; + } + } +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/DefaultDatabaseClientBuilder.java b/src/main/java/org/springframework/data/jdbc/core/function/DefaultDatabaseClientBuilder.java new file mode 100644 index 0000000..0830859 --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/DefaultDatabaseClientBuilder.java @@ -0,0 +1,96 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import java.util.function.Consumer; + +import org.springframework.data.jdbc.core.function.DatabaseClient.Builder; +import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator; +import org.springframework.jdbc.support.SQLExceptionTranslator; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import io.r2dbc.spi.ConnectionFactory; + +/** + * Default implementation of {@link DatabaseClient.Builder}. + * + * @author Mark Paluch + */ +class DefaultDatabaseClientBuilder implements DatabaseClient.Builder { + + private @Nullable ConnectionFactory connector; + private SQLExceptionTranslator exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(); + private ReactiveDataAccessStrategy accessStrategy = new DefaultReactiveDataAccessStrategy(); + + DefaultDatabaseClientBuilder() {} + + DefaultDatabaseClientBuilder(DefaultDatabaseClientBuilder other) { + + Assert.notNull(other, "DefaultDatabaseClientBuilder must not be null!"); + + this.connector = other.connector; + this.exceptionTranslator = exceptionTranslator; + } + + @Override + public Builder connectionFactory(ConnectionFactory factory) { + + Assert.notNull(factory, "ConnectionFactory must not be null!"); + + this.connector = factory; + return this; + } + + @Override + public Builder exceptionTranslator(SQLExceptionTranslator exceptionTranslator) { + + Assert.notNull(exceptionTranslator, "SQLExceptionTranslator must not be null!"); + + this.exceptionTranslator = exceptionTranslator; + return this; + } + + @Override + public Builder dataAccessStrategy(ReactiveDataAccessStrategy accessStrategy) { + + Assert.notNull(accessStrategy, "ReactiveDataAccessStrategy must not be null!"); + + this.accessStrategy = accessStrategy; + return this; + } + + @Override + public DatabaseClient build() { + + return new DefaultDatabaseClient(this.connector, exceptionTranslator, accessStrategy, + new DefaultDatabaseClientBuilder(this)); + } + + @Override + public DatabaseClient.Builder clone() { + return new DefaultDatabaseClientBuilder(this); + } + + @Override + public DatabaseClient.Builder apply(Consumer builderConsumer) { + Assert.notNull(builderConsumer, "BuilderConsumer must not be null"); + + builderConsumer.accept(this); + return this; + } +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/DefaultReactiveDataAccessStrategy.java b/src/main/java/org/springframework/data/jdbc/core/function/DefaultReactiveDataAccessStrategy.java new file mode 100644 index 0000000..38d101e --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/DefaultReactiveDataAccessStrategy.java @@ -0,0 +1,75 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; + +import org.springframework.data.convert.EntityInstantiators; +import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.jdbc.core.mapping.JdbcPersistentEntity; +import org.springframework.data.jdbc.core.mapping.JdbcPersistentProperty; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.util.Pair; +import org.springframework.util.ClassUtils; + +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; + +/** + * @author Mark Paluch + */ +public class DefaultReactiveDataAccessStrategy implements ReactiveDataAccessStrategy { + + private final EntityInstantiators instantiators = new EntityInstantiators(); + private final JdbcMappingContext mappingContext = new JdbcMappingContext(); + + @Override + public List> getInsert(Object object) { + + Class userClass = ClassUtils.getUserClass(object); + + JdbcPersistentEntity entity = mappingContext.getRequiredPersistentEntity(userClass); + PersistentPropertyAccessor propertyAccessor = entity.getPropertyAccessor(object); + + List> values = new ArrayList<>(); + + for (JdbcPersistentProperty property : entity) { + + Object value = propertyAccessor.getProperty(property); + + if (value == null) { + continue; + } + + values.add(Pair.of(property.getColumnName(), value)); + } + + return values; + } + + @Override + public BiFunction getRowMapper(Class typeToRead) { + return new EntityRowMapper((JdbcPersistentEntity) mappingContext.getRequiredPersistentEntity(typeToRead), + instantiators, mappingContext); + } + + @Override + public String getTableName(Class type) { + return mappingContext.getRequiredPersistentEntity(type).getTableName(); + } +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/EntityRowMapper.java b/src/main/java/org/springframework/data/jdbc/core/function/EntityRowMapper.java new file mode 100644 index 0000000..c78d28c --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/EntityRowMapper.java @@ -0,0 +1,168 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import java.sql.ResultSet; +import java.util.function.BiFunction; + +import org.springframework.core.convert.ConversionService; +import org.springframework.data.convert.EntityInstantiators; +import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.jdbc.core.mapping.JdbcPersistentEntity; +import org.springframework.data.jdbc.core.mapping.JdbcPersistentProperty; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.PreferredConstructor.Parameter; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.mapping.model.ParameterValueProvider; +import org.springframework.util.ClassUtils; + +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; + +/** + * Maps a {@link io.r2dbc.spi.Row} to an entity of type {@code T}, including entities referenced. + * + * @author Mark Paluch + * @since 1.0 + */ +public class EntityRowMapper implements BiFunction { + + private final JdbcPersistentEntity entity; + private final EntityInstantiators entityInstantiators; + private final ConversionService conversions; + private final MappingContext, JdbcPersistentProperty> context; + + public EntityRowMapper(JdbcPersistentEntity entity, EntityInstantiators entityInstantiators, + JdbcMappingContext context) { + + this.entity = entity; + this.entityInstantiators = entityInstantiators; + this.conversions = context.getConversions(); + this.context = context; + } + + @Override + public T apply(Row row, RowMetadata metadata) { + + T result = createInstance(row, "", entity); + + ConvertingPropertyAccessor propertyAccessor = new ConvertingPropertyAccessor(entity.getPropertyAccessor(result), + conversions); + + for (JdbcPersistentProperty property : entity) { + + if (property.isCollectionLike()) { + throw new UnsupportedOperationException(); + } else if (property.isMap()) { + + throw new UnsupportedOperationException(); + } else { + propertyAccessor.setProperty(property, readFrom(row, property, "")); + } + } + + return result; + } + + /** + * Read a single value or a complete Entity from the {@link ResultSet} passed as an argument. + * + * @param row the {@link Row} to extract the value from. Must not be {@code null}. + * @param property the {@link JdbcPersistentProperty} for which the value is intended. Must not be {@code null}. + * @param prefix to be used for all column names accessed by this method. Must not be {@code null}. + * @return the value read from the {@link ResultSet}. May be {@code null}. + */ + private Object readFrom(Row row, JdbcPersistentProperty property, String prefix) { + + try { + + if (property.isEntity()) { + return readEntityFrom(row, property); + } + + return row.get(prefix + property.getColumnName(), getType(property)); + + } catch (Exception o_O) { + throw new MappingException(String.format("Could not read property %s from result set!", property), o_O); + } + } + + private Class getType(JdbcPersistentProperty property) { + return ClassUtils.resolvePrimitiveIfNecessary(property.getActualType()); + } + + private S readEntityFrom(Row row, PersistentProperty property) { + + String prefix = property.getName() + "_"; + + @SuppressWarnings("unchecked") + JdbcPersistentEntity entity = (JdbcPersistentEntity) context + .getRequiredPersistentEntity(property.getActualType()); + + if (readFrom(row, entity.getRequiredIdProperty(), prefix) == null) { + return null; + } + + S instance = createInstance(row, prefix, entity); + + PersistentPropertyAccessor accessor = entity.getPropertyAccessor(instance); + ConvertingPropertyAccessor propertyAccessor = new ConvertingPropertyAccessor(accessor, conversions); + + for (JdbcPersistentProperty p : entity) { + propertyAccessor.setProperty(p, readFrom(row, p, prefix)); + } + + return instance; + } + + private S createInstance(Row row, String prefix, JdbcPersistentEntity entity) { + + return entityInstantiators.getInstantiatorFor(entity).createInstance(entity, + new RowParameterValueProvider(row, entity, conversions, prefix)); + } + + @RequiredArgsConstructor + private static class RowParameterValueProvider implements ParameterValueProvider { + + @NonNull private final Row resultSet; + @NonNull private final JdbcPersistentEntity entity; + @NonNull private final ConversionService conversionService; + @NonNull private final String prefix; + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.model.ParameterValueProvider#getParameterValue(org.springframework.data.mapping.PreferredConstructor.Parameter) + */ + @Override + public T getParameterValue(Parameter parameter) { + + String column = prefix + entity.getRequiredPersistentProperty(parameter.getName()).getColumnName(); + + try { + return conversionService.convert(resultSet.get(column, parameter.getType().getType()), + parameter.getType().getType()); + } catch (Exception o_O) { + throw new MappingException(String.format("Couldn't read column %s from Row.", column), o_O); + } + } + } +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/IterableUtils.java b/src/main/java/org/springframework/data/jdbc/core/function/IterableUtils.java new file mode 100644 index 0000000..8b6e512 --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/IterableUtils.java @@ -0,0 +1,62 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * @author Mark Paluch + */ +class IterableUtils { + + static Collection toCollection(Iterable iterable) { + + Assert.notNull(iterable, "Iterable must not be null!"); + + if (iterable instanceof Collection) { + return (Collection) iterable; + } + + List result = new ArrayList<>(); + + for (T element : iterable) { + result.add(element); + } + + return result; + } + + static List toList(Iterable iterable) { + + Assert.notNull(iterable, "Iterable must not be null!"); + + if (iterable instanceof List) { + return (List) iterable; + } + + List result = new ArrayList<>(); + + for (T element : iterable) { + result.add(element); + } + + return result; + } +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/ReactiveDataAccessStrategy.java b/src/main/java/org/springframework/data/jdbc/core/function/ReactiveDataAccessStrategy.java new file mode 100644 index 0000000..26c9f65 --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/ReactiveDataAccessStrategy.java @@ -0,0 +1,37 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import java.util.List; +import java.util.function.BiFunction; + +import org.springframework.data.util.Pair; + +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; + +/** + * @author Mark Paluch + */ +public interface ReactiveDataAccessStrategy { + + List> getInsert(Object object); + + // TODO: Broaden T to Mono/Flux for reactive relational data access? + BiFunction getRowMapper(Class typeToRead); + + String getTableName(Class type); +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/SqlResult.java b/src/main/java/org/springframework/data/jdbc/core/function/SqlResult.java new file mode 100644 index 0000000..70e746f --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/SqlResult.java @@ -0,0 +1,31 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import java.util.function.BiFunction; + +import org.springframework.data.jdbc.core.function.DatabaseClient.FetchSpec; + +import io.r2dbc.spi.Row; +import io.r2dbc.spi.RowMetadata; + +/** + * @author Mark Paluch + */ +public interface SqlResult extends FetchSpec { + + SqlResult extract(BiFunction mappingFunction); +} diff --git a/src/main/java/org/springframework/data/jdbc/core/function/connectionfactory/ConnectionProxy.java b/src/main/java/org/springframework/data/jdbc/core/function/connectionfactory/ConnectionProxy.java new file mode 100644 index 0000000..a473df7 --- /dev/null +++ b/src/main/java/org/springframework/data/jdbc/core/function/connectionfactory/ConnectionProxy.java @@ -0,0 +1,41 @@ +/* + * Copyright 2018 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.jdbc.core.function.connectionfactory; + +import io.r2dbc.spi.Connection; + +import java.sql.Wrapper; + +/** + * Subinterface of {@link Connection} to be implemented by Connection proxies. Allows access to the underlying target + * Connection. + *

+ * This interface can be checked when there is a need to cast to a native R2DBC {@link Connection}. Alternatively, all + * such connections also support JDBC 4.0's {@link Connection#unwrap}. + * + * @author Mark Paluch + */ +public interface ConnectionProxy extends Connection, Wrapper { + + /** + * Return the target Connection of this proxy. + *

+ * This will typically be the native driver Connection or a wrapper from a connection pool. + * + * @return the underlying Connection (never {@code null}) + */ + Connection getTargetConnection(); +} diff --git a/src/test/java/org/springframework/data/jdbc/core/function/DatabaseClientIntegrationTests.java b/src/test/java/org/springframework/data/jdbc/core/function/DatabaseClientIntegrationTests.java new file mode 100644 index 0000000..53b4bdc --- /dev/null +++ b/src/test/java/org/springframework/data/jdbc/core/function/DatabaseClientIntegrationTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import static org.assertj.core.api.Assertions.*; + +import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; +import io.r2dbc.postgresql.PostgresqlConnectionFactory; +import io.r2dbc.spi.ConnectionFactory; +import lombok.Data; +import reactor.core.publisher.Hooks; +import reactor.test.StepVerifier; + +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; +import org.postgresql.ds.PGSimpleDataSource; +import org.springframework.data.jdbc.core.function.ExternalDatabase.ProvidedDatabase; +import org.springframework.data.jdbc.core.mapping.Table; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * Integration tests for {@link DatabaseClient} against PostgreSQL. + * + * @author Mark Paluch + */ +public class DatabaseClientIntegrationTests { + + /** + * Local test database at {@code postgres:@localhost:5432/postgres}. + */ + @ClassRule public static final ExternalDatabase database = ProvidedDatabase.builder().hostname("localhost").port(5432) + .database("postgres").username("postgres").password("").build(); + + private ConnectionFactory connectionFactory; + + private JdbcTemplate jdbc; + + @Before + public void before() { + + Hooks.onOperatorDebug(); + + connectionFactory = new PostgresqlConnectionFactory( + PostgresqlConnectionConfiguration.builder().host(database.getHostname()).database(database.getDatabase()) + .username(database.getUsername()).password(database.getPassword()).build()); + + PGSimpleDataSource dataSource = new PGSimpleDataSource(); + dataSource.setUser(database.getUsername()); + dataSource.setPassword(database.getPassword()); + dataSource.setDatabaseName(database.getDatabase()); + dataSource.setServerName(database.getHostname()); + dataSource.setPortNumber(database.getPort()); + + String tableToCreate = "CREATE TABLE IF NOT EXISTS legoset (\n" + + " id integer CONSTRAINT id PRIMARY KEY,\n" + " name varchar(255) NOT NULL,\n" + + " manual integer NULL\n" + ");"; + + jdbc = new JdbcTemplate(dataSource); + jdbc.execute(tableToCreate); + jdbc.execute("DELETE FROM legoset"); + } + + @Test + public void executeInsert() { + + DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); + + databaseClient.execute().sql("INSERT INTO legoset (id, name, manual) VALUES($1, $2, $3)") // + .bind(0, 42055) // + .bind(1, "SCHAUFELRADBAGGER") // + .bindNull("$3") // + .fetch().rowsUpdated() // + .as(StepVerifier::create) // + .expectNext(1) // + .verifyComplete(); + + assertThat(jdbc.queryForMap("SELECT id, name, manual FROM legoset")).containsEntry("id", 42055); + } + + @Test + public void executeSelect() { + + jdbc.execute("INSERT INTO legoset (id, name, manual) VALUES(42055, 'SCHAUFELRADBAGGER', 12)"); + + DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); + + // TODO: Driver/Decode does not support decoding null values? + databaseClient.execute().sql("SELECT id, name, manual FROM legoset") // + .as(LegoSet.class) // + .fetch().all() // + .as(StepVerifier::create) // + .consumeNextWith(actual -> { + + assertThat(actual.getId()).isEqualTo(42055); + assertThat(actual.getName()).isEqualTo("SCHAUFELRADBAGGER"); + assertThat(actual.getManual()).isEqualTo(12); + }).verifyComplete(); + } + + @Test + public void insert() { + + DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); + + databaseClient.insert().into("legoset")// + .value("id", 42055) // + .value("name", "SCHAUFELRADBAGGER") // + .nullValue("manual") // + .exchange() // + .flatMapMany(it -> it.extract((r, m) -> r.get("id", Integer.class)).all()).as(StepVerifier::create) // + .expectNext(42055).verifyComplete(); + + assertThat(jdbc.queryForMap("SELECT id, name, manual FROM legoset")).containsEntry("id", 42055); + } + + @Test + public void insertTypedObject() { + + LegoSet legoSet = new LegoSet(); + legoSet.setId(42055); + legoSet.setName("SCHAUFELRADBAGGER"); + legoSet.setManual(12); + + DatabaseClient databaseClient = DatabaseClient.create(connectionFactory); + + databaseClient.insert().into(LegoSet.class)// + .using(legoSet).exchange() // + .flatMapMany(it -> it.extract((r, m) -> r.get("id", Integer.class)).all()).as(StepVerifier::create) // + .expectNext(42055).verifyComplete(); + + assertThat(jdbc.queryForMap("SELECT id, name, manual FROM legoset")).containsEntry("id", 42055); + } + + @Data + @Table("legoset") + static class LegoSet { + int id; + String name; + Integer manual; + } +} diff --git a/src/test/java/org/springframework/data/jdbc/core/function/ExternalDatabase.java b/src/test/java/org/springframework/data/jdbc/core/function/ExternalDatabase.java new file mode 100644 index 0000000..c2e8493 --- /dev/null +++ b/src/test/java/org/springframework/data/jdbc/core/function/ExternalDatabase.java @@ -0,0 +1,126 @@ +/* + * Copyright 2018 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.jdbc.core.function; + +import lombok.Builder; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.TimeUnit; + +import org.junit.AssumptionViolatedException; +import org.junit.rules.ExternalResource; + +/** + * {@link ExternalResource} wrapper to encapsulate {@link ProvidedDatabase} and + * {@link org.testcontainers.containers.PostgreSQLContainer}. + * + * @author Mark Paluch + */ +public abstract class ExternalDatabase extends ExternalResource { + + /** + * @return the post of the database service. + */ + public abstract int getPort(); + + /** + * @return hostname on which the database service runs. + */ + public abstract String getHostname(); + + /** + * @return name of the database. + */ + public abstract String getDatabase(); + + /** + * @return database user name. + */ + public abstract String getUsername(); + + @Override + protected void before() { + + try (Socket socket = new Socket()) { + ; + socket.connect(new InetSocketAddress(getHostname(), getPort()), Math.toIntExact(TimeUnit.SECONDS.toMillis(5))); + + } catch (IOException e) { + throw new AssumptionViolatedException( + String.format("Cannot connect to %s:%d. Skiping tests.", getHostname(), getPort())); + } + } + + /** + * @return password for the database user. + */ + public abstract String getPassword(); + + /** + * Provided (unmanaged resource) database connection coordinates. + */ + @Builder + static class ProvidedDatabase extends ExternalDatabase { + + private final int port; + private final String hostname; + private final String database; + private final String username; + private final String password; + + /* (non-Javadoc) + * @see org.springframework.data.jdbc.core.function.ExternalDatabase#getPort() + */ + @Override + public int getPort() { + return port; + } + + /* (non-Javadoc) + * @see org.springframework.data.jdbc.core.function.ExternalDatabase#getHostname() + */ + @Override + public String getHostname() { + return hostname; + } + + /* (non-Javadoc) + * @see org.springframework.data.jdbc.core.function.ExternalDatabase#getDatabase() + */ + @Override + public String getDatabase() { + return database; + } + + /* (non-Javadoc) + * @see org.springframework.data.jdbc.core.function.ExternalDatabase#getUsername() + */ + @Override + public String getUsername() { + return username; + } + + /* (non-Javadoc) + * @see org.springframework.data.jdbc.core.function.ExternalDatabase#getPassword() + */ + @Override + public String getPassword() { + return password; + } + } +}