From 416780097d01bd8afdc288f9b1a95b5edcbd9c3f Mon Sep 17 00:00:00 2001 From: Exlll Date: Thu, 9 May 2019 23:04:30 +0200 Subject: [PATCH] Add ScriptRunner utility class --- .travis.yml | 1 - .../src/main/resources/plugin.yml | 2 +- .../src/main/resources/plugin.yml | 2 +- .../databaselib/sql/util/QueryReader.java | 67 +++++++++++ .../databaselib/sql/util/ScriptRunner.java | 111 ++++++++++++++++++ .../databaselib/sql/DummyConnection.java | 18 ++- .../databaselib/sql/util/QueryReaderTest.java | 79 +++++++++++++ .../sql/util/ScriptRunnerTest.java | 94 +++++++++++++++ .../de/exlll/databaselib/sql/util/script.sql | 15 +++ README.md | 88 +++++++++++++- build.gradle | 2 +- 11 files changed, 467 insertions(+), 12 deletions(-) create mode 100644 DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/QueryReader.java create mode 100644 DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/ScriptRunner.java create mode 100644 DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/QueryReaderTest.java create mode 100644 DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/ScriptRunnerTest.java create mode 100644 DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/script.sql diff --git a/.travis.yml b/.travis.yml index ae781b0..e6f38c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: java jdk: - openjdk8 - - openjdk10 - openjdk11 - openjdk12 diff --git a/DatabaseLib-Bukkit/src/main/resources/plugin.yml b/DatabaseLib-Bukkit/src/main/resources/plugin.yml index f6767e4..063eb08 100644 --- a/DatabaseLib-Bukkit/src/main/resources/plugin.yml +++ b/DatabaseLib-Bukkit/src/main/resources/plugin.yml @@ -1,7 +1,7 @@ name: DatabaseLib author: Exlll -version: 3.1.0 +version: 3.2.0 main: de.exlll.databaselib.DatabaseLib depend: ['ConfigLib'] \ No newline at end of file diff --git a/DatabaseLib-Bungee/src/main/resources/plugin.yml b/DatabaseLib-Bungee/src/main/resources/plugin.yml index d8fab5c..e713198 100644 --- a/DatabaseLib-Bungee/src/main/resources/plugin.yml +++ b/DatabaseLib-Bungee/src/main/resources/plugin.yml @@ -1,7 +1,7 @@ name: DatabaseLib author: Exlll -version: 3.1.0 +version: 3.2.0 main: de.exlll.databaselib.DatabaseLib depends: ['ConfigLib'] \ No newline at end of file diff --git a/DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/QueryReader.java b/DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/QueryReader.java new file mode 100644 index 0000000..463ed99 --- /dev/null +++ b/DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/QueryReader.java @@ -0,0 +1,67 @@ +package de.exlll.databaselib.sql.util; + +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.List; + +final class QueryReader { + private final Reader reader; + private final char delimiter; + + QueryReader(Reader reader, char delimiter) { + this.reader = reader; + this.delimiter = delimiter; + } + + public List readQueries() throws IOException { + List queries = new ArrayList<>(); + + String query; + while (!(query = readQuery()).isEmpty()) { + queries.add(query.trim()); + } + + return queries; + } + + private String readQuery() throws IOException { + StringBuilder builder = new StringBuilder(); + + int value; + while ((value = reader.read()) != -1) { + char c = (char) value; + + if (c == '"') { + readToClosingQuote(builder, '"'); + } else if (c == '\'') { + readToClosingQuote(builder, '\''); + } else if (c == delimiter) { + return builder.toString(); + } else { + builder.append((c == '\n') ? ' ' : c); + } + } + + return builder.toString(); + } + + private void readToClosingQuote(StringBuilder builder, char quoteChar) + throws IOException { + builder.append(quoteChar); + + int value; + boolean escaped = false; + while ((value = reader.read()) != -1) { + char c = (char) value; + + if (c == quoteChar && !escaped) { + builder.append(quoteChar); + return; + } + + escaped = (c == '\\' && !escaped); + builder.append(c); + } + } +} diff --git a/DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/ScriptRunner.java b/DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/ScriptRunner.java new file mode 100644 index 0000000..496d836 --- /dev/null +++ b/DatabaseLib-Core/src/main/java/de/exlll/databaselib/sql/util/ScriptRunner.java @@ -0,0 +1,111 @@ +package de.exlll.databaselib.sql.util; + +import java.io.IOException; +import java.io.Reader; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Logger; + +/** + * A {@code ScriptRunner} is a utility class to execute SQL scripts. + *

+ * An SQL script is a file that contains SQL queries delimited by ';' (semicolon). + * You can create a {@code ScriptRunner} by passing an instance of a {@link Reader} + * and a {@link Connection} to its constructor. A {@code ScriptRunner} has the ability + * to replace any part of a query prior to executing it. + */ +public final class ScriptRunner { + private static final Logger queryLogger = Logger.getLogger( + ScriptRunner.class.getName() + ); + private final QueryReader queryReader; + private final Connection connection; + private Map replacements = Collections.emptyMap(); + private boolean logQueries; + + /** + * Creates a new {@code ScriptRunner} that executes queries read from the + * given {@code Reader} using the given {@code Connection}. + * + * @param reader {@code Reader} queries are read from + * @param connection {@code Connection} used to execute queries + * @throws NullPointerException if any argument is null + */ + public ScriptRunner(Reader reader, Connection connection) { + this.queryReader = new QueryReader( + Objects.requireNonNull(reader), ';' + ); + this.connection = Objects.requireNonNull(connection); + } + + /** + * Executes all queries from the given {@code Reader}. + * + * @throws IOException if an I/O error occurs while reading from the {@code Reader} + * @throws SQLException if a database access error occurred or + * if at least on of the queries failed to execute + */ + public void runScript() throws IOException, SQLException { + List queries = queryReader.readQueries(); + try (Statement stmt = connection.createStatement()) { + for (String query : queries) { + executeQuery(stmt, query); + } + } + } + + private void executeQuery(Statement stmt, String query) throws SQLException { + query = preProcess(query); + if (logQueries) { + queryLogger.info(query); + } + stmt.execute(query); + } + + private String preProcess(String query) { + for (Map.Entry entry : replacements.entrySet()) { + String key = entry.getKey(); + String replacement = (entry.getValue() == null) + ? "null" + : entry.getValue().toString(); + query = query.replace(key, replacement); + } + return query; + } + + /** + * Sets the query replacements. Defaults to an empty map. + *

+ * Before a query is executed, all parts of the query that + * match a key of the map are replaced with the value to + * which the key is mapped. + * + * @param replacements the query replacements + * @return this {@code ScriptRunner} + * @throws NullPointerException if {@code replacements} or any of its keys is null + */ + public ScriptRunner setReplacements(Map replacements) { + for (String key : replacements.keySet()) { + Objects.requireNonNull(key, "Map must not contain null keys."); + } + this.replacements = replacements; + return this; + } + + /** + * Enables or disables query logging. Default value is false. + * Each query is logged right before it is executed. + * + * @param logQueries true if queries should be logged, false otherwise + * @return this {@code ScriptRunner} + */ + public ScriptRunner setLogQueries(boolean logQueries) { + this.logQueries = logQueries; + return this; + } +} diff --git a/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/DummyConnection.java b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/DummyConnection.java index c7c60e9..38b9aec 100644 --- a/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/DummyConnection.java +++ b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/DummyConnection.java @@ -1,12 +1,26 @@ package de.exlll.databaselib.sql; import java.sql.*; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Properties; import java.util.concurrent.Executor; public class DummyConnection implements Connection { + private DummyStatement lastStatement; + + public DummyStatement getLastStatement() { + return lastStatement; + } + public static class DummyStatement implements Statement { + private final List executedQueries = new ArrayList<>(); + + public List getExecutedQueries() { + return executedQueries; + } + @Override public ResultSet executeQuery(String sql) { return null; @@ -79,7 +93,7 @@ public void setCursorName(String name) { @Override public boolean execute(String sql) { - return false; + return executedQueries.add(sql); } @Override @@ -231,7 +245,7 @@ public boolean isWrapperFor(Class iface) { @Override public Statement createStatement() { - return null; + return this.lastStatement = new DummyStatement(); } @Override diff --git a/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/QueryReaderTest.java b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/QueryReaderTest.java new file mode 100644 index 0000000..a2b4837 --- /dev/null +++ b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/QueryReaderTest.java @@ -0,0 +1,79 @@ +package de.exlll.databaselib.sql.util; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; + +class QueryReaderTest { + public static final String SQL_INPUT = "CREATE TABLE IF NOT EXISTS `test`\n" + + "(\n" + + " `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n" + + " `name` VARCHAR(32)\n" + + ") DEFAULT CHARACTER SET utf8;\n" + + "\n" + + "SELECT * FROM `test` WHERE `id` = 10;\n" + + "SELECT * FROM `test` WHERE `name` = \";\";\n" + + "SELECT * FROM `test` WHERE `name` = \"\\\"\";\n" + + "SELECT * FROM `test` WHERE `name` = \"\\\\\";\n" + + "SELECT * FROM `test` WHERE `name` = \"\\\\\\\"\";\n" + + "SELECT * FROM `test` WHERE `name` = ';';\n" + + "SELECT * FROM `test` WHERE `name` = '\\'';\n" + + "SELECT * FROM `test` WHERE `name` = '\\\\';\n" + + "SELECT * FROM `test` WHERE `name` = '\\\\\\'';"; + private static final String SQL_INPUT_PIPE_DELIM = "CREATE TABLE IF NOT EXISTS `test`\n" + + "(\n" + + " `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,\n" + + " `name` VARCHAR(32)\n" + + ") DEFAULT CHARACTER SET utf8|\n" + + "\n" + + "SELECT * FROM `test` WHERE `id` = 10|\n" + + "SELECT * FROM `test` WHERE `name` = \"|\"|\n" + + "SELECT * FROM `test` WHERE `name` = \"\\\"\"|\n" + + "SELECT * FROM `test` WHERE `name` = \"\\\\\"|\n" + + "SELECT * FROM `test` WHERE `name` = \"\\\\\\\"\"|\n" + + "SELECT * FROM `test` WHERE `name` = '|'|\n" + + "SELECT * FROM `test` WHERE `name` = '\\''|\n" + + "SELECT * FROM `test` WHERE `name` = '\\\\'|\n" + + "SELECT * FROM `test` WHERE `name` = '\\\\\\''|"; + public static final List QUERIES = Arrays.asList( + "CREATE TABLE IF NOT EXISTS `test` ( " + + " `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, " + + " `name` VARCHAR(32) ) " + + "DEFAULT CHARACTER SET utf8", + "SELECT * FROM `test` WHERE `id` = 10", + "SELECT * FROM `test` WHERE `name` = \";\"", + "SELECT * FROM `test` WHERE `name` = \"\\\"\"", + "SELECT * FROM `test` WHERE `name` = \"\\\\\"", + "SELECT * FROM `test` WHERE `name` = \"\\\\\\\"\"", + "SELECT * FROM `test` WHERE `name` = ';'", + "SELECT * FROM `test` WHERE `name` = '\\''", + "SELECT * FROM `test` WHERE `name` = '\\\\'", + "SELECT * FROM `test` WHERE `name` = '\\\\\\''" + ); + + + @Test + void readQueriesReadsAllQueries() throws IOException { + QueryReader reader = new QueryReader( + new StringReader(SQL_INPUT), ';' + ); + assertThat(reader.readQueries(), is(QUERIES)); + } + + @Test + void readQueriesUsesDelimiter() throws IOException { + QueryReader reader = new QueryReader( + new StringReader(SQL_INPUT_PIPE_DELIM), '|' + ); + List queries = new ArrayList<>(QueryReaderTest.QUERIES); + queries.replaceAll(s -> s.replace(';', '|')); + assertThat(reader.readQueries(), is(queries)); + } +} \ No newline at end of file diff --git a/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/ScriptRunnerTest.java b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/ScriptRunnerTest.java new file mode 100644 index 0000000..5a57635 --- /dev/null +++ b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/ScriptRunnerTest.java @@ -0,0 +1,94 @@ +package de.exlll.databaselib.sql.util; + +import de.exlll.databaselib.sql.DummyConnection; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.Reader; +import java.io.StringReader; +import java.util.*; + +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ScriptRunnerTest { + private ScriptRunner runner; + private DummyConnection connection; + private Reader reader; + + @BeforeEach + void setUp() { + connection = new DummyConnection(); + reader = new StringReader(QueryReaderTest.SQL_INPUT); + runner = new ScriptRunner(reader, connection); + } + + @Test + void constructorRequiresNonNullArgs() { + assertThrows(NullPointerException.class, + () -> new ScriptRunner(null, connection)); + assertThrows(NullPointerException.class, + () -> new ScriptRunner(reader, null)); + } + + @Test + void setReplacementsRequiresNonNullKeys() { + assertThrows(NullPointerException.class, + () -> runner.setReplacements(null) + ); + String msg = assertThrows(NullPointerException.class, + () -> runner.setReplacements(mapOf(null, "")) + ).getMessage(); + assertThat(msg, is("Map must not contain null keys.")); + + runner.setReplacements(mapOf("", "")); + } + + @Test + void runScriptExecutesQueries() throws Exception { + runner.runScript(); + List executedQueries = connection.getLastStatement() + .getExecutedQueries(); + assertThat(executedQueries, is(QueryReaderTest.QUERIES)); + } + + @Test + void runScriptAppliesReplacements() throws Exception { + final String query = "SELECT %X% FROM %Y% WHERE name = '%X%'"; + testReplacements( + query, mapOf("%X%", "TEST"), + "SELECT TEST FROM %Y% WHERE name = 'TEST'" + ); + testReplacements( + query, mapOf("%X%", "TEST1", "%Y%", "TEST2"), + "SELECT TEST1 FROM TEST2 WHERE name = 'TEST1'" + ); + } + + private void testReplacements( + String query, Map replacements, String expectedResult + ) throws Exception { + DummyConnection connection = new DummyConnection(); + Reader reader = new StringReader(query); + runner = new ScriptRunner(reader, connection) + .setReplacements(replacements); + runner.runScript(); + List executedQueries = connection.getLastStatement() + .getExecutedQueries(); + List expected = Collections.singletonList(expectedResult); + assertThat(executedQueries, is(expected)); + } + + private static Map mapOf(K k1, T v1) { + Map map = new HashMap<>(); + map.put(k1, v1); + return map; + } + + private static Map mapOf(K k1, T v1, K k2, T v2) { + Map map = mapOf(k1, v1); + map.put(k2, v2); + return map; + } +} \ No newline at end of file diff --git a/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/script.sql b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/script.sql new file mode 100644 index 0000000..05e7658 --- /dev/null +++ b/DatabaseLib-Core/src/test/java/de/exlll/databaselib/sql/util/script.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `test` +( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(32) +) DEFAULT CHARACTER SET utf8; + +SELECT * FROM `test` WHERE `id` = 10; +SELECT * FROM `test` WHERE `name` = ";"; +SELECT * FROM `test` WHERE `name` = "\""; +SELECT * FROM `test` WHERE `name` = "\\"; +SELECT * FROM `test` WHERE `name` = "\\\""; +SELECT * FROM `test` WHERE `name` = ';'; +SELECT * FROM `test` WHERE `name` = '\''; +SELECT * FROM `test` WHERE `name` = '\\'; +SELECT * FROM `test` WHERE `name` = '\\\''; diff --git a/README.md b/README.md index 077294c..dfb38df 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,64 @@ Some examples: - if you return a `String`, pass a `BiConsumer` - in general: if you return something of type `R`, pass a `BiConsumer` +

+ Usage example + +```java +public static final class UserRepo extends PluginSqlTaskSubmitter { + public UserRepo(JavaPlugin plugin) { super(plugin); } + + public void deleteUser(UUID uuid, Consumer callback) { + String query = "DELETE FROM `users` WHERE `uuid` = ?"; + submitSqlPreparedStatementTask(query, preparedStatement -> { + preparedStatement.setString(1, uuid.toString()); + preparedStatement.execute(); + }, callback); + } + + public void getUser(UUID uuid, BiConsumer callback) { + String query = "SELECT * FROM `users` WHERE `uuid` = ?"; + submitSqlPreparedStatementTask(query, preparedStatement -> { + preparedStatement.setString(1, uuid.toString()); + ResultSet rs = preparedStatement.executeQuery(); + return rs.next() + ? new User(uuid.toString(), rs.getString("email")) + : null; + }, callback); + } +} +``` +
+ #### Task submission without callbacks (new in 3.1.0) -You can submit tasks which don't require a callback method and which return a -`CompletionStage`. The result of the `CompletionStage` is the result of the +You can submit tasks which don't require a callback method and which instead return +a `CompletionStage`. The result of the `CompletionStage` is the result of the function that is given when the task is submitted. +
+ Usage example + +```java +public static final class UserRepo extends PluginSqlTaskSubmitter { + public UserRepo(JavaPlugin plugin) { super(plugin); } + + public CompletionStage> getUsers() { + String sql = "SELECT * FROM `users`"; + return submitSqlPreparedStatementTask(sql, preparedStatement -> { + List users = new ArrayList<>(); + ResultSet resultSet = preparedStatement.executeQuery(); + while (resultSet.next()) { + String uuid = resultSet.getString("uuid"); + String name = resultSet.getString("name"); + users.add(new User(uuid, name)); + } + return users; + }); + } +} +``` +
+ #### Asynchronous execution of tasks All tasks that are submitted through one of the different `submit...` methods are executed asynchronously in whichever thread the library chooses. After @@ -65,6 +118,29 @@ To use these methods you have to pass a `SqlPoolConfig` instance which can be cr using a `SqlPoolConfig.Builder`. If you want your users to be able to manually configure your pool from a file, you can use a `SqlPoolConfiguration` to store its options. +#### ScriptRunner +A `ScriptRunner` is a utility class that lets you execute SQL scripts. An SQL script is a +file that contains SQL queries delimited by ';' (semicolon). You can create a `ScriptRunner` +by passing an instance of a `Reader` and a `Connection` to its constructor. A `ScriptRunner` +has the ability to replace any part of a query prior to executing it. + +
+ Usage example + +```java +try (Reader reader = new FileReader("my_script.sql"); + Connection connection = getConnection()) { + + ScriptRunner runner = new ScriptRunner(reader, connection); + runner.setReplacements(Map.of("%USER%", "user1")); + runner.runScript(); + +} catch (IOException | SQLException e) { + e.printStackTrace(); +} +``` +
+ ## Examples #### Complete Bukkit plugin example ```java @@ -304,14 +380,14 @@ final class ExampleSubmitter extends PluginSqlTaskSubmitter { de.exlll databaselib-bukkit - 3.1.0 + 3.2.0 de.exlll databaselib-bungee - 3.1.0 + 3.2.0 ``` #### Gradle @@ -323,10 +399,10 @@ repositories { } dependencies { // for Bukkit plugins - compile group: 'de.exlll', name: 'databaselib-bukkit', version: '3.1.0' + compile group: 'de.exlll', name: 'databaselib-bukkit', version: '3.2.0' // for Bungee plugins - compile group: 'de.exlll', name: 'databaselib-bungee', version: '3.1.0' + compile group: 'de.exlll', name: 'databaselib-bungee', version: '3.2.0' } ``` Additionally, you either have to import the Bukkit or BungeeCord API diff --git a/build.gradle b/build.gradle index 7df75dd..8dda3e1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ allprojects { group 'de.exlll' - version '3.1.0' + version '3.2.0' } subprojects { apply plugin: 'java'