diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/R2dbcTransactionManager.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/R2dbcTransactionManager.java index e1b92df95..d32e1d2ac 100644 --- a/src/main/java/org/springframework/data/r2dbc/connectionfactory/R2dbcTransactionManager.java +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/R2dbcTransactionManager.java @@ -421,7 +421,7 @@ public class R2dbcTransactionManager extends AbstractReactiveTransactionManager * Prepare the transactional {@link Connection} right after transaction begin. *

* The default implementation executes a "SET TRANSACTION READ ONLY" statement if the {@link #setEnforceReadOnly - * "enforceReadOnly"} flag is set to {@code true} and the transaction definition indicates a read-only transaction. + * "enforceReadOnly"} flag is set to {@literal true} and the transaction definition indicates a read-only transaction. *

* The "SET TRANSACTION READ ONLY" is understood by Oracle, MySQL and Postgres and may work with other databases as * well. If you'd like to adapt this treatment, override this method accordingly. diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/CannotReadScriptException.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/CannotReadScriptException.java new file mode 100644 index 000000000..5192579c0 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/CannotReadScriptException.java @@ -0,0 +1,38 @@ +/* + * 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.connectionfactory.init; + +import org.springframework.core.io.support.EncodedResource; + +/** + * Thrown by {@link ScriptUtils} if an SQL script cannot be read. + * + * @author Mark Paluch + */ +public class CannotReadScriptException extends ScriptException { + + private static final long serialVersionUID = 7253084944991764250L; + + /** + * Creates a new {@link CannotReadScriptException}. + * + * @param resource the resource that cannot be read from. + * @param cause the underlying cause of the resource access failure. + */ + public CannotReadScriptException(EncodedResource resource, Throwable cause) { + super("Cannot read SQL script from " + resource, cause); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/CompositeDatabasePopulator.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/CompositeDatabasePopulator.java new file mode 100644 index 000000000..ebbb34a91 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/CompositeDatabasePopulator.java @@ -0,0 +1,99 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Composite {@link DatabasePopulator} that delegates to a list of given {@link DatabasePopulator} implementations, + * executing all scripts. + * + * @author Mark Paluch + */ +public class CompositeDatabasePopulator implements DatabasePopulator { + + private final List populators = new ArrayList<>(4); + + /** + * Creates an empty {@link CompositeDatabasePopulator}. + * + * @see #setPopulators + * @see #addPopulators + */ + public CompositeDatabasePopulator() {} + + /** + * Creates a {@link CompositeDatabasePopulator}. with the given populators. + * + * @param populators one or more populators to delegate to. + */ + public CompositeDatabasePopulator(Collection populators) { + + Assert.notNull(populators, "Collection of DatabasePopulator must not be null!"); + + this.populators.addAll(populators); + } + + /** + * Creates a {@link CompositeDatabasePopulator} with the given populators. + * + * @param populators one or more populators to delegate to. + */ + public CompositeDatabasePopulator(DatabasePopulator... populators) { + + Assert.notNull(populators, "DatabasePopulators must not be null!"); + + this.populators.addAll(Arrays.asList(populators)); + } + + /** + * Specify one or more populators to delegate to. + */ + public void setPopulators(DatabasePopulator... populators) { + + Assert.notNull(populators, "DatabasePopulators must not be null!"); + + this.populators.clear(); + this.populators.addAll(Arrays.asList(populators)); + } + + /** + * Add one or more populators to the list of delegates. + */ + public void addPopulators(DatabasePopulator... populators) { + + Assert.notNull(populators, "DatabasePopulators must not be null!"); + + this.populators.addAll(Arrays.asList(populators)); + } + + @Override + public Mono populate(Connection connection) throws ScriptException { + + Assert.notNull(connection, "Connection must not be null!"); + + return Flux.fromIterable(this.populators).concatMap(it -> it.populate(connection)).then(); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ConnectionFactoryInitializer.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ConnectionFactoryInitializer.java new file mode 100644 index 000000000..49ce781ad --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ConnectionFactoryInitializer.java @@ -0,0 +1,110 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.ConnectionFactory; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Used to {@link #setDatabasePopulator set up} a database during initialization and {@link #setDatabaseCleaner clean + * up} a database during destruction. + * + * @author Mark Paluch + * @see DatabasePopulator + */ +public class ConnectionFactoryInitializer implements InitializingBean, DisposableBean { + + private @Nullable ConnectionFactory connectionFactory; + + private @Nullable DatabasePopulator databasePopulator; + + private @Nullable DatabasePopulator databaseCleaner; + + private boolean enabled = true; + + /** + * The {@link ConnectionFactory} for the database to populate when this component is initialized and to clean up when + * this component is shut down. + *

+ * This property is mandatory with no default provided. + * + * @param connectionFactory the R2DBC {@link ConnectionFactory}. + */ + public void setConnectionFactory(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + + /** + * Set the {@link DatabasePopulator} to execute during the bean initialization phase. + * + * @param databasePopulator the {@link DatabasePopulator} to use during initialization + * @see #setDatabaseCleaner + */ + public void setDatabasePopulator(DatabasePopulator databasePopulator) { + this.databasePopulator = databasePopulator; + } + + /** + * Set the {@link DatabasePopulator} to execute during the bean destruction phase, cleaning up the database and + * leaving it in a known state for others. + * + * @param databaseCleaner the {@link DatabasePopulator} to use during destruction + * @see #setDatabasePopulator + */ + public void setDatabaseCleaner(DatabasePopulator databaseCleaner) { + this.databaseCleaner = databaseCleaner; + } + + /** + * Flag to explicitly enable or disable the {@link #setDatabasePopulator database populator} and + * {@link #setDatabaseCleaner database cleaner}. + * + * @param enabled {@literal true} if the database populator and database cleaner should be called on startup and + * shutdown, respectively + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Use the {@link #setDatabasePopulator database populator} to set up the database. + */ + @Override + public void afterPropertiesSet() { + execute(this.databasePopulator); + } + + /** + * Use the {@link #setDatabaseCleaner database cleaner} to clean up the database. + */ + @Override + public void destroy() { + execute(this.databaseCleaner); + } + + private void execute(@Nullable DatabasePopulator populator) { + + Assert.state(this.connectionFactory != null, "ConnectionFactory must be set"); + + if (this.enabled && populator != null) { + DatabasePopulatorUtils.execute(populator, this.connectionFactory); + } + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/DatabasePopulator.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/DatabasePopulator.java new file mode 100644 index 000000000..b40054ebd --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/DatabasePopulator.java @@ -0,0 +1,42 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; + +/** + * Strategy used to populate, initialize, or clean up a database. + * + * @author Mark Paluch + * @see ResourceDatabasePopulator + * @see DatabasePopulatorUtils + * @see ConnectionFactoryInitializer + */ +@FunctionalInterface +public interface DatabasePopulator { + + /** + * Populate, initialize, or clean up the database using the provided R2DBC {@link Connection}. + * + * @param connection the R2DBC connection to use to populate the db; already configured and ready to use, must not be + * {@literal null}. + * @return {@link Mono} that initiates script execution and is notified upon completion. + * @throws ScriptException in all other error cases + * @see DatabasePopulatorUtils#execute + */ + Mono populate(Connection connection) throws ScriptException; +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/DatabasePopulatorUtils.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/DatabasePopulatorUtils.java new file mode 100644 index 000000000..858b1a75e --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/DatabasePopulatorUtils.java @@ -0,0 +1,58 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; + +import org.springframework.dao.DataAccessException; +import org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils; +import org.springframework.util.Assert; + +/** + * Utility methods for executing a {@link DatabasePopulator}. + * + * @author Mark Paluch + */ +public abstract class DatabasePopulatorUtils { + + // utility constructor + private DatabasePopulatorUtils() {} + + /** + * Execute the given {@link DatabasePopulator} against the given {@link io.r2dbc.spi.ConnectionFactory}. + * + * @param populator the {@link DatabasePopulator} to execute. + * @param connectionFactory the {@link ConnectionFactory} to execute against. + * @return {@link Mono} that initiates {@link DatabasePopulator#populate(Connection)} and is notified upon completion. + */ + public static Mono execute(DatabasePopulator populator, ConnectionFactory connectionFactory) + throws DataAccessException { + + Assert.notNull(populator, "DatabasePopulator must not be null"); + Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); + + return Mono.usingWhen(ConnectionFactoryUtils.getConnection(connectionFactory).map(Tuple2::getT1), // + populator::populate, // + it -> ConnectionFactoryUtils.releaseConnection(it, connectionFactory), // + it -> ConnectionFactoryUtils.releaseConnection(it, connectionFactory)) + .onErrorMap(ex -> !(ex instanceof ScriptException), ex -> { + return new UncategorizedScriptException("Failed to execute database script", ex); + }); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ResourceDatabasePopulator.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ResourceDatabasePopulator.java new file mode 100644 index 000000000..b97eb1c29 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ResourceDatabasePopulator.java @@ -0,0 +1,280 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Populates, initializes, or cleans up a database using SQL scripts defined in external resources. + *

+ * + * @author Mark Paluch + * @see DatabasePopulatorUtils + * @see ScriptUtils + */ +public class ResourceDatabasePopulator implements DatabasePopulator { + + List scripts = new ArrayList<>(); + + private @Nullable Charset sqlScriptEncoding; + + private String separator = ScriptUtils.DEFAULT_STATEMENT_SEPARATOR; + + private String commentPrefix = ScriptUtils.DEFAULT_COMMENT_PREFIX; + + private String blockCommentStartDelimiter = ScriptUtils.DEFAULT_BLOCK_COMMENT_START_DELIMITER; + + private String blockCommentEndDelimiter = ScriptUtils.DEFAULT_BLOCK_COMMENT_END_DELIMITER; + + private boolean continueOnError = false; + + private boolean ignoreFailedDrops = false; + + private DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory(); + + /** + * Creates a new {@link ResourceDatabasePopulator} with default settings. + */ + public ResourceDatabasePopulator() {} + + /** + * Creates a new {@link ResourceDatabasePopulator} with default settings for the supplied scripts. + * + * @param scripts the scripts to execute to initialize or clean up the database (never {@literal null}) + */ + public ResourceDatabasePopulator(Resource... scripts) { + setScripts(scripts); + } + + /** + * Creates a new {@link ResourceDatabasePopulator} with the supplied values. + * + * @param continueOnError flag to indicate that all failures in SQL should be logged but not cause a failure + * @param ignoreFailedDrops flag to indicate that a failed SQL {@code DROP} statement can be ignored + * @param sqlScriptEncoding the encoding for the supplied SQL scripts (may be {@literal null} or empty to + * indicate platform encoding) + * @param scripts the scripts to execute to initialize or clean up the database, must not be {@literal null}. + */ + public ResourceDatabasePopulator(boolean continueOnError, boolean ignoreFailedDrops, + @Nullable String sqlScriptEncoding, Resource... scripts) { + + this.continueOnError = continueOnError; + this.ignoreFailedDrops = ignoreFailedDrops; + setSqlScriptEncoding(sqlScriptEncoding); + setScripts(scripts); + } + + /** + * Add a script to execute to initialize or clean up the database. + * + * @param script the path to an SQL script, must not be {@literal null}. + */ + public void addScript(Resource script) { + Assert.notNull(script, "Script must not be null"); + this.scripts.add(script); + } + + /** + * Add multiple scripts to execute to initialize or clean up the database. + * + * @param scripts the scripts to execute, must not be {@literal null}. + */ + public void addScripts(Resource... scripts) { + assertContentsOfScriptArray(scripts); + this.scripts.addAll(Arrays.asList(scripts)); + } + + /** + * Set the scripts to execute to initialize or clean up the database, replacing any previously added scripts. + * + * @param scripts the scripts to execute, must not be {@literal null}. + */ + public void setScripts(Resource... scripts) { + assertContentsOfScriptArray(scripts); + // Ensure that the list is modifiable + this.scripts = new ArrayList<>(Arrays.asList(scripts)); + } + + private void assertContentsOfScriptArray(Resource... scripts) { + Assert.notNull(scripts, "Scripts array must not be null"); + Assert.noNullElements(scripts, "Scripts array must not contain null elements"); + } + + /** + * Specify the encoding for the configured SQL scripts, if different from the platform encoding. + * + * @param sqlScriptEncoding the encoding used in scripts (may be {@literal null} or empty to indicate platform + * encoding). + * @see #addScript(Resource) + */ + public void setSqlScriptEncoding(@Nullable String sqlScriptEncoding) { + setSqlScriptEncoding(StringUtils.hasText(sqlScriptEncoding) ? Charset.forName(sqlScriptEncoding) : null); + } + + /** + * Specify the encoding for the configured SQL scripts, if different from the platform encoding. + * + * @param sqlScriptEncoding the encoding used in scripts (may be {@literal null} to indicate platform encoding). + * @see #addScript(Resource) + */ + public void setSqlScriptEncoding(@Nullable Charset sqlScriptEncoding) { + this.sqlScriptEncoding = sqlScriptEncoding; + } + + /** + * Specify the statement separator, if a custom one. + *

+ * Defaults to {@code ";"} if not specified and falls back to {@code "\n"} as a last resort; may be set to + * {@link ScriptUtils#EOF_STATEMENT_SEPARATOR} to signal that each script contains a single statement without a + * separator. + * + * @param separator the script statement separator. + */ + public void setSeparator(String separator) { + this.separator = separator; + } + + /** + * Set the prefix that identifies single-line comments within the SQL scripts. + *

+ * Defaults to {@code "--"}. + * + * @param commentPrefix the prefix for single-line comments + */ + public void setCommentPrefix(String commentPrefix) { + this.commentPrefix = commentPrefix; + } + + /** + * Set the start delimiter that identifies block comments within the SQL scripts. + *

+ * Defaults to {@code "/*"}. + * + * @param blockCommentStartDelimiter the start delimiter for block comments (never {@literal null} or empty). + * @see #setBlockCommentEndDelimiter + */ + public void setBlockCommentStartDelimiter(String blockCommentStartDelimiter) { + + Assert.hasText(blockCommentStartDelimiter, "BlockCommentStartDelimiter must not be null or empty"); + + this.blockCommentStartDelimiter = blockCommentStartDelimiter; + } + + /** + * Set the end delimiter that identifies block comments within the SQL scripts. + *

+ * Defaults to {@code "*/"}. + * + * @param blockCommentEndDelimiter the end delimiter for block comments (never {@literal null} or empty) + * @see #setBlockCommentStartDelimiter + */ + public void setBlockCommentEndDelimiter(String blockCommentEndDelimiter) { + + Assert.hasText(blockCommentEndDelimiter, "BlockCommentEndDelimiter must not be null or empty"); + + this.blockCommentEndDelimiter = blockCommentEndDelimiter; + } + + /** + * Flag to indicate that all failures in SQL should be logged but not cause a failure. + *

+ * Defaults to {@literal false}. + * + * @param continueOnError {@literal true} if script execution should continue on error. + */ + public void setContinueOnError(boolean continueOnError) { + this.continueOnError = continueOnError; + } + + /** + * Flag to indicate that a failed SQL {@code DROP} statement can be ignored. + *

+ * This is useful for a non-embedded database whose SQL dialect does not support an {@code IF EXISTS} clause in a + * {@code DROP} statement. + *

+ * The default is {@literal false} so that if the populator runs accidentally, it will fail fast if a script starts + * with a {@code DROP} statement. + * + * @param ignoreFailedDrops {@literal true} if failed drop statements should be ignored. + */ + public void setIgnoreFailedDrops(boolean ignoreFailedDrops) { + this.ignoreFailedDrops = ignoreFailedDrops; + } + + /** + * Set the {@link DataBufferFactory} to use for {@link Resource} loading. + *

+ * Defaults to {@link DefaultDataBufferFactory}. + * + * @param dataBufferFactory the {@link DataBufferFactory} to use, must not be {@literal null}. + */ + public void setDataBufferFactory(DataBufferFactory dataBufferFactory) { + + Assert.notNull(dataBufferFactory, "DataBufferFactory must not be null!"); + + this.dataBufferFactory = dataBufferFactory; + } + + @Override + public Mono populate(Connection connection) throws ScriptException { + + Assert.notNull(connection, "Connection must not be null"); + + return Flux.fromIterable(this.scripts).concatMap(it -> { + + EncodedResource encodedScript = new EncodedResource(it, this.sqlScriptEncoding); + + return ScriptUtils.executeSqlScript(connection, encodedScript, this.dataBufferFactory, this.continueOnError, + this.ignoreFailedDrops, this.commentPrefix, this.separator, this.blockCommentStartDelimiter, + this.blockCommentEndDelimiter); + }).then(); + } + + /** + * Execute this {@link ResourceDatabasePopulator} against the given {@link ConnectionFactory}. + *

+ * Delegates to {@link DatabasePopulatorUtils#execute}. + * + * @param connectionFactory the {@link ConnectionFactory} to execute against, must not be {@literal null}.. + * @return {@link Mono} tthat initiates script execution and is notified upon completion. + * @throws ScriptException if an error occurs. + * @see #populate(Connection) + */ + public Mono execute(ConnectionFactory connectionFactory) throws ScriptException { + return DatabasePopulatorUtils.execute(this, connectionFactory); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptException.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptException.java new file mode 100644 index 000000000..672740bf0 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptException.java @@ -0,0 +1,46 @@ +/* + * 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.connectionfactory.init; + +import org.springframework.dao.DataAccessException; +import org.springframework.lang.Nullable; + +/** + * Root of the hierarchy of data access exceptions that are related to processing of SQL scripts. + * + * @author Mark Paluch + */ +public abstract class ScriptException extends DataAccessException { + + /** + * Creates a new {@link ScriptException}. + * + * @param message the detail message. + */ + public ScriptException(String message) { + super(message); + } + + /** + * Creates a new {@link ScriptException}. + * + * @param message the detail message. + * @param cause the root cause. + */ + public ScriptException(String message, @Nullable Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptParseException.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptParseException.java new file mode 100644 index 000000000..24128ae7a --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptParseException.java @@ -0,0 +1,55 @@ +/* + * 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.connectionfactory.init; + +import org.springframework.core.io.support.EncodedResource; +import org.springframework.lang.Nullable; + +/** + * Thrown by {@link ScriptUtils} if an SQL script cannot be properly parsed. + * + * @author Mark Paluch + */ +public class ScriptParseException extends ScriptException { + + private static final long serialVersionUID = 6130513243627087332L; + + /** + * Creates a new {@link ScriptParseException}. + * + * @param message detailed message. + * @param resource the resource from which the SQL script was read. + */ + public ScriptParseException(String message, @Nullable EncodedResource resource) { + super(buildMessage(message, resource)); + } + + /** + * Creates a new {@link ScriptParseException}. + * + * @param message detailed message. + * @param resource the resource from which the SQL script was read. + * @param cause the underlying cause of the failure. + */ + public ScriptParseException(String message, @Nullable EncodedResource resource, @Nullable Throwable cause) { + super(buildMessage(message, resource), cause); + } + + private static String buildMessage(String message, @Nullable EncodedResource resource) { + return String.format("Failed to parse SQL script from resource [%s]: %s", + (resource == null ? "" : resource), message); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptStatementFailedException.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptStatementFailedException.java new file mode 100644 index 000000000..02dfdd6b2 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptStatementFailedException.java @@ -0,0 +1,54 @@ +/* + * 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.connectionfactory.init; + +import org.springframework.core.io.support.EncodedResource; + +/** + * Thrown by {@link ScriptUtils} if a statement in an SQL script failed when executing it against the target database. + * + * @author Mark Paluch + */ +public class ScriptStatementFailedException extends ScriptException { + + private static final long serialVersionUID = 912676424615782262L; + + /** + * Creates a new {@link ScriptStatementFailedException}. + * + * @param statement the actual SQL statement that failed. + * @param statementNumber the statement number in the SQL script (i.e., the n'th statement present in the resource). + * @param encodedResource the resource from which the SQL statement was read. + * @param cause the underlying cause of the failure. + */ + public ScriptStatementFailedException(String statement, int statementNumber, EncodedResource encodedResource, + Throwable cause) { + super(buildErrorMessage(statement, statementNumber, encodedResource), cause); + } + + /** + * Build an error message for an SQL script execution failure, based on the supplied arguments. + * + * @param statement the actual SQL statement that failed. + * @param statementNumber the statement number in the SQL script (i.e., the n'th statement present in the resource). + * @param encodedResource the resource from which the SQL statement was read. + * @return an error message suitable for an exception's detail message or logging. + */ + public static String buildErrorMessage(String statement, int statementNumber, EncodedResource encodedResource) { + return String.format("Failed to execute SQL script statement #%s of %s: %s", statementNumber, encodedResource, + statement); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptUtils.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptUtils.java new file mode 100644 index 000000000..2b7034e84 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptUtils.java @@ -0,0 +1,537 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.Result; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.LineNumberReader; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; + +import org.springframework.core.io.Resource; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.core.io.support.EncodedResource; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Generic utility methods for working with SQL scripts. + *

+ * Mainly for internal use within the framework. + * + * @author Mark Paluch + */ +public abstract class ScriptUtils { + + /** + * Default statement separator within SQL scripts: {@code ";"}. + */ + public static final String DEFAULT_STATEMENT_SEPARATOR = ";"; + + /** + * Fallback statement separator within SQL scripts: {@code "\n"}. + *

+ * Used if neither a custom separator nor the {@link #DEFAULT_STATEMENT_SEPARATOR} is present in a given script. + */ + public static final String FALLBACK_STATEMENT_SEPARATOR = "\n"; + + /** + * End of file (EOF) SQL statement separator: {@code "^^^ END OF SCRIPT ^^^"}. + *

+ * This value may be supplied as the {@code separator} to + * {@link #executeSqlScript(Connection, EncodedResource, boolean, boolean, String, String, String, String)} to denote + * that an SQL script contains a single statement (potentially spanning multiple lines) with no explicit statement + * separator. Note that such a script should not actually contain this value; it is merely a virtual + * statement separator. + */ + public static final String EOF_STATEMENT_SEPARATOR = "^^^ END OF SCRIPT ^^^"; + + /** + * Default prefix for single-line comments within SQL scripts: {@code "--"}. + */ + public static final String DEFAULT_COMMENT_PREFIX = "--"; + + /** + * Default start delimiter for block comments within SQL scripts: {@code "/*"}. + */ + public static final String DEFAULT_BLOCK_COMMENT_START_DELIMITER = "/*"; + + /** + * Default end delimiter for block comments within SQL scripts: "*/". + */ + public static final String DEFAULT_BLOCK_COMMENT_END_DELIMITER = "*/"; + + private static final Log logger = LogFactory.getLog(ScriptUtils.class); + + // utility constructor + private ScriptUtils() {} + + /** + * Split an SQL script into separate statements delimited by the provided separator character. Each individual + * statement will be added to the provided {@link List}. + *

+ * Within the script, {@value #DEFAULT_COMMENT_PREFIX} will be used as the comment prefix; any text beginning with the + * comment prefix and extending to the end of the line will be omitted from the output. Similarly, + * {@value #DEFAULT_BLOCK_COMMENT_START_DELIMITER} and {@value #DEFAULT_BLOCK_COMMENT_END_DELIMITER} will be used as + * the start and end block comment delimiters: any text enclosed in a block comment will be omitted + * from the output. In addition, multiple adjacent whitespace characters will be collapsed into a single space. + * + * @param script the SQL script. + * @param separator character separating each statement (typically a ';'). + * @param statements the list that will contain the individual statements . + * @throws ScriptException if an error occurred while splitting the SQL script. + * @see #splitSqlScript(String, String, List) + * @see #splitSqlScript(EncodedResource, String, String, String, String, String, List) + */ + static void splitSqlScript(String script, char separator, List statements) throws ScriptException { + splitSqlScript(script, String.valueOf(separator), statements); + } + + /** + * Split an SQL script into separate statements delimited by the provided separator string. Each individual statement + * will be added to the provided {@link List}. + *

+ * Within the script, {@value #DEFAULT_COMMENT_PREFIX} will be used as the comment prefix; any text beginning with the + * comment prefix and extending to the end of the line will be omitted from the output. Similarly, + * {@value #DEFAULT_BLOCK_COMMENT_START_DELIMITER} and {@value #DEFAULT_BLOCK_COMMENT_END_DELIMITER} will be used as + * the start and end block comment delimiters: any text enclosed in a block comment will be omitted + * from the output. In addition, multiple adjacent whitespace characters will be collapsed into a single space. + * + * @param script the SQL script. + * @param separator text separating each statement (typically a ';' or newline character). + * @param statements the list that will contain the individual statements. + * @throws ScriptException if an error occurred while splitting the SQL script. + * @see #splitSqlScript(String, char, List) + * @see #splitSqlScript(EncodedResource, String, String, String, String, String, List) + */ + static void splitSqlScript(String script, String separator, List statements) throws ScriptException { + splitSqlScript(null, script, separator, DEFAULT_COMMENT_PREFIX, DEFAULT_BLOCK_COMMENT_START_DELIMITER, + DEFAULT_BLOCK_COMMENT_END_DELIMITER, statements); + } + + /** + * Split an SQL script into separate statements delimited by the provided separator string. Each individual statement + * will be added to the provided {@link List}. + *

+ * Within the script, the provided {@code commentPrefix} will be honored: any text beginning with the comment prefix + * and extending to the end of the line will be omitted from the output. Similarly, the provided + * {@code blockCommentStartDelimiter} and {@code blockCommentEndDelimiter} delimiters will be honored: any text + * enclosed in a block comment will be omitted from the output. In addition, multiple adjacent whitespace characters + * will be collapsed into a single space. + * + * @param resource the resource from which the script was read. + * @param script the SQL script. + * @param separator text separating each statement (typically a ';' or newline character). + * @param commentPrefix the prefix that identifies SQL line comments (typically "--"). + * @param blockCommentStartDelimiter the start block comment delimiter. Must not be {@literal null} or empty. + * @param blockCommentEndDelimiter the end block comment delimiter. Must not be {@literal null} or empty. + * @param statements the list that will contain the individual statements. + * @throws ScriptException if an error occurred while splitting the SQL script. + */ + private static void splitSqlScript(@Nullable EncodedResource resource, String script, String separator, + String commentPrefix, String blockCommentStartDelimiter, String blockCommentEndDelimiter, List statements) + throws ScriptException { + + Assert.hasText(script, "'script' must not be null or empty"); + Assert.notNull(separator, "'separator' must not be null"); + Assert.hasText(commentPrefix, "'commentPrefix' must not be null or empty"); + Assert.hasText(blockCommentStartDelimiter, "'blockCommentStartDelimiter' must not be null or empty"); + Assert.hasText(blockCommentEndDelimiter, "'blockCommentEndDelimiter' must not be null or empty"); + + StringBuilder sb = new StringBuilder(); + boolean inSingleQuote = false; + boolean inDoubleQuote = false; + boolean inEscape = false; + + for (int i = 0; i < script.length(); i++) { + char c = script.charAt(i); + if (inEscape) { + inEscape = false; + sb.append(c); + continue; + } + // MySQL style escapes + if (c == '\\') { + inEscape = true; + sb.append(c); + continue; + } + if (!inDoubleQuote && (c == '\'')) { + inSingleQuote = !inSingleQuote; + } else if (!inSingleQuote && (c == '"')) { + inDoubleQuote = !inDoubleQuote; + } + if (!inSingleQuote && !inDoubleQuote) { + if (script.startsWith(separator, i)) { + // We've reached the end of the current statement + if (sb.length() > 0) { + statements.add(sb.toString()); + sb = new StringBuilder(); + } + i += separator.length() - 1; + continue; + } else if (script.startsWith(commentPrefix, i)) { + // Skip over any content from the start of the comment to the EOL + int indexOfNextNewline = script.indexOf('\n', i); + if (indexOfNextNewline > i) { + i = indexOfNextNewline; + continue; + } else { + // If there's no EOL, we must be at the end of the script, so stop here. + break; + } + } else if (script.startsWith(blockCommentStartDelimiter, i)) { + // Skip over any block comments + int indexOfCommentEnd = script.indexOf(blockCommentEndDelimiter, i); + if (indexOfCommentEnd > i) { + i = indexOfCommentEnd + blockCommentEndDelimiter.length() - 1; + continue; + } else { + throw new ScriptParseException("Missing block comment end delimiter: " + blockCommentEndDelimiter, + resource); + } + } else if (c == ' ' || c == '\r' || c == '\n' || c == '\t') { + // Avoid multiple adjacent whitespace characters + if (sb.length() > 0 && sb.charAt(sb.length() - 1) != ' ') { + c = ' '; + } else { + continue; + } + } + } + sb.append(c); + } + + if (StringUtils.hasText(sb)) { + statements.add(sb.toString()); + } + } + + /** + * Read a script without blocking from the given resource, using "{@code --}" as the comment prefix and "{@code ;}" as + * the statement separator, and build a String containing the lines. + * + * @param resource the {@link EncodedResource} to be read. + * @param dataBufferFactory the buffer factory for non-blocking script loading. + * @return {@link String} containing the script lines. + * @see DefaultDataBufferFactory + */ + public static Mono readScript(EncodedResource resource, DataBufferFactory dataBufferFactory) { + return readScript(resource, dataBufferFactory, DEFAULT_COMMENT_PREFIX, DEFAULT_STATEMENT_SEPARATOR, + DEFAULT_BLOCK_COMMENT_END_DELIMITER); + } + + /** + * Read a script without blocking from the provided resource, using the supplied comment prefix and statement + * separator, and build a {@link String} and build a String containing the lines. + *

+ * Lines beginning with the comment prefix are excluded from the results; however, line comments anywhere + * else — for example, within a statement — will be included in the results. + * + * @param resource the {@link EncodedResource} containing the script to be processed. + * @param commentPrefix the prefix that identifies comments in the SQL script (typically "--"). + * @param separator the statement separator in the SQL script (typically ";"). + * @param blockCommentEndDelimiter the end block comment delimiter. + * @return a {@link Mono} of {@link String} containing the script lines that completes once the resource was loaded. + */ + private static Mono readScript(EncodedResource resource, DataBufferFactory dataBufferFactory, + @Nullable String commentPrefix, @Nullable String separator, @Nullable String blockCommentEndDelimiter) { + + return DataBufferUtils.join(DataBufferUtils.read(resource.getResource(), dataBufferFactory, 8192)) + .handle((it, sink) -> { + + try (InputStream is = it.asInputStream()) { + + InputStreamReader in = resource.getCharset() != null ? new InputStreamReader(is, resource.getCharset()) + : new InputStreamReader(is); + LineNumberReader lnr = new LineNumberReader(in); + String script = readScript(lnr, commentPrefix, separator, blockCommentEndDelimiter); + + sink.next(script); + sink.complete(); + } catch (Exception e) { + sink.error(e); + } finally { + DataBufferUtils.release(it); + } + }); + } + + /** + * Read a script from the provided {@link LineNumberReader}, using the supplied comment prefix and statement + * separator, and build a {@link String} containing the lines. + *

+ * Lines beginning with the comment prefix are excluded from the results; however, line comments anywhere + * else — for example, within a statement — will be included in the results. + * + * @param lineNumberReader the {@link LineNumberReader} containing the script to be processed. + * @param lineCommentPrefix the prefix that identifies comments in the SQL script (typically "--"). + * @param separator the statement separator in the SQL script (typically ";"). + * @param blockCommentEndDelimiter the end block comment delimiter. + * @return a {@link String} containing the script lines. + * @throws IOException in case of I/O errors + */ + private static String readScript(LineNumberReader lineNumberReader, @Nullable String lineCommentPrefix, + @Nullable String separator, @Nullable String blockCommentEndDelimiter) throws IOException { + + String currentStatement = lineNumberReader.readLine(); + StringBuilder scriptBuilder = new StringBuilder(); + while (currentStatement != null) { + if ((blockCommentEndDelimiter != null && currentStatement.contains(blockCommentEndDelimiter)) + || (lineCommentPrefix != null && !currentStatement.startsWith(lineCommentPrefix))) { + if (scriptBuilder.length() > 0) { + scriptBuilder.append('\n'); + } + scriptBuilder.append(currentStatement); + } + currentStatement = lineNumberReader.readLine(); + } + appendSeparatorToScriptIfNecessary(scriptBuilder, separator); + return scriptBuilder.toString(); + } + + private static void appendSeparatorToScriptIfNecessary(StringBuilder scriptBuilder, @Nullable String separator) { + if (separator == null) { + return; + } + String trimmed = separator.trim(); + if (trimmed.length() == separator.length()) { + return; + } + // separator ends in whitespace, so we might want to see if the script is trying + // to end the same way + if (scriptBuilder.lastIndexOf(trimmed) == scriptBuilder.length() - trimmed.length()) { + scriptBuilder.append(separator.substring(trimmed.length())); + } + } + + /** + * Does the provided SQL script contain the specified delimiter? + * + * @param script the SQL script + * @param delim the string delimiting each statement - typically a ';' character + */ + static boolean containsSqlScriptDelimiters(String script, String delim) { + + boolean inLiteral = false; + boolean inEscape = false; + + for (int i = 0; i < script.length(); i++) { + char c = script.charAt(i); + if (inEscape) { + inEscape = false; + continue; + } + // MySQL style escapes + if (c == '\\') { + inEscape = true; + continue; + } + if (c == '\'') { + inLiteral = !inLiteral; + } + if (!inLiteral && script.startsWith(delim, i)) { + return true; + } + } + + return false; + } + + /** + * Execute the given SQL script using default settings for statement separators, comment delimiters, and exception + * handling flags. + *

+ * Statement separators and comments will be removed before executing individual statements within the supplied + * script. + *

+ * Warning: this method does not release the provided {@link Connection}. + * + * @param connection the R2DBC connection to use to execute the script; already configured and ready to use. + * @param resource the resource to load the SQL script from; encoded with the current platform's default encoding. + * @return {@link Mono} that initiates script execution and is notified upon completion. + * @throws ScriptException if an error occurred while executing the SQL script. + * @see #executeSqlScript(Connection, EncodedResource, DataBufferFactory, boolean, boolean, String, String, String, + * String) + * @see #DEFAULT_STATEMENT_SEPARATOR + * @see #DEFAULT_COMMENT_PREFIX + * @see #DEFAULT_BLOCK_COMMENT_START_DELIMITER + * @see #DEFAULT_BLOCK_COMMENT_END_DELIMITER + * @see org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils#getConnection + * @see org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils#releaseConnection + */ + public static Mono executeSqlScript(Connection connection, Resource resource) throws ScriptException { + return executeSqlScript(connection, new EncodedResource(resource)); + } + + /** + * Execute the given SQL script using default settings for statement separators, comment delimiters, and exception + * handling flags. + *

+ * Statement separators and comments will be removed before executing individual statements within the supplied + * script. + *

+ * Warning: this method does not release the provided {@link Connection}. + * + * @param connection the R2DBC connection to use to execute the script; already configured and ready to use. + * @param resource the resource (potentially associated with a specific encoding) to load the SQL script from. + * @return {@link Mono} that initiates script execution and is notified upon completion. + * @throws ScriptException if an error occurred while executing the SQL script. + * @see #executeSqlScript(Connection, EncodedResource, DataBufferFactory, boolean, boolean, String, String, String, + * String) + * @see #DEFAULT_STATEMENT_SEPARATOR + * @see #DEFAULT_COMMENT_PREFIX + * @see #DEFAULT_BLOCK_COMMENT_START_DELIMITER + * @see #DEFAULT_BLOCK_COMMENT_END_DELIMITER + * @see org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils#getConnection + * @see org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils#releaseConnection + */ + public static Mono executeSqlScript(Connection connection, EncodedResource resource) throws ScriptException { + return executeSqlScript(connection, resource, new DefaultDataBufferFactory(), false, false, DEFAULT_COMMENT_PREFIX, + DEFAULT_STATEMENT_SEPARATOR, DEFAULT_BLOCK_COMMENT_START_DELIMITER, DEFAULT_BLOCK_COMMENT_END_DELIMITER); + } + + /** + * Execute the given SQL script. + *

+ * Statement separators and comments will be removed before executing individual statements within the supplied + * script. + *

+ * Warning: this method does not release the provided {@link Connection}. + * + * @param connection the R2DBC connection to use to execute the script; already configured and ready to use. + * @param dataBufferFactory the buffer factory for non-blocking script loading. + * @param resource the resource (potentially associated with a specific encoding) to load the SQL script from. + * @param continueOnError whether or not to continue without throwing an exception in the event of an error. + * @param ignoreFailedDrops whether or not to continue in the event of specifically an error on a {@code DROP} + * statement. + * @param commentPrefix the prefix that identifies single-line comments in the SQL script (typically "--"). + * @param separator the script statement separator; defaults to {@value #DEFAULT_STATEMENT_SEPARATOR} if not specified + * and falls back to {@value #FALLBACK_STATEMENT_SEPARATOR} as a last resort; may be set to + * {@value #EOF_STATEMENT_SEPARATOR} to signal that the script contains a single statement without a + * separator. + * @param blockCommentStartDelimiter the start block comment delimiter. + * @param blockCommentEndDelimiter the end block comment delimiter. + * @return {@link Mono} that initiates script execution and is notified upon completion. + * @throws ScriptException if an error occurred while executing the SQL script. + * @see #DEFAULT_STATEMENT_SEPARATOR + * @see #FALLBACK_STATEMENT_SEPARATOR + * @see #EOF_STATEMENT_SEPARATOR + * @see org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils#getConnection + * @see org.springframework.data.r2dbc.connectionfactory.ConnectionFactoryUtils#releaseConnection + */ + public static Mono executeSqlScript(Connection connection, EncodedResource resource, + DataBufferFactory dataBufferFactory, boolean continueOnError, boolean ignoreFailedDrops, String commentPrefix, + @Nullable String separator, String blockCommentStartDelimiter, String blockCommentEndDelimiter) + throws ScriptException { + + if (logger.isDebugEnabled()) { + logger.debug("Executing SQL script from " + resource); + } + + long startTime = System.currentTimeMillis(); + + Mono script = readScript(resource, dataBufferFactory, commentPrefix, separator, blockCommentEndDelimiter) + .onErrorMap(IOException.class, ex -> new CannotReadScriptException(resource, ex)); + + AtomicInteger statementNumber = new AtomicInteger(); + + Flux executeScript = script.flatMapIterable(it -> { + return splitStatements(it, resource, commentPrefix, separator, blockCommentStartDelimiter, + blockCommentEndDelimiter); + }).concatMap(statement -> { + + statementNumber.incrementAndGet(); + return runStatement(statement, connection, resource, continueOnError, ignoreFailedDrops, statementNumber); + }); + + if (logger.isDebugEnabled()) { + + executeScript = executeScript.doOnComplete(() -> { + + long elapsedTime = System.currentTimeMillis() - startTime; + logger.debug("Executed SQL script from " + resource + " in " + elapsedTime + " ms."); + }); + } + + return executeScript.onErrorMap(ex -> !(ex instanceof ScriptException), + ex -> new UncategorizedScriptException("Failed to execute database script from resource [" + resource + "]", + ex)) + .then(); + } + + private static List splitStatements(String script, EncodedResource resource, String commentPrefix, + @Nullable String separator, String blockCommentStartDelimiter, String blockCommentEndDelimiter) { + + String separatorToUse = separator; + if (separatorToUse == null) { + separatorToUse = DEFAULT_STATEMENT_SEPARATOR; + } + if (!EOF_STATEMENT_SEPARATOR.equals(separatorToUse) && !containsSqlScriptDelimiters(script, separatorToUse)) { + separatorToUse = FALLBACK_STATEMENT_SEPARATOR; + } + + List statements = new ArrayList<>(); + splitSqlScript(resource, script, separatorToUse, commentPrefix, blockCommentStartDelimiter, + blockCommentEndDelimiter, statements); + + return statements; + } + + private static Publisher runStatement(String statement, Connection connection, + EncodedResource resource, boolean continueOnError, boolean ignoreFailedDrops, AtomicInteger statementNumber) { + + Mono execution = Flux.from(connection.createStatement(statement).execute()) // + .flatMap(Result::getRowsUpdated) // + .collect(Collectors.summingLong(it -> it)); + + if (logger.isDebugEnabled()) { + execution = execution.doOnNext(rowsAffected -> { + logger.debug(rowsAffected + " returned as update count for SQL: " + statement); + }); + } + + return execution.onErrorResume(ex -> { + + boolean dropStatement = StringUtils.startsWithIgnoreCase(statement.trim(), "drop"); + if (continueOnError || (dropStatement && ignoreFailedDrops)) { + if (logger.isDebugEnabled()) { + logger.debug(ScriptStatementFailedException.buildErrorMessage(statement, statementNumber.get(), resource), + ex); + } + } else { + return Mono.error(new ScriptStatementFailedException(statement, statementNumber.get(), resource, ex)); + } + + return Mono.empty(); + }).then(); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/UncategorizedScriptException.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/UncategorizedScriptException.java new file mode 100644 index 000000000..ce09afd01 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/UncategorizedScriptException.java @@ -0,0 +1,46 @@ +/* + * 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.connectionfactory.init; + +/** + * Thrown when we cannot determine anything more specific than "something went wrong while processing an SQL script": + * for example, a {@link io.r2dbc.spi.R2dbcException} from R2DBC that we cannot pinpoint more precisely. + * + * @author Mark Paluch + */ +public class UncategorizedScriptException extends ScriptException { + + private static final long serialVersionUID = -3196706179230349902L; + + /** + * Creates a new {@link UncategorizedScriptException}. + * + * @param message detailed message. + */ + public UncategorizedScriptException(String message) { + super(message); + } + + /** + * Creates a new {@link UncategorizedScriptException}. + * + * @param message detailed message. + * @param cause the root cause. + */ + public UncategorizedScriptException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/package-info.java b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/package-info.java new file mode 100644 index 000000000..113c6ba2c --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/connectionfactory/init/package-info.java @@ -0,0 +1,6 @@ +/** + * Provides extensible support for initializing databases through scripts. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package org.springframework.data.r2dbc.connectionfactory.init; diff --git a/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/AbstractDatabaseInitializationTests.java b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/AbstractDatabaseInitializationTests.java new file mode 100644 index 000000000..35c49169a --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/AbstractDatabaseInitializationTests.java @@ -0,0 +1,134 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.ConnectionFactory; +import reactor.test.StepVerifier; + +import org.junit.Test; + +import org.springframework.core.io.ClassRelativeResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.data.r2dbc.core.DatabaseClient; + +/** + * Abstract test support for {@link DatabasePopulator}. + * + * @author Mark Paluch + */ +public abstract class AbstractDatabaseInitializationTests { + + ClassRelativeResourceLoader resourceLoader = new ClassRelativeResourceLoader(getClass()); + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(); + + @Test + public void scriptWithSingleLineCommentsAndFailedDrop() { + + databasePopulator.addScript(resource("db-schema-failed-drop-comments.sql")); + databasePopulator.addScript(resource("db-test-data.sql")); + databasePopulator.setIgnoreFailedDrops(true); + + runPopulator(); + + assertUsersDatabaseCreated("Heisenberg"); + } + + private void runPopulator() { + DatabasePopulatorUtils.execute(databasePopulator, getConnectionFactory()) // + .as(StepVerifier::create) // + .verifyComplete(); + } + + @Test + public void scriptWithStandardEscapedLiteral() { + + databasePopulator.addScript(defaultSchema()); + databasePopulator.addScript(resource("db-test-data-escaped-literal.sql")); + + runPopulator(); + + assertUsersDatabaseCreated("'Heisenberg'"); + } + + @Test + public void scriptWithMySqlEscapedLiteral() { + + databasePopulator.addScript(defaultSchema()); + databasePopulator.addScript(resource("db-test-data-mysql-escaped-literal.sql")); + + runPopulator(); + + assertUsersDatabaseCreated("\\$Heisenberg\\$"); + } + + @Test + public void scriptWithMultipleStatements() { + + databasePopulator.addScript(defaultSchema()); + databasePopulator.addScript(resource("db-test-data-multiple.sql")); + + runPopulator(); + + assertUsersDatabaseCreated("Heisenberg", "Jesse"); + } + + @Test + public void scriptWithMultipleStatementsAndLongSeparator() { + + databasePopulator.addScript(defaultSchema()); + databasePopulator.addScript(resource("db-test-data-endings.sql")); + databasePopulator.setSeparator("@@"); + + runPopulator(); + + assertUsersDatabaseCreated("Heisenberg", "Jesse"); + } + + abstract ConnectionFactory getConnectionFactory(); + + Resource resource(String path) { + return resourceLoader.getResource(path); + } + + Resource defaultSchema() { + return resource("db-schema.sql"); + } + + Resource usersSchema() { + return resource("users-schema.sql"); + } + + void assertUsersDatabaseCreated(String... lastNames) { + assertUsersDatabaseCreated(getConnectionFactory(), lastNames); + } + + void assertUsersDatabaseCreated(ConnectionFactory connectionFactory, String... lastNames) { + + DatabaseClient client = DatabaseClient.create(connectionFactory); + + for (String lastName : lastNames) { + + client.execute("select count(0) from users where last_name = :name") // + .bind("name", lastName) // + .map((row, metadata) -> row.get(0)) // + .first() // + .map(it -> ((Number) it).intValue()) // + .as(StepVerifier::create) // + .expectNext(1).as("Did not find user with last name [" + lastName + "].") // + .verifyComplete(); + } + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/CompositeDatabasePopulatorTests.java b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/CompositeDatabasePopulatorTests.java new file mode 100644 index 000000000..1fa6a67d7 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/CompositeDatabasePopulatorTests.java @@ -0,0 +1,112 @@ +/* + * 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.connectionfactory.init; + +import static org.mockito.Mockito.*; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.LinkedHashSet; +import java.util.Set; + +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link CompositeDatabasePopulator}. + * + * @author Mark Paluch + */ +public class CompositeDatabasePopulatorTests { + + Connection mockedConnection = mock(Connection.class); + + DatabasePopulator mockedDatabasePopulator1 = mock(DatabasePopulator.class); + + DatabasePopulator mockedDatabasePopulator2 = mock(DatabasePopulator.class); + + @Before + public void before() { + + when(mockedDatabasePopulator1.populate(mockedConnection)).thenReturn(Mono.empty()); + when(mockedDatabasePopulator2.populate(mockedConnection)).thenReturn(Mono.empty()); + } + + @Test + public void addPopulators() { + + CompositeDatabasePopulator populator = new CompositeDatabasePopulator(); + populator.addPopulators(mockedDatabasePopulator1, mockedDatabasePopulator2); + + populator.populate(mockedConnection).as(StepVerifier::create).verifyComplete(); + + verify(mockedDatabasePopulator1, times(1)).populate(mockedConnection); + verify(mockedDatabasePopulator2, times(1)).populate(mockedConnection); + } + + @Test + public void setPopulatorsWithMultiple() { + + CompositeDatabasePopulator populator = new CompositeDatabasePopulator(); + populator.setPopulators(mockedDatabasePopulator1, mockedDatabasePopulator2); // multiple + + populator.populate(mockedConnection).as(StepVerifier::create).verifyComplete(); + + verify(mockedDatabasePopulator1, times(1)).populate(mockedConnection); + verify(mockedDatabasePopulator2, times(1)).populate(mockedConnection); + } + + @Test + public void setPopulatorsForOverride() { + + CompositeDatabasePopulator populator = new CompositeDatabasePopulator(); + populator.setPopulators(mockedDatabasePopulator1); + populator.setPopulators(mockedDatabasePopulator2); // override + + populator.populate(mockedConnection).as(StepVerifier::create).verifyComplete(); + + verify(mockedDatabasePopulator1, times(0)).populate(mockedConnection); + verify(mockedDatabasePopulator2, times(1)).populate(mockedConnection); + } + + @Test + public void constructWithVarargs() { + + CompositeDatabasePopulator populator = new CompositeDatabasePopulator(mockedDatabasePopulator1, + mockedDatabasePopulator2); + + populator.populate(mockedConnection).as(StepVerifier::create).verifyComplete(); + + verify(mockedDatabasePopulator1, times(1)).populate(mockedConnection); + verify(mockedDatabasePopulator2, times(1)).populate(mockedConnection); + } + + @Test + public void constructWithCollection() { + + Set populators = new LinkedHashSet<>(); + populators.add(mockedDatabasePopulator1); + populators.add(mockedDatabasePopulator2); + + CompositeDatabasePopulator populator = new CompositeDatabasePopulator(populators); + populator.populate(mockedConnection).as(StepVerifier::create).verifyComplete(); + + verify(mockedDatabasePopulator1, times(1)).populate(mockedConnection); + verify(mockedDatabasePopulator2, times(1)).populate(mockedConnection); + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/H2DatabasePopulatorIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/H2DatabasePopulatorIntegrationTests.java new file mode 100644 index 000000000..fc388b0f9 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/H2DatabasePopulatorIntegrationTests.java @@ -0,0 +1,57 @@ +/* + * 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.connectionfactory.init; + +import io.r2dbc.spi.ConnectionFactories; +import io.r2dbc.spi.ConnectionFactory; +import reactor.test.StepVerifier; + +import java.util.UUID; + +import org.junit.Test; + +/** + * Integration tests for {@link DatabasePopulator} using H2. + * + * @author Mark Paluch + */ +public class H2DatabasePopulatorIntegrationTests extends AbstractDatabaseInitializationTests { + + UUID databaseName = UUID.randomUUID(); + + ConnectionFactory connectionFactory = ConnectionFactories + .get("r2dbc:h2:mem:///" + databaseName + "?options=DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"); + + @Override + ConnectionFactory getConnectionFactory() { + return this.connectionFactory; + } + + @Test + public void shouldRunScript() { + + databasePopulator.addScript(usersSchema()); + databasePopulator.addScript(resource("db-test-data-h2.sql")); + // Set statement separator to double newline so that ";" is not + // considered a statement separator within the source code of the + // aliased function 'REVERSE'. + databasePopulator.setSeparator("\n\n"); + + DatabasePopulatorUtils.execute(databasePopulator, connectionFactory).as(StepVerifier::create).verifyComplete(); + + assertUsersDatabaseCreated(connectionFactory, "White"); + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/ResourceDatabasePopulatorUnitTests.java b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/ResourceDatabasePopulatorUnitTests.java new file mode 100644 index 000000000..d5594572a --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/ResourceDatabasePopulatorUnitTests.java @@ -0,0 +1,110 @@ +/* + * 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.connectionfactory.init; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.Test; + +import org.springframework.core.io.Resource; + +/** + * Unit tests for {@link ResourceDatabasePopulator}. + * + * @author Mark Paluch + */ +public class ResourceDatabasePopulatorUnitTests { + + private static final Resource script1 = mock(Resource.class); + private static final Resource script2 = mock(Resource.class); + private static final Resource script3 = mock(Resource.class); + + @Test + public void constructWithNullResource() { + assertThatIllegalArgumentException().isThrownBy(() -> new ResourceDatabasePopulator((Resource) null)); + } + + @Test + public void constructWithNullResourceArray() { + assertThatIllegalArgumentException().isThrownBy(() -> new ResourceDatabasePopulator((Resource[]) null)); + } + + @Test + public void constructWithResource() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(script1); + assertThat(databasePopulator.scripts.size()).isEqualTo(1); + } + + @Test + public void constructWithMultipleResources() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(script1, script2); + assertThat(databasePopulator.scripts.size()).isEqualTo(2); + } + + @Test + public void constructWithMultipleResourcesAndThenAddScript() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(script1, script2); + assertThat(databasePopulator.scripts.size()).isEqualTo(2); + + databasePopulator.addScript(script3); + assertThat(databasePopulator.scripts.size()).isEqualTo(3); + } + + @Test + public void addScriptsWithNullResource() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(); + assertThatIllegalArgumentException().isThrownBy(() -> databasePopulator.addScripts((Resource) null)); + } + + @Test + public void addScriptsWithNullResourceArray() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(); + assertThatIllegalArgumentException().isThrownBy(() -> databasePopulator.addScripts((Resource[]) null)); + } + + @Test + public void setScriptsWithNullResource() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(); + assertThatIllegalArgumentException().isThrownBy(() -> databasePopulator.setScripts((Resource) null)); + } + + @Test + public void setScriptsWithNullResourceArray() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(); + assertThatIllegalArgumentException().isThrownBy(() -> databasePopulator.setScripts((Resource[]) null)); + } + + @Test + public void setScriptsAndThenAddScript() { + + ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator(); + assertThat(databasePopulator.scripts.size()).isEqualTo(0); + + databasePopulator.setScripts(script1, script2); + assertThat(databasePopulator.scripts.size()).isEqualTo(2); + + databasePopulator.addScript(script3); + assertThat(databasePopulator.scripts.size()).isEqualTo(3); + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptUtilsUnitTests.java b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptUtilsUnitTests.java new file mode 100644 index 000000000..111c811bd --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/connectionfactory/init/ScriptUtilsUnitTests.java @@ -0,0 +1,205 @@ +/* + * 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.connectionfactory.init; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; + +import org.assertj.core.util.Strings; +import org.junit.Test; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.core.io.support.EncodedResource; + +/** + * Unit tests for {@link ScriptUtils}. + * + * @author Mark Paluch + */ +public class ScriptUtilsUnitTests { + + @Test + public void splitSqlScriptDelimitedWithSemicolon() { + + String rawStatement1 = "insert into customer (id, name)\nvalues (1, 'Rod ; Johnson'), (2, 'Adrian \n Collier')"; + String cleanedStatement1 = "insert into customer (id, name) values (1, 'Rod ; Johnson'), (2, 'Adrian \n Collier')"; + String rawStatement2 = "insert into orders(id, order_date, customer_id)\nvalues (1, '2008-01-02', 2)"; + String cleanedStatement2 = "insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2)"; + String rawStatement3 = "insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2)"; + String cleanedStatement3 = "insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2)"; + + String script = Strings.join(rawStatement1, rawStatement2, rawStatement3).with(";"); + + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, ";", statements); + + assertThat(statements).hasSize(3).containsSequence(cleanedStatement1, cleanedStatement2, cleanedStatement3); + } + + @Test + public void splitSqlScriptDelimitedWithNewLine() { + + String statement1 = "insert into customer (id, name) values (1, 'Rod ; Johnson'), (2, 'Adrian \n Collier')"; + String statement2 = "insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2)"; + String statement3 = "insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2)"; + + String script = Strings.join(statement1, statement2, statement3).with("\n"); + + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, "\n", statements); + + assertThat(statements).hasSize(3).containsSequence(statement1, statement2, statement3); + } + + @Test + public void splitSqlScriptDelimitedWithNewLineButDefaultDelimiterSpecified() { + + String statement1 = "do something"; + String statement2 = "do something else"; + + char delim = '\n'; + String script = statement1 + delim + statement2 + delim; + + List statements = new ArrayList<>(); + + ScriptUtils.splitSqlScript(script, ScriptUtils.DEFAULT_STATEMENT_SEPARATOR, statements); + + assertThat(statements).hasSize(1).contains(script.replace('\n', ' ')); + } + + @Test + public void splitScriptWithSingleQuotesNestedInsideDoubleQuotes() { + + String statement1 = "select '1' as \"Dogbert's owner's\" from dual"; + String statement2 = "select '2' as \"Dilbert's\" from dual"; + + char delim = ';'; + String script = statement1 + delim + statement2 + delim; + + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, ';', statements); + + assertThat(statements).hasSize(2).containsSequence(statement1, statement2); + } + + @Test + public void readAndSplitScriptWithMultipleNewlinesAsSeparator() { + + String script = readScript("db-test-data-multi-newline.sql"); + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, "\n\n", statements); + + String statement1 = "insert into users (last_name) values ('Walter')"; + String statement2 = "insert into users (last_name) values ('Jesse')"; + + assertThat(statements.size()).as("wrong number of statements").isEqualTo(2); + assertThat(statements.get(0)).as("statement 1 not split correctly").isEqualTo(statement1); + assertThat(statements.get(1)).as("statement 2 not split correctly").isEqualTo(statement2); + } + + @Test + public void readAndSplitScriptContainingComments() { + String script = readScript("test-data-with-comments.sql"); + splitScriptContainingComments(script); + } + + @Test + public void readAndSplitScriptContainingCommentsWithWindowsLineEnding() { + String script = readScript("test-data-with-comments.sql").replaceAll("\n", "\r\n"); + splitScriptContainingComments(script); + } + + private void splitScriptContainingComments(String script) { + + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, ';', statements); + + String statement1 = "insert into customer (id, name) values (1, 'Rod; Johnson'), (2, 'Adrian Collier')"; + String statement2 = "insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2)"; + String statement3 = "insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2)"; + String statement4 = "INSERT INTO persons( person_id , name) VALUES( 1 , 'Name' )"; + + assertThat(statements).hasSize(4).containsSequence(statement1, statement2, statement3, statement4); + } + + @Test + public void readAndSplitScriptContainingCommentsWithLeadingTabs() { + + String script = readScript("test-data-with-comments-and-leading-tabs.sql"); + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, ';', statements); + + String statement1 = "insert into customer (id, name) values (1, 'Walter White')"; + String statement2 = "insert into orders(id, order_date, customer_id) values (1, '2013-06-08', 1)"; + String statement3 = "insert into orders(id, order_date, customer_id) values (2, '2013-06-08', 1)"; + + assertThat(statements).hasSize(3).containsSequence(statement1, statement2, statement3); + } + + @Test + public void readAndSplitScriptContainingMultiLineComments() { + + String script = readScript("test-data-with-multi-line-comments.sql"); + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, ';', statements); + + String statement1 = "INSERT INTO users(first_name, last_name) VALUES('Walter', 'White')"; + String statement2 = "INSERT INTO users(first_name, last_name) VALUES( 'Jesse' , 'Pinkman' )"; + + assertThat(statements).hasSize(2).containsSequence(statement1, statement2); + } + + @Test + public void readAndSplitScriptContainingMultiLineNestedComments() { + + String script = readScript("test-data-with-multi-line-nested-comments.sql"); + List statements = new ArrayList<>(); + ScriptUtils.splitSqlScript(script, ';', statements); + + String statement1 = "INSERT INTO users(first_name, last_name) VALUES('Walter', 'White')"; + String statement2 = "INSERT INTO users(first_name, last_name) VALUES( 'Jesse' , 'Pinkman' )"; + + assertThat(statements).hasSize(2).containsSequence(statement1, statement2); + } + + @Test + public void containsDelimiters() { + + assertThat(ScriptUtils.containsSqlScriptDelimiters("select 1\n select ';'", ";")).isFalse(); + assertThat(ScriptUtils.containsSqlScriptDelimiters("select 1; select 2", ";")).isTrue(); + + assertThat(ScriptUtils.containsSqlScriptDelimiters("select 1; select '\\n\n';", "\n")).isFalse(); + assertThat(ScriptUtils.containsSqlScriptDelimiters("select 1\n select 2", "\n")).isTrue(); + + assertThat(ScriptUtils.containsSqlScriptDelimiters("select 1\n select 2", "\n\n")).isFalse(); + assertThat(ScriptUtils.containsSqlScriptDelimiters("select 1\n\n select 2", "\n\n")).isTrue(); + + // MySQL style escapes '\\' + assertThat( + ScriptUtils.containsSqlScriptDelimiters("insert into users(first_name, last_name)\nvalues('a\\\\', 'b;')", ";")) + .isFalse(); + assertThat(ScriptUtils.containsSqlScriptDelimiters( + "insert into users(first_name, last_name)\nvalues('Charles', 'd\\'Artagnan'); select 1;", ";")).isTrue(); + } + + private String readScript(String path) { + EncodedResource resource = new EncodedResource(new ClassPathResource(path, getClass())); + return ScriptUtils.readScript(resource, new DefaultDataBufferFactory()).block(); + } +} diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-schema-failed-drop-comments.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-schema-failed-drop-comments.sql new file mode 100644 index 000000000..8ce888986 --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-schema-failed-drop-comments.sql @@ -0,0 +1,5 @@ +-- Failed DROP can be ignored if necessary +drop table users; + +-- Create the test table +create table users (last_name varchar(50) not null); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-schema.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-schema.sql new file mode 100644 index 000000000..4de3841ec --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-schema.sql @@ -0,0 +1,3 @@ +drop table users if exists; + +create table users (last_name varchar(50) not null); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-endings.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-endings.sql new file mode 100644 index 000000000..78a82189b --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-endings.sql @@ -0,0 +1,2 @@ +insert into users (last_name) values ('Heisenberg')@@ +insert into users (last_name) values ('Jesse')@@ diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-escaped-literal.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-escaped-literal.sql new file mode 100644 index 000000000..3ba33ccac --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-escaped-literal.sql @@ -0,0 +1 @@ +insert into users (last_name) values ('''Heisenberg'''); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-h2.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-h2.sql new file mode 100644 index 000000000..a62e920a2 --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-h2.sql @@ -0,0 +1 @@ +INSERT INTO users(first_name, last_name) values('Walter', 'White'); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-multi-newline.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-multi-newline.sql new file mode 100644 index 000000000..6239f6adc --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-multi-newline.sql @@ -0,0 +1,5 @@ +insert into users (last_name) +values ('Walter') + +insert into users (last_name) +values ('Jesse') diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-multiple.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-multiple.sql new file mode 100644 index 000000000..ea185476c --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-multiple.sql @@ -0,0 +1,2 @@ +insert into users (last_name) values ('Heisenberg'); +insert into users (last_name) values ('Jesse'); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-mysql-escaped-literal.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-mysql-escaped-literal.sql new file mode 100644 index 000000000..dae44d40e --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data-mysql-escaped-literal.sql @@ -0,0 +1 @@ +insert into users (last_name) values ('\$Heisenberg\$'); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data.sql new file mode 100644 index 000000000..85673705f --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/db-test-data.sql @@ -0,0 +1 @@ +insert into users (last_name) values ('Heisenberg'); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-comments-and-leading-tabs.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-comments-and-leading-tabs.sql new file mode 100644 index 000000000..ddb67f0cf --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-comments-and-leading-tabs.sql @@ -0,0 +1,9 @@ +-- The next comment line starts with a tab. + -- x, y, z... + +insert into customer (id, name) +values (1, 'Walter White'); + -- This is also a comment with a leading tab. +insert into orders(id, order_date, customer_id) values (1, '2013-06-08', 1); + -- This is also a comment with a leading tab, a space, and a tab. +insert into orders(id, order_date, customer_id) values (2, '2013-06-08', 1); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-comments.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-comments.sql new file mode 100644 index 000000000..82483ca4d --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-comments.sql @@ -0,0 +1,16 @@ +-- The next comment line has no text after the '--' prefix. +-- +-- The next comment line starts with a space. + -- x, y, z... + +insert into customer (id, name) +values (1, 'Rod; Johnson'), (2, 'Adrian Collier'); +-- This is also a comment. +insert into orders(id, order_date, customer_id) +values (1, '2008-01-02', 2); +insert into orders(id, order_date, customer_id) values (1, '2008-01-02', 2); +INSERT INTO persons( person_id-- + , name) +VALUES( 1 -- person_id + , 'Name' --name +);-- \ No newline at end of file diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-multi-line-comments.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-multi-line-comments.sql new file mode 100644 index 000000000..8cfa6d438 --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-multi-line-comments.sql @@ -0,0 +1,17 @@ +/* This is a multi line comment + * The next comment line has no text + + * The next comment line starts with a space. + * x, y, z... + */ + +INSERT INTO users(first_name, last_name) VALUES('Walter', 'White'); +-- This is also a comment. +/* + * Let's add another comment + * that covers multiple lines + */INSERT INTO +users(first_name, last_name) +VALUES( 'Jesse' -- first_name + , 'Pinkman' -- last_name +);-- diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-multi-line-nested-comments.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-multi-line-nested-comments.sql new file mode 100644 index 000000000..5a3d3a136 --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/test-data-with-multi-line-nested-comments.sql @@ -0,0 +1,23 @@ +/* This is a multi line comment + * The next comment line has no text + + * The next comment line starts with a space. + * x, y, z... + */ + +INSERT INTO users(first_name, last_name) VALUES('Walter', 'White'); +-- This is also a comment. +/*------------------------------------------- +-- A fancy multi-line comments that puts +-- single line comments inside of a multi-line +-- comment block. +Moreover, the block comment end delimiter +appears on a line that can potentially also +be a single-line comment if we weren't +already inside a multi-line comment run. +-------------------------------------------*/ + INSERT INTO +users(first_name, last_name) -- This is a single line comment containing the block-end-comment sequence here */ but it's still a single-line comment +VALUES( 'Jesse' -- first_name + , 'Pinkman' -- last_name +);-- diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/users-data.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/users-data.sql new file mode 100644 index 000000000..a6aa78385 --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/users-data.sql @@ -0,0 +1,3 @@ +INSERT INTO +users(first_name, last_name) +values('Sam', 'Brannen'); diff --git a/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/users-schema.sql b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/users-schema.sql new file mode 100644 index 000000000..80ffe23da --- /dev/null +++ b/src/test/resources/org/springframework/data/r2dbc/connectionfactory/init/users-schema.sql @@ -0,0 +1,7 @@ +DROP TABLE users IF EXISTS; + +CREATE TABLE users ( + id INTEGER NOT NULL IDENTITY, + first_name VARCHAR(50) NOT NULL, + last_name VARCHAR(50) NOT NULL +);