diff --git a/.gitignore b/.gitignore index 247f4d7..eea32d5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ test-deploy-token.txt /test/ assets/resources/database.properties /logs/ +/config/ diff --git a/assets/resources/sql statements.kvp b/assets/resources/sql statements.kvp index 675937a..f58462b 100644 --- a/assets/resources/sql statements.kvp +++ b/assets/resources/sql statements.kvp @@ -1,9 +1,9 @@ ########## GUILD DATA ######### -guild_data_insert = INSERT INTO `WatameBot`.`Guild` (GuildID, GuildProperties) VALUES (?, ?); +guild_data_insert = INSERT INTO `WatameBot`.`Guild` (GuildID, GuildProperties) VALUES (?, '{}'); guild_data_get = SELECT * FROM `WatameBot`.`Guild`; guild_data_get_id = SELECT * FROM `WatameBot`.`Guild` WHERE GuildID = ?; ########## GUILD JSON DATA ######### guild_json_select = SELECT JSON_EXTRACT(GuildProperties, ?) FROM `WatameBot`.`Guild` WHERE GuildID = ?; guild_json_update = UPDATE `WatameBot`.`Guild` SET GuildProperties = JSON_SET(GuildProperties, ?, ?) WHERE GuildID = ?; -guild_json_remove = UPDATE `WatameBot`.`Guild` SET GuildProperties = JSON_REMOVE(GuildProperties, ?); \ No newline at end of file +guild_json_remove = UPDATE `WatameBot`.`Guild` SET GuildProperties = JSON_REMOVE(GuildProperties, ?) WHERE GuildID = ?; \ No newline at end of file diff --git a/pom.xml b/pom.xml index a798830..3b0ecd2 100644 --- a/pom.xml +++ b/pom.xml @@ -3,23 +3,19 @@ net.foxgenesis.watame watamebot 1.0.1 - WatameBot https://foxgenesis.net/watamebot A plugin based Discord bot written in Java. - FoxGenesis https://foxgenesis.net - GNU General Public License v2.0 https://www.gnu.org/licenses/old-licenses/gpl-2.0-standalone.html - FoxGenesis @@ -27,13 +23,11 @@ contact@foxgenesis.net - https://github.com/FoxGenesis/Watamebot/tree/main scm:git:git@github.com;FoxGenesis/Watamebot.git scm:git:ssh://github.com;FoxGenesis/Watamebot.git - src @@ -62,7 +56,6 @@ 17 - org.apache.maven.plugins maven-surefire-plugin @@ -77,64 +70,60 @@ JDA 5.0.0-alpha.18 - org.xerial sqlite-jdbc 3.39.2.1 - org.slf4j slf4j-api 2.0.6 - ch.qos.logback logback-classic 1.4.5 - org.fusesource.jansi jansi 2.4.0 - org.json json 20220924 - com.zaxxer HikariCP 5.0.1 - com.mysql mysql-connector-j 8.0.31 - + + + commons-beanutils + commons-beanutils + 1.9.4 + org.apache.commons commons-text 1.10.0 - org.apache.commons commons-configuration2 2.8.0 - ci-cd1 @@ -163,7 +152,6 @@ - org.apache.maven.plugins maven-javadoc-plugin @@ -202,7 +190,6 @@ - ci-cd2 @@ -232,7 +219,6 @@ - org.apache.maven.plugins maven-javadoc-plugin @@ -271,7 +257,6 @@ - zip diff --git a/src/module-info.java b/src/module-info.java index ee9a6f3..f5dd64f 100644 --- a/src/module-info.java +++ b/src/module-info.java @@ -18,9 +18,11 @@ requires com.zaxxer.hikari; requires ch.qos.logback.classic; requires ch.qos.logback.core; + requires org.apache.commons.configuration2; exports net.foxgenesis.config; exports net.foxgenesis.config.fields; + exports net.foxgenesis.database; exports net.foxgenesis.property; exports net.foxgenesis.log; exports net.foxgenesis.watame.sql; diff --git a/src/net/foxgenesis/.gitignore b/src/net/foxgenesis/.gitignore new file mode 100644 index 0000000..1933786 --- /dev/null +++ b/src/net/foxgenesis/.gitignore @@ -0,0 +1 @@ +/test/ diff --git a/src/net/foxgenesis/config/fields/ConfigField.java b/src/net/foxgenesis/config/fields/ConfigField.java index 74afa45..0e43a59 100644 --- a/src/net/foxgenesis/config/fields/ConfigField.java +++ b/src/net/foxgenesis/config/fields/ConfigField.java @@ -44,7 +44,7 @@ public void set(@Nonnull Guild g, E newState) { protected JSONObjectAdv getDataForGuild(@Nonnull Guild guild) { // FIXME need new way of field creation that doesn't require hard coded database // call - return WatameBot.getInstance().getDatabase().getDataForGuild(guild).getConfig(); + return WatameBot.getInstance().getDataForGuild(guild).getConfig(); } abstract E optFrom(@Nonnull JSONObjectAdv config, @Nonnull Guild guild); diff --git a/src/net/foxgenesis/database/AConnectionProvider.java b/src/net/foxgenesis/database/AConnectionProvider.java new file mode 100644 index 0000000..eb90445 --- /dev/null +++ b/src/net/foxgenesis/database/AConnectionProvider.java @@ -0,0 +1,84 @@ +package net.foxgenesis.database; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Objects; +import java.util.Properties; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.dv8tion.jda.internal.utils.IOUtil; + +public abstract class AConnectionProvider implements AutoCloseable { + + protected final Logger logger; + private final String name; + protected final Properties properties; + + public AConnectionProvider(Properties properties, String name) { + this.name = Objects.requireNonNull(name); + this.logger = LoggerFactory.getLogger(name); + this.properties = Objects.requireNonNull(properties); + + String type = properties.getProperty("databaseType", "mysql"); + properties.remove("databaseType"); + + String ip = properties.getProperty("ip", "localhost"); + properties.remove("ip"); + + String port = properties.getProperty("port", "3306"); + properties.remove("port"); + + String database = properties.getProperty("database", "WatameBot"); + properties.remove("database"); + + properties.put("jdbcUrl", "jdbc:%s://%s:%s/%s".formatted(type, ip, port, database)); + + properties.put("poolName", name); + } + + protected abstract Connection openConnection() throws SQLException; + + protected U openAutoClosedConnection(ConnectionConsumer consumer, Consumer error) { + try (Connection conn = openConnection()) { + return consumer.applyConnection(conn); + } catch (Exception e) { + if (error != null) + error.accept(e); + return null; + } + } + + protected CompletableFuture asyncConnection(Function function) { + CompletableFuture conn = CompletableFuture.supplyAsync(() -> { + try { + return openConnection(); + } catch (SQLException e) { + throw new CompletionException(e); + } + }); + + CompletableFuture copy = conn.copy(); + return conn.thenApplyAsync(function).whenCompleteAsync((result, error) -> { + copy.thenAcceptAsync(IOUtil::silentClose); + if (error != null) + throw new CompletionException(error); + }); + } + + public final String getName() { return name; } + + @Override + public void close() throws Exception { + logger.debug("Shutting down {}", name); + + } + + @FunctionalInterface + public interface ConnectionConsumer { public U applyConnection(Connection connection) throws SQLException; } +} diff --git a/src/net/foxgenesis/database/AbstractDatabase.java b/src/net/foxgenesis/database/AbstractDatabase.java new file mode 100644 index 0000000..b9e4ae0 --- /dev/null +++ b/src/net/foxgenesis/database/AbstractDatabase.java @@ -0,0 +1,191 @@ +package net.foxgenesis.database; + +import java.io.IOException; +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Consumer; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import net.dv8tion.jda.internal.utils.IOUtil; +import net.foxgenesis.config.KVPFile; +import net.foxgenesis.util.ResourceUtils.ModuleResource; + +public abstract class AbstractDatabase implements AutoCloseable { + @Nonnull + private final HashMap statements = new HashMap<>(); + + @Nonnull + private final ModuleResource operationsFile; + + @Nonnull + private final ModuleResource setupFile; + + @Nonnull + private final String name; + + @Nonnull + protected final Logger logger; + + @Nonnull + private AConnectionProvider provider; + + public AbstractDatabase(@Nonnull String name, @Nonnull ModuleResource operationsFile, + @Nonnull ModuleResource setupFile) { + this.name = Objects.requireNonNull(name); + this.operationsFile = Objects.requireNonNull(operationsFile); + this.setupFile = Objects.requireNonNull(setupFile); + + logger = LoggerFactory.getLogger(name); + } + + final void setup(@Nonnull AConnectionProvider provider) throws IOException { + if (this.provider != null) + throw new UnsupportedOperationException("Database is already setup!"); + + Objects.requireNonNull(provider); + logger.debug("Setting up {} with provider {}", name, provider.getName()); + + new KVPFile(operationsFile).forEach((id, raw) -> { + if (!hasStatementID(id)) + this.registerStatement(id, raw); + else + logger.error("Statement id {} is already registered!", id); + }); + + this.provider = provider; + + onReady(); + } + + protected abstract void onReady(); + + protected Connection openUnprotectedConnection() throws SQLException { + return provider.openConnection(); + } + + protected CompletableFuture prepareStatementAsync(String id, Function func) { + if (!hasStatementID(id)) + throw new NoSuchElementException("No statement exists with id " + id); + CompletableFuture future = provider.asyncConnection(conn -> { + try { + return conn.prepareStatement(getRawStatement(id)); + } catch (SQLException e) { + throw new CompletionException(e); + } + }); + + CompletableFuture copy = future.copy(); + + return future.thenApplyAsync(func).whenCompleteAsync((result, error) -> { + copy.thenAccept(IOUtil::silentClose); + }); + } + + protected CompletableFuture prepareCallableAsync(String id, Function func) { + if (!hasStatementID(id)) + throw new NoSuchElementException("No statement exists with id " + id); + CompletableFuture future = provider.asyncConnection(conn -> { + try { + return conn.prepareCall(getRawStatement(id)); + } catch (SQLException e) { + throw new CompletionException(e); + } + }); + + CompletableFuture copy = future.copy(); + + return future.thenApplyAsync(func).whenCompleteAsync((result, error) -> { + copy.thenAccept(IOUtil::silentClose); + }); + } + + protected void prepareStatement(String id, SQLConsumer func, Consumer error) { + if (!hasStatementID(id)) + throw new NoSuchElementException("No statement exists with id " + id); + provider.openAutoClosedConnection(conn -> { + try(PreparedStatement statement = conn.prepareStatement(getRawStatement(id))) { + func.accept(statement); + return null; + } + }, error); + } + + protected void prepareCallable(String id, SQLConsumer func, Consumer error) { + if (!hasStatementID(id)) + throw new NoSuchElementException("No statement exists with id " + id); + provider.openAutoClosedConnection(conn -> { + try(CallableStatement statement = conn.prepareCall(getRawStatement(id))) { + func.accept(statement); + return null; + } + }, error); + } + + protected U mapStatement(String id, SQLFunction func, Consumer error) { + if (!hasStatementID(id)) + throw new NoSuchElementException("No statement exists with id " + id); + return provider.openAutoClosedConnection(conn -> { + try(PreparedStatement statement = conn.prepareStatement(getRawStatement(id))) { + return func.apply(statement); + } + }, error); + } + + protected U mapCallable(String id, SQLFunction func, Consumer error) { + if (!hasStatementID(id)) + throw new NoSuchElementException("No statement exists with id " + id); + return provider.openAutoClosedConnection(conn -> { + try(CallableStatement statement = conn.prepareCall(getRawStatement(id))) { + return func.apply(statement); + } + }, error); + } + + private void registerStatement(@Nonnull String id, @Nonnull String raw) { + Objects.requireNonNull(id); + Objects.requireNonNull(raw); + + if (statements.containsKey(id)) + throw new IllegalArgumentException("id [" + id + "] is already registered!"); + + logger.debug("Adding statement with id {} [{}]", id, raw); + statements.put(id, raw); + } + + protected final boolean hasStatementID(String id) { return statements.containsKey(id); } + + protected final String getRawStatement(String id) { + if (!hasStatementID(id)) + throw new NoSuchElementException("No statement exists with id " + id); + return statements.get(id); + } + + final String[] getSetupLines() throws IOException { return setupFile.readAllLines(); } + + @Nonnull + public final String getName() { return name; } + + public final boolean isReady() { return provider != null; } + + @FunctionalInterface + public interface SQLConsumer{ + void accept(U u) throws SQLException; + } + + @FunctionalInterface + public interface SQLFunction{ + V apply(U u) throws SQLException; + } +} diff --git a/src/net/foxgenesis/database/DatabaseManager.java b/src/net/foxgenesis/database/DatabaseManager.java new file mode 100644 index 0000000..6afa311 --- /dev/null +++ b/src/net/foxgenesis/database/DatabaseManager.java @@ -0,0 +1,133 @@ +package net.foxgenesis.database; + +import java.io.IOException; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import javax.annotation.Nonnull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DatabaseManager implements IDatabaseManager { + @Nonnull + protected final Logger logger; + + @Nonnull + private final Set databases = new HashSet<>(); + + @Nonnull + private final String name; + + private AConnectionProvider provider; + + private boolean ready = false; + + public DatabaseManager(@Nonnull String name) { + this.name = Objects.requireNonNull(name); + logger = LoggerFactory.getLogger(name); + } + + @Override + public boolean register(@Nonnull AbstractDatabase database) throws IOException { + Objects.requireNonNull(database); + + if (databases.contains(database)) + throw new IllegalArgumentException("Database is already registered!"); + + logger.debug("Registering database {}", database.getName()); + + boolean wasAdded = false; + synchronized (databases) { + wasAdded = databases.add(database); + } + + if (wasAdded && ready && provider != null) { + database.setup(provider); + database.onReady(); + } + + return wasAdded; + } + + @Override + public boolean isDatabaseRegistered(AbstractDatabase database) { return databases.contains(database); } + + public CompletableFuture start(@Nonnull AConnectionProvider provider) { + return CompletableFuture.runAsync(() -> { + this.provider = Objects.requireNonNull(provider); + logger.info("Starting {} using provider {}", name, provider.getName()); + + logger.debug("Collecting database setup lines"); + List setupLines = collectDatabaseSetupLines(); + + synchronized (databases) { + provider.openAutoClosedConnection(connection -> { + try (Statement statement = connection.createStatement()) { + for (String line : setupLines) + try { + statement.execute(line); + } catch (SQLException e) { + logger.error("Error executing database setup line [" + line + "]", e); + } + } + return null; + }, error -> logger.error("Error while setting up database tables", error)); + } + }).thenComposeAsync((v) -> { + synchronized (databases) { + return CompletableFuture.allOf(databases.stream().map(database -> CompletableFuture.runAsync(() -> { + try { + database.setup(provider); + } catch (IOException e) { + throw new CompletionException(e); + } + }).exceptionally(e -> { + logger.error("Error while setting up " + database.getName(), e); + return null; + })).toArray(CompletableFuture[]::new)); + } + }).thenRun(() -> ready = true).thenComposeAsync((v) -> { + synchronized (databases) { + return CompletableFuture.allOf(databases.stream().map(database -> CompletableFuture.runAsync(() -> { + database.onReady(); + }).exceptionally(e -> { + logger.error("Error while setting up " + database.getName(), e); + return null; + })).toArray(CompletableFuture[]::new)); + } + }); + } + + @Override + public boolean isReady() { return ready; } + + @Override + public String getName() { return name; } + + @Override + public void close() {} + + private List collectDatabaseSetupLines() { + List lines = new ArrayList<>(); + + synchronized (databases) { + for (AbstractDatabase database : databases) + try { + for (String line : database.getSetupLines()) + lines.add(line); + } catch (IOException e) { + logger.error("Error while reading setup lines from " + database.getName(), e); + } + } + + return lines; + } +} diff --git a/src/net/foxgenesis/database/IDatabaseManager.java b/src/net/foxgenesis/database/IDatabaseManager.java new file mode 100644 index 0000000..27ff8c2 --- /dev/null +++ b/src/net/foxgenesis/database/IDatabaseManager.java @@ -0,0 +1,19 @@ +package net.foxgenesis.database; + +import java.io.IOException; + +public interface IDatabaseManager extends AutoCloseable { + /** + * Check if all guild data has been processed and is ready for use. + * + * @return Returns {@code true} when all data has been loaded from the database + * @see #isConnectionValid() + */ + public boolean isReady(); + + boolean register(AbstractDatabase database) throws IOException; + + boolean isDatabaseRegistered(AbstractDatabase database); + + String getName(); +} diff --git a/src/net/foxgenesis/database/providers/MySQLConnectionProvider.java b/src/net/foxgenesis/database/providers/MySQLConnectionProvider.java new file mode 100644 index 0000000..62be350 --- /dev/null +++ b/src/net/foxgenesis/database/providers/MySQLConnectionProvider.java @@ -0,0 +1,37 @@ +package net.foxgenesis.database.providers; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Properties; + +import javax.sql.DataSource; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import net.foxgenesis.database.AConnectionProvider; + +public class MySQLConnectionProvider extends AConnectionProvider { + + private final DataSource source; + + public MySQLConnectionProvider(Properties properties) { + super(properties, "MySQL Connection Provider"); + + properties.putIfAbsent("dataSource.cachePrepStmts", true); + properties.putIfAbsent("dataSource.prepStmtCacheSize", 250); + properties.putIfAbsent("dataSource.prepStmtCacheSqlLimit", 2048); + properties.putIfAbsent("dataSource.useServerPrepStmts", true); + properties.putIfAbsent("dataSource.useLocalSessionState", true); + properties.putIfAbsent("dataSource.rewriteBatchedStatements", true); + properties.putIfAbsent("dataSource.cacheResultSetMetadata", true); + properties.putIfAbsent("dataSource.cacheServerConfiguration", true); + properties.putIfAbsent("dataSource.elideSetAutoCommits", false); + properties.putIfAbsent("dataSource.maintainTimeStats", true); + + source = new HikariDataSource(new HikariConfig(properties)); + } + + @Override + protected Connection openConnection() throws SQLException { return source.getConnection(); } +} diff --git a/src/net/foxgenesis/util/ResourceUtils.java b/src/net/foxgenesis/util/ResourceUtils.java index b1e1932..3eff219 100644 --- a/src/net/foxgenesis/util/ResourceUtils.java +++ b/src/net/foxgenesis/util/ResourceUtils.java @@ -5,6 +5,10 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; @@ -58,6 +62,18 @@ public static Properties getPropertiesResource(URL path) throws IOException { return properties; } + + public static Properties getProperties(Path path, ModuleResource defaults) throws IOException { + // If file does not exist, create a new one and try to open it again + if(Files.notExists(path, LinkOption.NOFOLLOW_LINKS)) { + defaults.writeToFile(path); + return getProperties(path, defaults); + } + + Properties properties = new Properties(); + properties.load(Files.newInputStream(path, StandardOpenOption.READ)); + return properties; + } public static String toString(@Nonnull InputStream input) throws IOException { Objects.requireNonNull(input, "InputStream must not be null!"); @@ -78,6 +94,10 @@ public InputStream openStream() throws IOException { logger.trace("Attempting to read resource: " + resourcePath); return module.getResourceAsStream(resourcePath); } + + public void writeToFile(Path path) throws IOException { + Files.copy(openStream(), path); + } public String readToString() throws IOException { return ResourceUtils.toString(openStream()); } diff --git a/src/net/foxgenesis/util/function/QuadFunction.java b/src/net/foxgenesis/util/function/QuadFunction.java new file mode 100644 index 0000000..d4044cf --- /dev/null +++ b/src/net/foxgenesis/util/function/QuadFunction.java @@ -0,0 +1,49 @@ +package net.foxgenesis.util.function; + +import java.util.Objects; +import java.util.function.Function; + +/** + * Represents a function that accepts two arguments and produces a result. + * This is the two-arity specialization of {@link Function}. + * + *

This is a functional interface + * whose functional method is {@link #apply(Object, Object)}. + * + * @param the type of the first argument to the function + * @param the type of the second argument to the function + * @param the type of the result of the function + * + * @see Function + * @since 1.8 + */ +@FunctionalInterface +public interface QuadFunction { + + /** + * Applies this function to the given arguments. + * + * @param t the first function argument + * @param u the second function argument + * @return the function result + */ + R apply(T t, U u, V v, W w); + + /** + * Returns a composed function that first applies this function to + * its input, and then applies the {@code after} function to the result. + * If evaluation of either function throws an exception, it is relayed to + * the caller of the composed function. + * + * @param the type of output of the {@code after} function, and of the + * composed function + * @param after the function to apply after this function is applied + * @return a composed function that first applies this function and then + * applies the {@code after} function + * @throws NullPointerException if after is null + */ + default QuadFunction andThen(Function after) { + Objects.requireNonNull(after); + return (T t, U u, V v, W w) -> after.apply(apply(t, u, v, w)); + } +} diff --git a/src/net/foxgenesis/watame/Main.java b/src/net/foxgenesis/watame/Main.java index ce1037c..39c5d96 100644 --- a/src/net/foxgenesis/watame/Main.java +++ b/src/net/foxgenesis/watame/Main.java @@ -22,7 +22,7 @@ public class Main { /** * Global logger */ - public static final Logger logger = LoggerFactory.getLogger(Main.class); + public static final Logger logger = LoggerFactory.getLogger("WatameBot Startup"); /** * Program arguments diff --git a/src/net/foxgenesis/watame/WatameBot.java b/src/net/foxgenesis/watame/WatameBot.java index 9228496..5e7ef89 100644 --- a/src/net/foxgenesis/watame/WatameBot.java +++ b/src/net/foxgenesis/watame/WatameBot.java @@ -4,13 +4,12 @@ import java.io.File; import java.io.FileReader; import java.io.IOException; +import java.nio.file.Path; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; @@ -37,15 +36,20 @@ import net.dv8tion.jda.api.utils.MemberCachePolicy; import net.dv8tion.jda.api.utils.cache.CacheFlag; import net.dv8tion.jda.internal.utils.IOUtil; +import net.foxgenesis.database.DatabaseManager; +import net.foxgenesis.database.IDatabaseManager; +import net.foxgenesis.database.providers.MySQLConnectionProvider; import net.foxgenesis.property.IPropertyProvider; import net.foxgenesis.util.ProgramArguments; +import net.foxgenesis.util.ResourceUtils; +import net.foxgenesis.util.ResourceUtils.ModuleResource; import net.foxgenesis.watame.plugin.IPlugin; import net.foxgenesis.watame.plugin.SeverePluginException; import net.foxgenesis.watame.plugin.UntrustedPluginLoader; import net.foxgenesis.watame.property.GuildPropertyProvider; import net.foxgenesis.watame.property.IGuildPropertyMapping; -import net.foxgenesis.watame.sql.DataManager; -import net.foxgenesis.watame.sql.IDatabaseManager; +import net.foxgenesis.watame.sql.IGuildData; +import net.foxgenesis.watame.sql.WatameBotDatabase; /** * Class containing WatameBot implementation @@ -139,10 +143,12 @@ private static String readToken(String filepath) { */ private JDA discord; + private final DatabaseManager manager; + /** * Database connection handler */ - private final DataManager database; + private final WatameBotDatabase database; /** * Property provider @@ -162,7 +168,7 @@ private static String readToken(String filepath) { /** * List of all plugins */ - private Collection plugins = new ArrayList<>(); + private ConcurrentHashMap plugins = new ConcurrentHashMap<>(); /** * Create a new instance with a specified login {@code token}. @@ -182,8 +188,11 @@ private WatameBot(@Nonnull String token) throws SQLException { // Load plugins loader = new UntrustedPluginLoader<>(IPlugin.class); + // Create our database manager + manager = new DatabaseManager("WatameBot Database Manager"); + // Connect to our database file - database = new DataManager(); + database = new WatameBotDatabase(); // Create connection to discord through our token builder = createJDA(token); @@ -194,7 +203,7 @@ private WatameBot(@Nonnull String token) throws SQLException { void start() { logger.info(state.marker, "Starting..."); - plugins.addAll(loader.getPlugins()); + loader.getPlugins().forEach(plugin -> plugins.put(plugin.getName(), plugin)); logger.debug(state.marker, "Found {} plugins", plugins.size()); preInit(); @@ -214,7 +223,7 @@ private void shutdown() { // Close all plugins logger.debug(state.marker, "Closing all pugins"); - plugins.forEach(plugin -> IOUtil.silentClose(plugin)); + plugins.values().forEach(plugin -> IOUtil.silentClose(plugin)); // Await all futures to complete if (!ForkJoinPool.commonPool().awaitQuiescence(3, TimeUnit.MINUTES)) @@ -252,29 +261,22 @@ private void preInit() { // Pre-initialize all plugins async logger.debug(state.marker, "Calling plugin pre-initialization async"); - CompletableFuture pluginPreInit = CompletableFuture.allOf(List.copyOf(plugins).stream() + CompletableFuture pluginPreInit = CompletableFuture.allOf(plugins.values().stream() .map(plugin -> CompletableFuture.runAsync(plugin::preInit).exceptionallyAsync(error -> { pluginError(plugin, error, state.marker); return null; })).toArray(CompletableFuture[]::new)); - // Setup and connect to the database + // Setup the database try { - logger.debug(state.marker, "Connecting to database"); - database.connect(); - database.retrieveDatabaseData(null); + logger.debug(state.marker, "Adding database to database manager"); + manager.register(database); } catch (IOException e) { // Some error occurred while setting up database ExitCode.DATABASE_SETUP_ERROR.programExit(e); } catch (IllegalArgumentException e) { // Resource was null ExitCode.DATABASE_INVALID_SETUP_FILE.programExit(e); - } catch (UnsupportedOperationException e) { - // Unable to connect to database - ExitCode.DATABASE_NOT_CONNECTED.programExit(e); - } catch (SQLException e) { - // Error while accessing database - ExitCode.DATABASE_ACCESS_ERROR.programExit(e); } /* @@ -303,18 +305,30 @@ private void init() { // Initialize all plugins logger.debug(state.marker, "Calling plugin initialization async"); ProtectedJDABuilder pBuilder = new ProtectedJDABuilder(builder); - CompletableFuture pluginInit = CompletableFuture.allOf(List.copyOf(plugins).stream() + CompletableFuture pluginInit = CompletableFuture.allOf(plugins.values().stream() .map(plugin -> CompletableFuture.runAsync(() -> plugin.init(pBuilder)).exceptionallyAsync(error -> { pluginError(plugin, error, state.marker); return null; })).toArray(CompletableFuture[]::new)); + // Start databases + try { + logger.info(state.marker, "Starting database pool"); + manager.start( + new MySQLConnectionProvider(ResourceUtils.getProperties(Path.of("config", "database.properties"), + new ModuleResource("watamebot", "defaults/database.properties")))).join(); + } catch (IOException e) { + // Some error occurred while setting up database + ExitCode.DATABASE_SETUP_ERROR.programExit(e); + } + /* * ====== END INITIALIZATION ====== */ logger.trace(state.marker, "Waiting for plugin initialization"); pluginInit.join(); + postInit(); } @@ -335,18 +349,18 @@ private void postInit() { // Post-initialize all plugins logger.debug(state.marker, "Calling plugin post-initialization async"); - CompletableFuture pluginPostInit = CompletableFuture.allOf(List.copyOf(plugins).stream() + CompletableFuture pluginPostInit = CompletableFuture.allOf(plugins.values().stream() .map(plugin -> CompletableFuture.runAsync(() -> plugin.postInit(this)).exceptionallyAsync(error -> { pluginError(plugin, error, state.marker); return null; - })).toArray(CompletableFuture[]::new)).thenRunAsync(() -> { - // Register commands - logger.trace(state.marker, "Collecting command data"); - CommandListUpdateAction update = discord.updateCommands(); - List.copyOf(plugins).stream().filter(IPlugin::providesCommands) - .forEach(plugin -> update.addCommands(plugin.getCommands())); - update.queue(); - }); + })).toArray(CompletableFuture[]::new)); + + // Register commands + logger.trace(state.marker, "Collecting command data"); + CommandListUpdateAction update = discord.updateCommands(); + plugins.values().stream().filter(IPlugin::providesCommands) + .forEach(plugin -> update.addCommands(plugin.getCommands())); + update.queue(); /* * ====== END POST-INITIALIZATION ====== @@ -373,7 +387,7 @@ private void ready() { logger.trace("STATE = " + state); logger.debug("Calling plugin on ready async"); - CompletableFuture.allOf(List.copyOf(plugins).stream() + CompletableFuture.allOf(plugins.values().stream() .map(plugin -> CompletableFuture.runAsync(() -> plugin.onReady(this)).exceptionallyAsync(error -> { pluginError(plugin, error, state.marker); return null; @@ -459,7 +473,7 @@ private JDA buildJDA() { private void unloadPlugin(IPlugin plugin) { logger.trace(state.marker, "Unloading {}", plugin.getClass()); IOUtil.silentClose(plugin); - plugins.remove(plugin); + plugins.remove(plugin.getName()); logger.warn(state.marker, plugin.getClass() + " unloaded"); } @@ -504,7 +518,11 @@ private void pluginError(IPlugin plugin, Throwable error, Marker marker) { * * @return */ - public IDatabaseManager getDatabase() { return database; } + public IDatabaseManager getDatabaseManager() { return manager; } + + public IGuildData getDataForGuild(Guild guild) { + return database.getDataForGuild(guild); + } /** * Get the property provider instance. diff --git a/src/net/foxgenesis/watame/command/ConfigCommand.java b/src/net/foxgenesis/watame/command/ConfigCommand.java index e94dfc3..bf1b760 100644 --- a/src/net/foxgenesis/watame/command/ConfigCommand.java +++ b/src/net/foxgenesis/watame/command/ConfigCommand.java @@ -91,6 +91,6 @@ public void onCommandAutoCompleteInteraction(CommandAutoCompleteInteractionEvent } private static JSONObjectAdv getConfig(Guild guild) { - return WatameBot.getInstance().getDatabase().getDataForGuild(guild).getConfig(); + return WatameBot.getInstance().getDataForGuild(guild).getConfig(); } } diff --git a/src/net/foxgenesis/watame/sql/DataManager.java b/src/net/foxgenesis/watame/sql/DataManager.java index 5fd10ac..9defe07 100644 --- a/src/net/foxgenesis/watame/sql/DataManager.java +++ b/src/net/foxgenesis/watame/sql/DataManager.java @@ -92,7 +92,7 @@ public class DataManager implements IDatabaseManager, AutoCloseable { * @throws IllegalArgumentException if folder exists and is not a directory * @see #DataManager(File) */ - public DataManager() { this(new File("repo")); } + private DataManager() { this(new File("repo")); } /** * Create a new instance using the specified folder as the repository folder. @@ -103,7 +103,7 @@ public class DataManager implements IDatabaseManager, AutoCloseable { * @throws NullPointerException if {@code folder} is null * @see #DataManager() */ - public DataManager(@Nonnull File folder) { + private DataManager(@Nonnull File folder) { // Ensure repository folder is created createDatabaseFolder(folder); @@ -145,7 +145,7 @@ public void addGuild(@Nonnull Guild guild) { logger.debug("Loading guild ({})[{}]", guild.getName(), guild.getIdLong()); - this.data.put(guild.getIdLong(), new GuildData(guild, this)); + //this.data.put(guild.getIdLong(), new GuildData(guild, this)); // If initial data retrieval has already been completed, retrieve needed data if (this.allDataReady) @@ -315,7 +315,7 @@ private void insertGuildInDatabase(@Nonnull Guild guild) { @Override public boolean isReady() { return this.allDataReady; } - @Override + public boolean isConnectionValid() { try { // Check if our connection is valid with a one second timeout diff --git a/src/net/foxgenesis/watame/sql/GuildData.java b/src/net/foxgenesis/watame/sql/GuildData.java index df91c07..dd10284 100644 --- a/src/net/foxgenesis/watame/sql/GuildData.java +++ b/src/net/foxgenesis/watame/sql/GuildData.java @@ -1,6 +1,5 @@ package net.foxgenesis.watame.sql; -import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Objects; @@ -13,6 +12,7 @@ import net.dv8tion.jda.api.entities.Channel; import net.dv8tion.jda.api.entities.Guild; import net.foxgenesis.config.fields.JSONObjectAdv; +import net.foxgenesis.util.function.QuadFunction; /** * Class used to contain guild database data. @@ -26,7 +26,7 @@ public class GuildData implements IGuildData, AutoCloseable { * Link to parent data manager */ @Nonnull - private final DataManager dataManager; + private final QuadFunction consumer; /** * {@link Guild} this instance is based on @@ -54,9 +54,9 @@ public class GuildData implements IGuildData, AutoCloseable { * @param guild - The {@link Guild} that this instance represents * @param dataManager - the {@link DataManager} that created this instance */ - GuildData(@Nonnull Guild guild, @Nonnull DataManager dataManager) { - Objects.nonNull(dataManager); - this.dataManager = dataManager; + GuildData(@Nonnull Guild guild, @Nonnull QuadFunction consumer) { + Objects.nonNull(consumer); + this.consumer = consumer; Objects.nonNull(guild); this.guild = guild; @@ -119,62 +119,13 @@ void setData(@Nonnull ResultSet result) throws SQLException { } // Set our current data and pass our update method - this.data = new JSONObjectAdv(jsonString, this::pushJSONUpdate); + this.data = new JSONObjectAdv(jsonString, (key, obj, remove) -> {; + consumer.apply(key, obj, guild, remove); + }); this.setup = true; } - /** - * Method used to either update or remove JSON data in the database. - * - * @param name - JSON name path - * @param data - data to set or {@code null} if removing - * @param remove - should the data at {@code name} be removed or updated - * @throws NullPointerException if {@code name} is {@code null} - * @throws IllegalArgumentException if {@code remove} is {@code true} and - * {@code data} is {@code null} - * @see #setData(ResultSet) - */ - private void pushJSONUpdate(@Nonnull String name, @Nullable Object data, boolean remove) { - Objects.nonNull(name); - - int result; - - if (remove) - try (PreparedStatement removeStatement = dataManager.getAndAssertStatement("guild_json_remove")) { - // Set data and execute update - removeStatement.setString(1, "$." + name); - - DataManager.sqlLogger.debug(DataManager.UPDATE_MARKER, "PushUpdate -> " + removeStatement); - - result = removeStatement.executeUpdate(); - } catch (SQLException e) { - DataManager.logger.error(DataManager.UPDATE_MARKER, "Error while removing guild json data", e); - return; - } - else { - // Ensure we have data passed - if (data == null) - throw new IllegalArgumentException("Data must not be null if 'remove' is 'true'!"); - - try (PreparedStatement updateStatement = dataManager.getAndAssertStatement("guild_json_update")) { - // Set data and execute update - updateStatement.setString(1, "$." + name); - updateStatement.setString(2, data.toString()); - updateStatement.setLong(3, guild.getIdLong()); - - DataManager.sqlLogger.debug(DataManager.UPDATE_MARKER, "PushUpdate -> " + updateStatement); - - result = updateStatement.executeUpdate(); - } catch (SQLException e) { - DataManager.logger.error(DataManager.UPDATE_MARKER, "Error while updating guild json data", e); - return; - } - } - - DataManager.sqlLogger.debug(DataManager.UPDATE_MARKER, "ExecuteUpdate <- " + result); - } - private void checkSetup() { if (!setup) throw new UnsupportedOperationException("GuildData has not been setup yet!"); diff --git a/src/net/foxgenesis/watame/sql/IDatabaseManager.java b/src/net/foxgenesis/watame/sql/IDatabaseManager.java index 0f7c1a7..cbb1e03 100644 --- a/src/net/foxgenesis/watame/sql/IDatabaseManager.java +++ b/src/net/foxgenesis/watame/sql/IDatabaseManager.java @@ -5,13 +5,6 @@ import net.dv8tion.jda.api.entities.Guild; public interface IDatabaseManager { - /** - * Check if the database is connected and is ready for operations. - * - * @return Returns {@code true} if is connected and ready - */ - public boolean isConnectionValid(); - /** * Check if all guild data has been processed and is ready for use. * diff --git a/src/net/foxgenesis/watame/sql/WatameBotDatabase.java b/src/net/foxgenesis/watame/sql/WatameBotDatabase.java new file mode 100644 index 0000000..d18c0fa --- /dev/null +++ b/src/net/foxgenesis/watame/sql/WatameBotDatabase.java @@ -0,0 +1,206 @@ +package net.foxgenesis.watame.sql; + +import java.sql.ResultSet; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.Marker; +import org.slf4j.MarkerFactory; + +import net.dv8tion.jda.api.entities.Guild; +import net.foxgenesis.watame.Constants; + +public class WatameBotDatabase extends net.foxgenesis.database.AbstractDatabase implements IDatabaseManager { + // =============================== STATIC ================================= + static final Logger logger = LoggerFactory.getLogger("Database"); + static final Logger sqlLogger = LoggerFactory.getLogger("SQLInfo"); + + static final Marker SQL_MARKER = MarkerFactory.getMarker("SQL"); + + static final Marker UPDATE_MARKER = MarkerFactory.getMarker("SQL_UPDATE"); + static final Marker QUERY_MARKER = MarkerFactory.getMarker("SQL_QUERY"); + + static { + UPDATE_MARKER.add(SQL_MARKER); + QUERY_MARKER.add(SQL_MARKER); + } + + // =============================== INSTANCE ================================= + + /** + * Map of guild id to guild data object + */ + private final ConcurrentHashMap data = new ConcurrentHashMap<>(); + + public WatameBotDatabase() { + super("WatameBot Database", Constants.DATABASE_OPERATIONS_FILE, Constants.DATABASE_SETUP_FILE); + } + + /** + * Register a guild to be loaded during data retrieval. + * + * @param guild - {@link Guild} to be loaded + * @throws NullPointerException if {@code guild} is {@code null} + * @see #removeGuild(Guild) + */ + public void addGuild(@Nonnull Guild guild) { + Objects.requireNonNull(guild); + + logger.debug("Loading guild ({})[{}]", guild.getName(), guild.getIdLong()); + + this.data.put(guild.getIdLong(), new GuildData(guild, this::pushJSONUpdate)); + + // If initial data retrieval has already been completed, retrieve needed data + retrieveData(guild); + + logger.trace("Guild loaded ({})[{}]", guild.getName(), guild.getIdLong()); + } + + /** + * Remove a guild from the data manager. + * + * @param guild - {@link Guild} to be removed + * @throws NullPointerException if {@code guild} is {@code null} + * @see #addGuild(Guild) + */ + public void removeGuild(@Nonnull Guild guild) { + Objects.requireNonNull(guild); + + logger.debug("Removing guild ({})[{}]", guild.getName(), guild.getIdLong()); + + try { + this.data.remove(guild.getIdLong()).close(); + logger.trace("Guild REMOVED ({})[{}]", guild.getName(), guild.getIdLong()); + } catch (Exception e) { + e.printStackTrace(); + } + } + + /** + * Insert a new row into the database for a {@link Guild}. + * + * @param guild - guild to insert + */ + private void insertGuildInDatabase(@Nonnull Guild guild) { + Objects.requireNonNull(guild); + + this.prepareStatement("guild_data_insert", st -> { + long guildID = guild.getIdLong(); + st.setLong(1, guildID); + + sqlLogger.trace(UPDATE_MARKER, st.toString()); + System.out.println("update: " + st.executeUpdate()); + }, e -> sqlLogger.error(QUERY_MARKER, "Error while inserting new guild", e)); + } + + /** + * Retrieve guild data for a specific guild. + * + * @param guild - {@link Guild} to retrieve data for + */ + private void retrieveData(@Nonnull Guild guild) { + Objects.requireNonNull(guild); + + this.prepareStatement("guild_data_get_id", s -> { + s.setLong(1, guild.getIdLong()); + sqlLogger.trace(QUERY_MARKER, s.toString()); + + try (ResultSet set = s.executeQuery()) { + if (set.next()) { + long id = set.getLong("GuildID"); //$NON-NLS-1$ + if (id != 0 && this.data.containsKey(id)) { + this.data.get(id).setData(set); + return; + } + } + logger.warn("Guild ({})[{}] is missing in database! Attempting to insert and retrieve...", + guild.getName(), guild.getIdLong()); + insertGuildInDatabase(guild); + retrieveData(guild); + } + }, e -> sqlLogger.error(QUERY_MARKER, "Error while reading guild", e)); + } + + @Override + @Nullable + public IGuildData getDataForGuild(@Nonnull Guild guild) { + // Ensure non null guild + Objects.requireNonNull(guild); + + // Check if database processing is finished + if (!isReady()) + throw new UnsupportedOperationException("Data not ready yet"); + + // Check and get guild data + if (!data.containsKey(guild.getIdLong())) { + // Guild data doesn't exist + logger.warn("Attempted to get non existant data for guild {}. Attempting to insert and retrieve data", + guild.getId()); + + insertGuildInDatabase(guild); + retrieveData(guild); + } + + return data.get(guild.getIdLong()); + } + + /** + * Method used to either update or remove JSON data in the database. + * + * @param name - JSON name path + * @param data - data to set or {@code null} if removing + * @param remove - should the data at {@code name} be removed or updated + * @return + * @throws NullPointerException if {@code name} is {@code null} + * @throws IllegalArgumentException if {@code remove} is {@code true} and + * {@code data} is {@code null} + * @see #setData(ResultSet) + */ + private Integer pushJSONUpdate(@Nonnull String name, @Nullable Object data, @Nonnull Guild guild, boolean remove) { + Objects.requireNonNull(name); + Objects.requireNonNull(guild); + + int result; + if (remove) { + result = this.mapStatement("guild_json_remove", removeStatement -> { + // Set data and execute update + removeStatement.setString(1, "$." + name); + removeStatement.setLong(3, guild.getIdLong()); + + sqlLogger.debug(UPDATE_MARKER, "PushUpdate -> " + removeStatement); + + return removeStatement.executeUpdate(); + }, e -> logger.error(UPDATE_MARKER, "Error while removing guild json data", e)); + + } else { + // Ensure we have data passed + if (data == null) + throw new IllegalArgumentException("Data must not be null if 'remove' is 'false'!"); + + result = this.mapStatement("guild_json_update", updateStatement -> { + // Set data and execute update + updateStatement.setString(1, "$." + name); + updateStatement.setString(2, data.toString()); + updateStatement.setLong(3, guild.getIdLong()); + + sqlLogger.debug(UPDATE_MARKER, "PushUpdate -> " + updateStatement); + + return updateStatement.executeUpdate(); + }, e -> logger.error(UPDATE_MARKER, "Error while updating guild json data", e)); + } + sqlLogger.debug(UPDATE_MARKER, "ExecuteUpdate <- " + result); + + return result; + } + + @Override + public void close() throws Exception {} + + @Override + protected void onReady() {} +}