From 91be4f5ef898ef9b8ca21a6b4ed9883d1c5904b5 Mon Sep 17 00:00:00 2001 From: Rubix327 Date: Sun, 27 Aug 2023 22:24:46 +0300 Subject: [PATCH] Database System update --- .../fo/database/SimpleDatabase.java | 989 ------------------ .../fo/database/SimpleDatabaseConnector.java | 375 +++++++ .../fo/database/SimpleDatabaseManager.java | 570 ++++++++++ .../fo/database/SimpleDatabaseObject.java | 62 ++ .../fo/database/SimpleFlatDatabase.java | 71 +- 5 files changed, 1026 insertions(+), 1041 deletions(-) delete mode 100644 src/main/java/org/mineacademy/fo/database/SimpleDatabase.java create mode 100644 src/main/java/org/mineacademy/fo/database/SimpleDatabaseConnector.java create mode 100644 src/main/java/org/mineacademy/fo/database/SimpleDatabaseManager.java create mode 100644 src/main/java/org/mineacademy/fo/database/SimpleDatabaseObject.java diff --git a/src/main/java/org/mineacademy/fo/database/SimpleDatabase.java b/src/main/java/org/mineacademy/fo/database/SimpleDatabase.java deleted file mode 100644 index 7dde763b4..000000000 --- a/src/main/java/org/mineacademy/fo/database/SimpleDatabase.java +++ /dev/null @@ -1,989 +0,0 @@ -package org.mineacademy.fo.database; - -import lombok.*; -import org.mineacademy.fo.*; -import org.mineacademy.fo.SerializeUtil.Mode; -import org.mineacademy.fo.collection.SerializedMap; -import org.mineacademy.fo.collection.StrictMap; -import org.mineacademy.fo.debug.Debugger; -import org.mineacademy.fo.exception.FoException; -import org.mineacademy.fo.model.ConfigSerializable; -import org.mineacademy.fo.remain.Remain; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.sql.*; -import java.util.ArrayList; -import java.util.List; -import java.util.Map.Entry; -import java.util.Set; - -/** - * Represents a simple MySQL database - *

- * Before running queries make sure to call connect() methods. - *

- * You can also override onConnected() to run your code after the - * connection has been established. - *

- * To use this class you must know the MySQL command syntax! - */ -public class SimpleDatabase { - - /** - * The established connection, or null if none - */ - @Getter(value = AccessLevel.PROTECTED) - private volatile Connection connection; - - /** - * Map of variables you can use with the {} syntax in SQL - */ - private final StrictMap sqlVariables = new StrictMap<>(); - - /** - * The raw URL from which the connection was created - */ - private String url; - - /** - * The last credentials from the connect function, or null if never called - */ - private LastCredentials lastCredentials; - - /** - * Private indicator that we are connecting to database right now - */ - private boolean connecting = false; - - /** - * Optional Hikari data source (you plugin needs to include com.zaxxer.HikariCP library in its plugin.yml (MC 1.16+ required) - */ - private Object hikariDataSource; - - // -------------------------------------------------------------------- - // Connecting - // -------------------------------------------------------------------- - - /** - * Attempts to establish a new database connection - * - * @param host - * @param port - * @param database - * @param user - * @param password - */ - public final void connect(final String host, final int port, final String database, final String user, final String password) { - this.connect(host, port, database, user, password, null); - } - - /** - * Attempts to establish a new database connection, - * you can then use {table} in SQL to replace with your table name - * - * @param host - * @param port - * @param database - * @param user - * @param password - * @param table - */ - public final void connect(final String host, final int port, final String database, final String user, final String password, final String table) { - this.connect(host, port, database, user, password, table, true); - } - - /** - * Attempts to establish a new database connection - * you can then use {table} in SQL to replace with your table name - * - * @param host - * @param port - * @param database - * @param user - * @param password - * @param table - * @param autoReconnect - */ - public final void connect(final String host, final int port, final String database, final String user, final String password, final String table, final boolean autoReconnect) { - this.connect("jdbc:mysql://" + host + ":" + port + "/" + database + "?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&autoReconnect=" + autoReconnect, user, password, table); - } - - /** - * Connects to the database. - * - * WARNING: Requires a database type NOT needing a username nor a password! - * - * @param url - */ - public final void connect(final String url) { - this.connect(url, null, null); - } - - /** - * Connects to the database - * - * @param url - * @param user - * @param password - */ - public final void connect(final String url, final String user, final String password) { - this.connect(url, user, password, null); - } - - /** - * Connects to the database - * you can then use {table} in SQL to replace with your table name* - * - * @param url - * @param user - * @param password - * @param table - */ - public final void connect(final String url, final String user, final String password, final String table) { - - this.url = url; - this.connecting = true; - - try { - - // Support local storage of databases on your disk, typically in your plugin's folder - // Make sure to load the library using "libraries" and "legacy-libraries" feature in plugin.yml: - // - // libraries: - // - org.xerial:sqlite-jdbc:3.36.0.3 - // - // legacy-libraries: - // - org.xerial:sqlite-jdbc:3.36.0.3 - // - if (url.startsWith("jdbc:sqlite")) { - Class.forName("org.sqlite.JDBC"); - - this.connection = DriverManager.getConnection(url); - } - - // Avoid using imports so that Foundation users don't have to include Hikari, you can - // optionally load the library using "libraries" and "legacy-libraries" feature in plugin.yml: - // - // libraries: - // - com.zaxxer:HikariCP:5.0.1 - // legacy-libraries: - // - org.slf4j:slf4j-simple:1.7.36 - // - org.slf4j:slf4j-api:1.7.36 - // - com.zaxxer:HikariCP:4.0.3 - // - else if (ReflectionUtil.isClassAvailable("com.zaxxer.hikari.HikariConfig")) { - - final Object hikariConfig = ReflectionUtil.instantiate("com.zaxxer.hikari.HikariConfig"); - - if (url.startsWith("jdbc:mysql://")) - try { - ReflectionUtil.invoke("setDriverClassName", hikariConfig, "com.mysql.cj.jdbc.Driver"); - - } catch (final Throwable t) { - - // Fall back to legacy driver - ReflectionUtil.invoke("setDriverClassName", hikariConfig, "com.mysql.jdbc.Driver"); - } - else if (url.startsWith("jdbc:mariadb://")) - ReflectionUtil.invoke("setDriverClassName", hikariConfig, "org.mariadb.jdbc.Driver"); - - else - throw new FoException("Unknown database driver, expected jdbc:mysql or jdbc:mariadb, got: " + url); - - ReflectionUtil.invoke("setJdbcUrl", hikariConfig, url); - - if (user != null) - ReflectionUtil.invoke("setUsername", hikariConfig, user); - - if (password != null) - ReflectionUtil.invoke("setPassword", hikariConfig, password); - - final Constructor dataSourceConst = ReflectionUtil.getConstructor("com.zaxxer.hikari.HikariDataSource", hikariConfig.getClass()); - final Object hikariSource = ReflectionUtil.instantiate(dataSourceConst, hikariConfig); - - this.hikariDataSource = hikariSource; - - final Method getConnection = hikariSource.getClass().getDeclaredMethod("getConnection"); - - try { - this.connection = ReflectionUtil.invoke(getConnection, hikariSource); - - } catch (final Throwable t) { - Common.warning("Could not get HikariCP connection, please report this with the information below to github.com/kangarko/foundation"); - Common.warning("Method: " + getConnection); - Common.warning("Arguments: " + Common.join(getConnection.getParameters())); - - t.printStackTrace(); - } - } - - /* - * Check for JDBC Drivers (MariaDB, MySQL or Legacy MySQL) - */ - else { - if (url.startsWith("jdbc:mariadb://") && ReflectionUtil.isClassAvailable("org.mariadb.jdbc.Driver")) - Class.forName("org.mariadb.jdbc.Driver"); - - else if (url.startsWith("jdbc:mysql://") && ReflectionUtil.isClassAvailable("com.mysql.cj.jdbc.Driver")) - Class.forName("com.mysql.cj.jdbc.Driver"); - - else { - Common.warning("Your database driver is outdated, switching to MySQL legacy JDBC Driver. If you encounter issues, consider updating your database or switching to MariaDB. You can safely ignore this warning"); - - Class.forName("com.mysql.jdbc.Driver"); - } - - this.connection = user != null && password != null ? DriverManager.getConnection(url, user, password) : DriverManager.getConnection(url); - } - - this.lastCredentials = new LastCredentials(url, user, password, table); - this.onConnected(); - - } catch (final Exception ex) { - - if (Common.getOrEmpty(ex.getMessage()).contains("No suitable driver found")) - Common.logFramed(true, - "Failed to look up database driver! If you had database disabled,", - "then enable it and reload - this is expected.", - "", - "You have have access to your server machine, try installing", - "https://mariadb.com/downloads/connectors/connectors-data-access/", - "", - "If this problem persists after a restart, please contact", - "your hosting provider with the error message below."); - else - Common.logFramed(true, - "Failed to connect to database", - "URL: " + url, - "Error: " + ex.getMessage()); - - Remain.sneaky(ex); - - } finally { - this.connecting = false; - } - } - - /** - * Attempts to connect using last known credentials. Fails gracefully if those are not provided - * i.e. connect function was never called - */ - protected final void connectUsingLastCredentials() { - if (this.lastCredentials != null) - this.connect(this.lastCredentials.url, this.lastCredentials.user, this.lastCredentials.password, this.lastCredentials.table); - } - - /** - * Called automatically after the first connection has been established - */ - protected void onConnected() { - } - - // -------------------------------------------------------------------- - // Disconnecting - // -------------------------------------------------------------------- - - /** - * Attempts to close the result set if not - * - * @param resultSet - */ - public final void close(ResultSet resultSet) { - try { - if (!resultSet.isClosed()) - resultSet.close(); - - } catch (final SQLException e) { - Common.error(e, "Error closing database result set!"); - } - } - - /** - * Attempts to close the connection, if not null - */ - public final void close() { - try { - if (this.connection != null) - this.connection.close(); - - if (this.hikariDataSource != null) - ReflectionUtil.invoke("close", this.hikariDataSource); - - } catch (final SQLException e) { - Common.error(e, "Error closing database connection!"); - } - } - - // -------------------------------------------------------------------- - // Querying - // -------------------------------------------------------------------- - - /** - * Creates a database table, to be used in onConnected - * - * @param creator - */ - protected final void createTable(TableCreator creator) { - String columns = ""; - - for (final TableRow column : creator.getColumns()) { - columns += (columns.isEmpty() ? "" : ", ") + "`" + column.getName() + "` " + column.getDataType(); - - if (column.getAutoIncrement() != null && column.getAutoIncrement()) - columns += " NOT NULL AUTO_INCREMENT"; - - else if (column.getNotNull() != null && column.getNotNull()) - columns += " NOT NULL"; - - if (column.getDefaultValue() != null) - columns += " DEFAULT " + column.getDefaultValue(); - } - - if (creator.getPrimaryColumn() != null) - columns += ", PRIMARY KEY (`" + creator.getPrimaryColumn() + "`)"; - - try { - final boolean isSQLite = this.url != null && this.url.startsWith("jdbc:sqlite"); - - this.update("CREATE TABLE IF NOT EXISTS `" + creator.getName() + "` (" + columns + ")" + (isSQLite ? "" : " DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci") + ";"); - - } catch (final Throwable t) { - if (t.toString().contains("Unknown collation")) { - Common.log("You need to update your database driver to support utf8mb4_unicode_520_ci collation. We switched to support unicode using 4 bits length because the previous system only supported 3 bits."); - Common.log("Some characters such as smiley or Chinese are stored in 4 bits so they would crash the 3-bit database leading to more problems. Most hosting providers have now widely adopted the utf8mb4_unicode_520_ci encoding you seem lacking. Disable database connection or update your driver to fix this."); - } - - else - throw t; - } - } - - /** - * Insert the given column-values pairs into the {@link #getTable()} - * - * @param columsAndValues - */ - protected final void insert(@NonNull SerializedMap columsAndValues) { - this.insert("{table}", columsAndValues); - } - - /** - * Insert the given serializable object as its column-value pairs into the given table - * - * @param - * @param table - * @param serializableObject - */ - protected final void insert(String table, @NonNull T serializableObject) { - this.insert(table, serializableObject.serialize()); - } - - /** - * Insert the given column-values pairs into the given table - * - * @param table - * @param columsAndValues - */ - protected final void insert(String table, @NonNull SerializedMap columsAndValues) { - final String columns = Common.join(columsAndValues.keySet()); - final String values = Common.join(columsAndValues.values(), ", ", value -> value == null || value.equals("NULL") ? "NULL" : "'" + value + "'"); - final String duplicateUpdate = Common.join(columsAndValues.entrySet(), ", ", entry -> entry.getKey() + "=VALUES(" + entry.getKey() + ")"); - - this.update("INSERT INTO " + this.replaceVariables(table) + " (" + columns + ") VALUES (" + values + ") ON DUPLICATE KEY UPDATE " + duplicateUpdate + ";"); - } - - /** - * Insert the batch map into {@link #getTable()} - * - * @param maps - */ - protected final void insertBatch(@NonNull List maps) { - this.insertBatch("{table}", maps); - } - - /** - * Insert the batch map into the database - * - * @param table - * @param maps - */ - protected final void insertBatch(String table, @NonNull List maps) { - final List sqls = new ArrayList<>(); - - for (final SerializedMap map : maps) { - final String columns = Common.join(map.keySet()); - final String values = Common.join(map.values(), ", ", this::parseValue); - final String duplicateUpdate = Common.join(map.entrySet(), ", ", entry -> entry.getKey() + "=VALUES(" + entry.getKey() + ")"); - - sqls.add("INSERT INTO " + table + " (" + columns + ") VALUES (" + values + ") ON DUPLICATE KEY UPDATE " + duplicateUpdate + ";"); - } - - this.batchUpdate(sqls); - } - - /* - * A helper method to insert compatible value to db - */ - private final String parseValue(Object value) { - return value == null || value.equals("NULL") ? "NULL" : "'" + SerializeUtil.serialize(Mode.YAML, value).toString() + "'"; - } - - /** - * Attempts to execute a new update query - *

- * Make sure you called connect() first otherwise an error will be thrown - * - * @param sql - */ - protected final void update(String sql) { - - if (!this.connecting) - Valid.checkAsync("Updating database must be done async! Call: " + sql); - - this.checkEstablished(); - - if (!this.isConnected()) - this.connectUsingLastCredentials(); - - sql = this.replaceVariables(sql); - Valid.checkBoolean(!sql.contains("{table}"), "Table not set! Either use connect() method that specifies it or call addVariable(table, 'yourtablename') in your constructor!"); - - Debugger.debug("mysql", "Updating database with: " + sql); - - try (Statement statement = this.connection.createStatement()) { - statement.executeUpdate(sql); - - } catch (final SQLException e) { - this.handleError(e, "Error on updating database with: " + sql); - } - } - - /** - * Lists all rows in the given table with the "*" parameter, listing all rows - * - * @param table - * @param consumer - */ - protected final void selectAll(String table, ResultReader consumer) { - this.select(table, "*", consumer); - } - - /** - * Lists all rows in the given table with the given parameter. - * Do not forget to close the connection when done in your consumer. - * - * @param table - * @param param - * @param consumer - */ - protected final void select(String table, String param, ResultReader consumer) { - if (!this.isLoaded()) - return; - - try (ResultSet resultSet = this.query("SELECT " + param + " FROM " + table)) { - while (resultSet.next()) - try { - consumer.accept(resultSet); - - } catch (final Throwable t) { - Common.log("Error reading a row from table " + table + " with param '" + param + "', aborting..."); - - t.printStackTrace(); - break; - } - - } catch (final Throwable t) { - Common.error(t, "Error selecting rows from table " + table + " with param '" + param + "'"); - } - } - - /** - * Returns the amount of rows from the given table per the key-value conditions. - * - * Example conditions: count("MyTable", "Player", "kangarko, "Status", "PENDING") - * This example will return all rows where column Player is equal to kangarko and Status column equals PENDING. - * - * @param table - * @param array - * @return - */ - protected final int count(String table, Object... array) { - return this.count(table, SerializedMap.ofArray(array)); - } - - /** - * Returns the amount of rows from the given table per the conditions, - * - * Example conditions: SerializedMap.ofArray("Player", "kangarko, "Status", "PENDING") - * This example will return all rows where column Player is equal to kangarko and Status column equals PENDING. - * - * @param table - * @param conditions - * @return - */ - protected final int count(String table, SerializedMap conditions) { - - // Convert conditions into SQL syntax - final Set conditionsList = Common.convertSet(conditions.entrySet(), entry -> entry.getKey() + " = '" + SerializeUtil.serialize(Mode.YAML, entry.getValue()) + "'"); - - // Run the query - final String sql = "SELECT * FROM " + table + (conditionsList.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditionsList)) + ";"; - - try (ResultSet resultSet = this.query(sql)) { - int count = 0; - - while (resultSet.next()) - count++; - - return count; - - } catch (final SQLException ex) { - Common.throwError(ex, - "Unable to count rows!", - "Table: " + this.replaceVariables(table), - "Conditions: " + conditions, - "Query: " + sql); - } - - return 0; - } - - /** - * Attempts to execute a new query - *

- * Make sure you called connect() first otherwise an error will be thrown - * - * @param sql - * @return - */ - protected final ResultSet query(String sql) { - Valid.checkAsync("Sending database query must be called async, command: " + sql); - - this.checkEstablished(); - - if (!this.isConnected()) - this.connectUsingLastCredentials(); - - sql = this.replaceVariables(sql); - - Debugger.debug("mysql", "Querying database with: " + sql); - - try { - final Statement statement = this.connection.createStatement(); - final ResultSet resultSet = statement.executeQuery(sql); - - return resultSet; - - } catch (final SQLException ex) { - if (ex instanceof SQLSyntaxErrorException && ex.getMessage().startsWith("Table") && ex.getMessage().endsWith("doesn't exist")) - return new DummyResultSet(); - - this.handleError(ex, "Error on querying database with: " + sql); - } - - return null; - } - - /** - * Executes a massive batch update - * - * @param sqls - */ - protected final void batchUpdate(@NonNull List sqls) { - if (sqls.isEmpty()) - return; - - this.checkEstablished(); - - if (!this.isConnected()) - this.connectUsingLastCredentials(); - - try (Statement batchStatement = this.getConnection().createStatement(ResultSet.TYPE_SCROLL_SENSITIVE, ResultSet.CONCUR_UPDATABLE)) { - final int processedCount = sqls.size(); - - for (final String sql : sqls) - batchStatement.addBatch(this.replaceVariables(sql)); - - if (processedCount > 10_000) - Common.log("Updating your database (" + processedCount + " entries)... PLEASE BE PATIENT THIS WILL TAKE " - + (processedCount > 50_000 ? "10-20 MINUTES" : "5-10 MINUTES") + " - If server will print a crash report, ignore it, update will proceed."); - - // Prevent automatically sending db instructions - this.getConnection().setAutoCommit(false); - - try { - // Execute - batchStatement.executeBatch(); - - // This will block the thread - this.getConnection().commit(); - - } catch (final Throwable t) { - // Cancel the task but handle the error upstream - throw t; - } - - } catch (final Throwable t) { - final List errorLog = new ArrayList<>(); - - errorLog.add(Common.consoleLine()); - errorLog.add(" [" + TimeUtil.getFormattedDateShort() + "] Failed to save batch sql, please contact the plugin author with this file content: " + t); - errorLog.add(Common.consoleLine()); - - for (final String statement : sqls) - errorLog.add(this.replaceVariables(statement)); - - FileUtil.write("sql-error.log", sqls); - - t.printStackTrace(); - - } finally { - try { - this.getConnection().setAutoCommit(true); - - } catch (final SQLException ex) { - ex.printStackTrace(); - } - } - } - - /** - * Attempts to return a prepared statement - *

- * Make sure you called connect() first otherwise an error will be thrown - * - * @param sql - * @return - * @throws SQLException - */ - protected final java.sql.PreparedStatement prepareStatement(String sql) throws SQLException { - this.checkEstablished(); - - if (!this.isConnected()) - this.connectUsingLastCredentials(); - - sql = this.replaceVariables(sql); - - Debugger.debug("mysql", "Preparing statement: " + sql); - return this.connection.prepareStatement(sql); - } - - /** - * Attempts to return a prepared statement - *

- * Make sure you called connect() first otherwise an error will be thrown - * - * @param sql - * @param type - * @param concurrency - * - * @return - * @throws SQLException - */ - protected final java.sql.PreparedStatement prepareStatement(String sql, int type, int concurrency) throws SQLException { - this.checkEstablished(); - - if (!this.isConnected()) - this.connectUsingLastCredentials(); - - sql = this.replaceVariables(sql); - - Debugger.debug("mysql", "Preparing statement: " + sql); - return this.connection.prepareStatement(sql, type, concurrency); - } - - /** - * Is the connection established, open and valid? - * Performs a blocking ping request to the database - * - * @return whether the connection driver was set - */ - protected final boolean isConnected() { - if (!this.isLoaded()) - return false; - - try { - if (!this.connection.isValid(0)) - return false; - } catch (SQLException | AbstractMethodError err) { - // Pass through silently - } - - try { - return !this.connection.isClosed(); - - } catch (final SQLException ex) { - return false; - } - } - - /* - * Checks if there's a collation-related error and prints warning message for the user to - * update his database. - */ - private void handleError(Throwable t, String fallbackMessage) { - if (t.toString().contains("Unknown collation")) { - Common.log("You need to update your database provider driver. We switched to support unicode using 4 bits length because the previous system only supported 3 bits."); - Common.log("Some characters such as smiley or Chinese are stored in 4 bits so they would crash the 3-bit database leading to more problems. Most hosting providers have now widely adopted the utf8mb4_unicode_520_ci encoding you seem lacking. Disable database connection or update your driver to fix this."); - } - - else if (t.toString().contains("Incorrect string value")) { - Common.log("Attempted to save unicode letters (e.g. coors) to your database with invalid encoding, see https://stackoverflow.com/a/10959780 and adjust it. MariaDB may cause issues, use MySQL 8.0 for best results."); - - t.printStackTrace(); - - } else - Common.throwError(t, fallbackMessage); - } - - // -------------------------------------------------------------------- - // Non-blocking checking - // -------------------------------------------------------------------- - - /** - * Return if the developer called {@link #addVariable(String, String)} early enough - * to be registered - * - * @param key - * @return - */ - final boolean hasVariable(String key) { - return this.sqlVariables.containsKey(key); - } - - /** - * Return the table from last connection, throwing an error if never connected - * - * @return - */ - protected final String getTable() { - this.checkEstablished(); - - return Common.getOrEmpty(this.lastCredentials.table); - } - - /** - * Checks if the connect() function was called - */ - private final void checkEstablished() { - Valid.checkBoolean(this.isLoaded(), "Connection was never established, did you call connect() on " + this + "? Use isLoaded() to check."); - } - - /** - * Return true if the connect function was called so that the driver was loaded - * - * @return - */ - public final boolean isLoaded() { - return this.connection != null; - } - - // -------------------------------------------------------------------- - // Variables - // -------------------------------------------------------------------- - - /** - * Adds a new variable you can then use in your queries. - * The variable name will be added {} brackets automatically. - * - * @param name - * @param value - */ - protected final void addVariable(final String name, final String value) { - this.sqlVariables.put(name, value); - } - - /** - * Replace the {table} and {@link #sqlVariables} in the sql query - * - * @param sql - * @return - */ - protected final String replaceVariables(String sql) { - - for (final Entry entry : this.sqlVariables.entrySet()) - sql = sql.replace("{" + entry.getKey() + "}", entry.getValue()); - - return sql.replace("{table}", this.getTable()); - } - - // -------------------------------------------------------------------- - // Classes - // -------------------------------------------------------------------- - - /** - * Helps to create new database tables preventing SQL syntax errors - */ - @Getter - @RequiredArgsConstructor - protected final static class TableCreator { - - /** - * The table name - */ - private final String name; - - /** - * The table columns - */ - private final List columns = new ArrayList<>(); - - /** - * The primary column - */ - private String primaryColumn; - - /** - * Add a new column of the given name and data type - * - * @param name - * @param dataType - * @return - */ - public TableCreator add(String name, String dataType) { - this.columns.add(TableRow.builder().name(name).dataType(dataType).build()); - - return this; - } - - /** - * Add a new column of the given name and data type that is "NOT NULL" - * - * @param name - * @param dataType - * @return - */ - public TableCreator addNotNull(String name, String dataType) { - this.columns.add(TableRow.builder().name(name).dataType(dataType).notNull(true).build()); - - return this; - } - - /** - * Add a new column of the given name and data type that is "NOT NULL AUTO_INCREMENT" - * - * @param name - * @param dataType - * @return - */ - public TableCreator addAutoIncrement(String name, String dataType) { - this.columns.add(TableRow.builder().name(name).dataType(dataType).autoIncrement(true).build()); - - return this; - } - - /** - * Add a new column of the given name and data type that has a default value - * - * @param name - * @param dataType - * @param def - * @return - */ - public TableCreator addDefault(String name, String dataType, String def) { - this.columns.add(TableRow.builder().name(name).dataType(dataType).defaultValue(def).build()); - - return this; - } - - /** - * Marks which column is the primary key - * - * @param primaryColumn - * @return - */ - public TableCreator setPrimaryColumn(String primaryColumn) { - this.primaryColumn = primaryColumn; - - return this; - } - - /** - * Create a new table - * - * @param name - * @return - */ - public static TableCreator of(String name) { - return new TableCreator(name); - } - } - - /* - * Internal helper to create table rows - */ - @Data - @Builder - private final static class TableRow { - - /** - * The table row name - */ - private final String name; - - /** - * The data type - */ - private final String dataType; - - /** - * Is this row NOT NULL? - */ - private final Boolean notNull; - - /** - * Does this row have a default value? - */ - private final String defaultValue; - - /** - * Is this row NOT NULL AUTO_INCREMENT? - */ - private final Boolean autoIncrement; - } - - /** - * A helper class to read results set. (We cannot use a simple Consumer since it does not - * catch exceptions automatically.) - */ - protected interface ResultReader { - - /** - * Reads and process the given results set, we handle exceptions for you - * - * @param set - * @throws SQLException - */ - void accept(ResultSet set) throws SQLException; - } - - /** - * Stores last known credentials from the connect() functions - */ - @RequiredArgsConstructor - private final class LastCredentials { - - /** - * The connecting URL, for example: - *

- * jdbc:mysql://host:port/database - */ - private final String url; - - /** - * The user name for the database - */ - private final String user; - - /** - * The password for the database - */ - private final String password; - - /** - * The table. Never used in this class, only stored for your convenience - */ - private final String table; - } -} \ No newline at end of file diff --git a/src/main/java/org/mineacademy/fo/database/SimpleDatabaseConnector.java b/src/main/java/org/mineacademy/fo/database/SimpleDatabaseConnector.java new file mode 100644 index 000000000..1c61f8a6f --- /dev/null +++ b/src/main/java/org/mineacademy/fo/database/SimpleDatabaseConnector.java @@ -0,0 +1,375 @@ +package org.mineacademy.fo.database; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.mineacademy.fo.Common; +import org.mineacademy.fo.ReflectionUtil; +import org.mineacademy.fo.Valid; +import org.mineacademy.fo.exception.FoException; +import org.mineacademy.fo.remain.Remain; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a MySQL database connector.
+ * This class does not run any queries. It only connects to the database.
+ * To use this class you must know the MySQL command syntax! + *

+ * To run sql queries please use {@link SimpleDatabaseManager}, {@link SimpleDatabaseObject} or {@link SimpleFlatDatabase} + *

+ * Before running any queries from managers make sure to connect to a database from this connector. + *

+ * An example of connecting to the database and adding a manager may look like this: + *

+ * public class DatabaseConnector extends SimpleDatabaseConnector;
+ * public class PhoneDatabaseManager extends SimpleDatabaseObject;
+ *
+ * DatabaseConnector.getInstance()
+ *       .addManager(PhoneDatabaseManager.getInstance())
+ *       .connect("localhost", 3306, "test", "root", "");
+ * 
+ * + * After the connection is established, the {@link #onConnected()} method is run automatically. + * Then all managers added to the connector automatically run their {@link #onConnected()} methods. + * + * @author kangarko + * @author Rubix327 + * @since 6.2.5.6 + */ +public class SimpleDatabaseConnector { + + private final List managers = new ArrayList<>(); + + /** + * The established connection, or null if none + */ + private volatile Connection connection; + + /** + * The raw URL from which the connection was created + */ + @Getter + private String url; + + /** + * The last credentials from the connect function, or null if never called + */ + private LastCredentials lastCredentials; + + /** + * Private indicator that we are connecting to database right now + */ + private boolean connecting = false; + + /** + * Optional Hikari data source (you plugin needs to include com.zaxxer.HikariCP library in its plugin.yml (MC 1.16+ required) + */ + private Object hikariDataSource; + + // -------------------------------------------------------------------- + // Connecting + // -------------------------------------------------------------------- + + /** + * Attempts to establish a new database connection + */ + public final void connect(final String host, final int port, final String database, final String user, final String password) { + this.connect(host, port, database, user, password, true); + } + + /** + * Attempts to establish a new database connection + */ + public final void connect(final String host, final int port, final String database, final String user, final String password, final boolean autoReconnect) { + this.connect("jdbc:mysql://" + host + ":" + port + "/" + database + "?useSSL=false&useUnicode=yes&characterEncoding=UTF-8&autoReconnect=" + autoReconnect, user, password); + } + + /** + * Connects to the database. + *
+ * WARNING: Requires a database type NOT needing a username nor a password! + */ + public final void connect(final String url) { + this.connect(url, null, null); + } + + /** + * Connects to the database + */ + public final void connect(final String url, final String user, final String password) { + + this.url = url; + this.connecting = true; + + try { + + // Support local storage of databases on your disk, typically in your plugin's folder + // Make sure to load the library using "libraries" and "legacy-libraries" feature in plugin.yml: + // + // libraries: + // - org.xerial:sqlite-jdbc:3.36.0.3 + // + // legacy-libraries: + // - org.xerial:sqlite-jdbc:3.36.0.3 + // + if (url.startsWith("jdbc:sqlite")) { + Class.forName("org.sqlite.JDBC"); + + this.connection = DriverManager.getConnection(url); + } + + // Avoid using imports so that Foundation users don't have to include Hikari, you can + // optionally load the library using "libraries" and "legacy-libraries" feature in plugin.yml: + // + // libraries: + // - com.zaxxer:HikariCP:5.0.1 + // legacy-libraries: + // - org.slf4j:slf4j-simple:1.7.36 + // - org.slf4j:slf4j-api:1.7.36 + // - com.zaxxer:HikariCP:4.0.3 + // + else if (ReflectionUtil.isClassAvailable("com.zaxxer.hikari.HikariConfig")) { + + final Object hikariConfig = ReflectionUtil.instantiate("com.zaxxer.hikari.HikariConfig"); + + if (url.startsWith("jdbc:mysql://")) + try { + ReflectionUtil.invoke("setDriverClassName", hikariConfig, "com.mysql.cj.jdbc.Driver"); + + } catch (final Throwable t) { + + // Fall back to legacy driver + ReflectionUtil.invoke("setDriverClassName", hikariConfig, "com.mysql.jdbc.Driver"); + } + else if (url.startsWith("jdbc:mariadb://")) + ReflectionUtil.invoke("setDriverClassName", hikariConfig, "org.mariadb.jdbc.Driver"); + + else + throw new FoException("Unknown database driver, expected jdbc:mysql or jdbc:mariadb, got: " + url); + + ReflectionUtil.invoke("setJdbcUrl", hikariConfig, url); + + if (user != null) + ReflectionUtil.invoke("setUsername", hikariConfig, user); + + if (password != null) + ReflectionUtil.invoke("setPassword", hikariConfig, password); + + final Constructor dataSourceConst = ReflectionUtil.getConstructor("com.zaxxer.hikari.HikariDataSource", hikariConfig.getClass()); + final Object hikariSource = ReflectionUtil.instantiate(dataSourceConst, hikariConfig); + + this.hikariDataSource = hikariSource; + + final Method getConnection = hikariSource.getClass().getDeclaredMethod("getConnection"); + + try { + this.connection = ReflectionUtil.invoke(getConnection, hikariSource); + + } catch (final Throwable t) { + Common.warning("Could not get HikariCP connection, please report this with the information below to github.com/kangarko/foundation"); + Common.warning("Method: " + getConnection); + Common.warning("Arguments: " + Common.join(getConnection.getParameters())); + + t.printStackTrace(); + } + } + + /* + * Check for JDBC Drivers (MariaDB, MySQL or Legacy MySQL) + */ + else { + if (url.startsWith("jdbc:mariadb://") && ReflectionUtil.isClassAvailable("org.mariadb.jdbc.Driver")) + Class.forName("org.mariadb.jdbc.Driver"); + + else if (url.startsWith("jdbc:mysql://") && ReflectionUtil.isClassAvailable("com.mysql.cj.jdbc.Driver")) + Class.forName("com.mysql.cj.jdbc.Driver"); + + else { + Common.warning("Your database driver is outdated, switching to MySQL legacy JDBC Driver. If you encounter issues, consider updating your database or switching to MariaDB. You can safely ignore this warning"); + + Class.forName("com.mysql.jdbc.Driver"); + } + + this.connection = user != null && password != null ? DriverManager.getConnection(url, user, password) : DriverManager.getConnection(url); + } + + for (SimpleDatabaseManager m : managers){ + m.setConnector(this); + } + + this.lastCredentials = new LastCredentials(url, user, password); + this.onConnected(); + + for (SimpleDatabaseManager m : managers){ + m.onConnected(); + } + + } catch (final Exception ex) { + + if (Common.getOrEmpty(ex.getMessage()).contains("No suitable driver found")) + Common.logFramed(true, + "Failed to look up database driver! If you had database disabled,", + "then enable it and reload - this is expected.", + "", + "You have have access to your server machine, try installing", + "https://mariadb.com/downloads/connectors/connectors-data-access/", + "", + "If this problem persists after a restart, please contact", + "your hosting provider with the error message below."); + else + Common.logFramed(true, + "Failed to connect to database", + "URL: " + url, + "Error: " + ex.getMessage()); + + Remain.sneaky(ex); + + } finally { + this.connecting = false; + } + } + + /** + * Attempts to connect using last known credentials. Fails gracefully if those are not provided + * i.e. connect function was never called + */ + protected final void connectUsingLastCredentials() { + if (this.lastCredentials != null){ + this.connect(this.lastCredentials.url, this.lastCredentials.user, this.lastCredentials.password); + } + } + + /** + * Called automatically after the first connection has been established + */ + protected void onConnected() { + } + + public final SimpleDatabaseConnector addManager(SimpleDatabaseManager manager){ + managers.add(manager); + return this; + } + + // -------------------------------------------------------------------- + // Disconnecting + // -------------------------------------------------------------------- + + /** + * Attempts to close the result set if it is not already closed + */ + public final void close(ResultSet resultSet) { + try { + if (!resultSet.isClosed()) + resultSet.close(); + + } catch (final SQLException e) { + Common.error(e, "Error closing database result set!"); + } + } + + /** + * Attempts to close the connection, if not null + */ + public final void close() { + try { + if (this.connection != null) + this.connection.close(); + + if (this.hikariDataSource != null) + ReflectionUtil.invoke("close", this.hikariDataSource); + + } catch (final SQLException e) { + Common.error(e, "Error closing database connection!"); + } + } + + /** + * Is the connection established, open and valid? + * Performs a blocking ping request to the database + * + * @return whether the connection driver was set + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + protected final boolean isConnected() { + if (!this.isLoaded()) + return false; + + try { + if (!this.connection.isValid(0)) + return false; + } catch (SQLException | AbstractMethodError err) { + // Pass through silently + } + + try { + return !this.connection.isClosed(); + + } catch (final SQLException ex) { + return false; + } + } + + protected final Connection getConnection(){ + return this.connection; + } + + public final boolean isConnecting() { + return connecting; + } + + // -------------------------------------------------------------------- + // Non-blocking checking + // -------------------------------------------------------------------- + + /** + * Checks if the "connect(...)" function was called + */ + public void checkEstablished() { + Valid.checkBoolean(this.isLoaded(), "Connection was never established, did you call connect() on " + this + "? Use isLoaded() to check."); + } + + /** + * Check if the connect function was called so that the driver was loaded + * + * @return true if the driver was loaded + */ + public final boolean isLoaded() { + return this.connection != null; + } + + // -------------------------------------------------------------------- + // Classes + // -------------------------------------------------------------------- + + /** + * Stores last known credentials from the "connect()" methods + */ + @RequiredArgsConstructor + private static final class LastCredentials { + + /** + * The connecting URL, for example: + *

+ * jdbc:mysql://host:port/database + */ + private final String url; + + /** + * The username for the database + */ + private final String user; + + /** + * The password for the database + */ + private final String password; + + } +} \ No newline at end of file diff --git a/src/main/java/org/mineacademy/fo/database/SimpleDatabaseManager.java b/src/main/java/org/mineacademy/fo/database/SimpleDatabaseManager.java new file mode 100644 index 000000000..e36754193 --- /dev/null +++ b/src/main/java/org/mineacademy/fo/database/SimpleDatabaseManager.java @@ -0,0 +1,570 @@ +package org.mineacademy.fo.database; + +import lombok.*; +import org.mineacademy.fo.*; +import org.mineacademy.fo.collection.SerializedMap; +import org.mineacademy.fo.collection.StrictMap; +import org.mineacademy.fo.debug.Debugger; +import org.mineacademy.fo.model.ConfigSerializable; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Represents a simple MySQL database + *

+ * Before running queries make sure to call connect() methods. + *

+ * You can also override onConnected() to run your code after the + * connection has been established. + *

+ * To use this class you must know the MySQL command syntax! + * + * @author kangarko + * @author Rubix327 + * @since 6.2.5.6 + */ +@SuppressWarnings({"unused", "SameParameterValue"}) +public class SimpleDatabaseManager { + + @Getter + private SimpleDatabaseConnector connector; + private Connection connection; + + /** + * Map of variables you can use with the {} syntax in SQL + */ + private final StrictMap sqlVariables = new StrictMap<>(); + + void setConnector(SimpleDatabaseConnector connector){ + this.connector = connector; + this.connection = connector.getConnection(); + } + + protected void onConnected(){}; + + private Connection getConnection(){ + return connector.getConnection(); + } + + private String getUrl(){ + return connector.getUrl(); + } + + // -------------------------------------------------------------------- + // Querying + // -------------------------------------------------------------------- + + /** + * Creates a database table, to be used in onConnected + */ + protected final void createTable(TableCreator creator) { + StringBuilder columns = new StringBuilder(); + + for (final TableRow column : creator.getColumns()) { + columns.append((columns.length() == 0) ? "" : ", ").append("`").append(column.getName()).append("` ").append(column.getDataType()); + + if (column.getAutoIncrement() != null && column.getAutoIncrement()) + columns.append(" NOT NULL AUTO_INCREMENT"); + + else if (column.getNotNull() != null && column.getNotNull()) + columns.append(" NOT NULL"); + + if (column.getDefaultValue() != null) + columns.append(" DEFAULT ").append(column.getDefaultValue()); + } + + if (creator.getPrimaryColumn() != null) + columns.append(", PRIMARY KEY (`").append(creator.getPrimaryColumn()).append("`)"); + + try { + final boolean isSQLite = getUrl() != null && getUrl().startsWith("jdbc:sqlite"); + + this.update("CREATE TABLE IF NOT EXISTS `" + creator.getName() + "` (" + columns + ")" + (isSQLite ? "" : " DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci") + ";"); + + } catch (final Throwable t) { + if (t.toString().contains("Unknown collation")) { + Common.log("You need to update your database driver to support utf8mb4_unicode_520_ci collation. We switched to support unicode using 4 bits length because the previous system only supported 3 bits."); + Common.log("Some characters such as smiley or Chinese are stored in 4 bits so they would crash the 3-bit database leading to more problems. Most hosting providers have now widely adopted the utf8mb4_unicode_520_ci encoding you seem lacking. Disable database connection or update your driver to fix this."); + } + + else + throw t; + } + } + + /** + * Insert the given serializable object as its column-value pairs into the given table + */ + protected final void insert(String table, @NonNull T serializableObject) { + this.insert(table, serializableObject.serialize()); + } + + /** + * Insert the given column-values pairs into the given table + */ + protected final void insert(String table, @NonNull SerializedMap columnsAndValues) { + final String columns = Common.join(columnsAndValues.keySet()); + final String values = Common.join(columnsAndValues.values(), ", ", value -> value == null || value.equals("NULL") ? "NULL" : "'" + value + "'"); + final String duplicateUpdate = Common.join(columnsAndValues.entrySet(), ", ", entry -> entry.getKey() + "=VALUES(" + entry.getKey() + ")"); + + this.update("INSERT INTO " + this.replaceVariables(table) + " (" + columns + ") VALUES (" + values + ") ON DUPLICATE KEY UPDATE " + duplicateUpdate + ";"); + } + + /** + * Insert the batch map into the database + */ + protected final void insertBatch(String table, @NonNull List maps) { + final List sqls = new ArrayList<>(); + + for (final SerializedMap map : maps) { + final String columns = Common.join(map.keySet()); + final String values = Common.join(map.values(), ", ", this::parseValue); + final String duplicateUpdate = Common.join(map.entrySet(), ", ", entry -> entry.getKey() + "=VALUES(" + entry.getKey() + ")"); + + sqls.add("INSERT INTO " + table + " (" + columns + ") VALUES (" + values + ") ON DUPLICATE KEY UPDATE " + duplicateUpdate + ";"); + } + + this.batchUpdate(sqls); + } + + /* + * A helper method to insert compatible value to db + */ + private String parseValue(Object value) { + return value == null || value.equals("NULL") ? "NULL" : "'" + SerializeUtil.serialize(SerializeUtil.Mode.YAML, value).toString() + "'"; + } + + /** + * Attempts to execute a new update query + *

+ * Make sure you called connect() first otherwise an error will be thrown + */ + protected final void update(String sql) { + if (!this.connector.isConnecting()){ + Valid.checkAsync("Updating database must be done async! Call: " + sql); + } + + this.connector.checkEstablished(); + + if (!this.connector.isConnected()){ + this.connector.connectUsingLastCredentials(); + } + + sql = this.replaceVariables(sql); + Debugger.debug("mysql", "Updating database with: " + sql); + + try (Statement statement = this.connection.createStatement()) { + statement.executeUpdate(sql); + + } catch (final SQLException e) { + this.handleError(e, "Error on updating database with: " + sql); + } + } + + /** + * Lists all rows in the given table with the "*" parameter, listing all rows + */ + protected final void selectAll(String table, ResultReader consumer) { + this.select(table, "*", consumer); + } + + /** + * Lists all rows in the given table with the given parameter. + * Do not forget to close the connection when done in your consumer. + */ + protected final void select(String table, String param, ResultReader consumer) { + if (!this.connector.isLoaded()){ + return; + } + + try (ResultSet resultSet = this.query("SELECT " + param + " FROM " + table)) { + while (resultSet.next()){ + try { + consumer.accept(resultSet); + + } catch (final Throwable t) { + Common.log("Error reading a row from table " + table + " with param '" + param + "', aborting..."); + + t.printStackTrace(); + break; + } + } + + } catch (final Throwable t) { + Common.error(t, "Error selecting rows from table " + table + " with param '" + param + "'"); + } + } + + /** + * Get the amount of rows from the given table per the key-value conditions. + *

+ * Example conditions: count("MyTable", "Player", "kangarko", "Status", "PENDING") + * This example will return all rows where column Player is equal to kangarko and Status column equals PENDING. + */ + protected final int count(String table, Object... array) { + return this.count(table, SerializedMap.ofArray(array)); + } + + /** + * Get the amount of rows from the given table per the conditions, + *

+ * Example conditions: SerializedMap.ofArray("Player", "kangarko", "Status", "PENDING") + * This example will return all rows where column Player is equal to kangarko and Status column equals PENDING. + */ + protected final int count(String table, SerializedMap conditions) { + + // Convert conditions into SQL syntax + final Set conditionsList = Common.convertSet(conditions.entrySet(), entry -> entry.getKey() + " = '" + SerializeUtil.serialize(SerializeUtil.Mode.YAML, entry.getValue()) + "'"); + + // Run the query + final String sql = "SELECT * FROM " + table + (conditionsList.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditionsList)) + ";"; + + try (ResultSet resultSet = this.query(sql)) { + int count = 0; + + while (resultSet.next()){ + count++; + } + + return count; + } catch (final SQLException ex) { + Common.throwError(ex, + "Unable to count rows!", + "Table: " + this.replaceVariables(table), + "Conditions: " + conditions, + "Query: " + sql); + } + + return 0; + } + + /** + * Attempts to execute a new query + *

+ * Make sure you called connect() first otherwise an error will be thrown + */ + protected final ResultSet query(String sql) { + Valid.checkAsync("Sending database query must be called async, command: " + sql); + + this.connector.checkEstablished(); + + if (!this.connector.isConnected()){ + this.connector.connectUsingLastCredentials(); + } + + sql = this.replaceVariables(sql); + + Debugger.debug("mysql", "Querying database with: " + sql); + + try { + final Statement statement = this.connection.createStatement(); + final ResultSet resultSet = statement.executeQuery(sql); + + return resultSet; + + } catch (final SQLException ex) { + if (ex instanceof SQLSyntaxErrorException && ex.getMessage().startsWith("Table") && ex.getMessage().endsWith("doesn't exist")) + return new DummyResultSet(); + + this.handleError(ex, "Error on querying database with: " + sql); + } + + return null; + } + + /** + * Executes a massive batch update + * + * @param sqls + */ + protected final void batchUpdate(@NonNull List sqls) { + if (sqls.isEmpty()) + return; + + this.connector.checkEstablished(); + + if (!this.connector.isConnected()){ + this.connector.connectUsingLastCredentials(); + } + + try (Statement batchStatement = this.getConnection().createStatement(ResultSet.TYPE_SCROLL_SENSITIVE, ResultSet.CONCUR_UPDATABLE)) { + final int processedCount = sqls.size(); + + for (final String sql : sqls) + batchStatement.addBatch(this.replaceVariables(sql)); + + if (processedCount > 10_000) + Common.log("Updating your database (" + processedCount + " entries)... PLEASE BE PATIENT THIS WILL TAKE " + + (processedCount > 50_000 ? "10-20 MINUTES" : "5-10 MINUTES") + " - If server will print a crash report, ignore it, update will proceed."); + + // Prevent automatically sending db instructions + this.getConnection().setAutoCommit(false); + + try { + // Execute + batchStatement.executeBatch(); + + // This will block the thread + this.getConnection().commit(); + + } catch (final Throwable t) { + // Cancel the task but handle the error upstream + throw t; + } + + } catch (final Throwable t) { + final List errorLog = new ArrayList<>(); + + errorLog.add(Common.consoleLine()); + errorLog.add(" [" + TimeUtil.getFormattedDateShort() + "] Failed to save batch sql, please contact the plugin author with this file content: " + t); + errorLog.add(Common.consoleLine()); + + for (final String statement : sqls) + errorLog.add(this.replaceVariables(statement)); + + FileUtil.write("sql-error.log", sqls); + + t.printStackTrace(); + + } finally { + try { + this.connection.setAutoCommit(true); + + } catch (final SQLException ex) { + ex.printStackTrace(); + } + } + } + + /** + * Attempts to return a prepared statement + *

+ * Make sure you called connect() first otherwise an error will be thrown + */ + protected final java.sql.PreparedStatement prepareStatement(String sql) throws SQLException { + this.connector.checkEstablished(); + + if (!this.connector.isConnected()){ + this.connector.connectUsingLastCredentials(); + } + + sql = this.replaceVariables(sql); + + Debugger.debug("mysql", "Preparing statement: " + sql); + return this.connection.prepareStatement(sql); + } + + /** + * Attempts to return a prepared statement + *

+ * Make sure you called connect() first otherwise an error will be thrown + */ + protected final java.sql.PreparedStatement prepareStatement(String sql, int type, int concurrency) throws SQLException { + this.connector.checkEstablished(); + + if (!this.connector.isConnected()){ + this.connector.connectUsingLastCredentials(); + } + + sql = this.replaceVariables(sql); + + Debugger.debug("mysql", "Preparing statement: " + sql); + return this.connection.prepareStatement(sql, type, concurrency); + } + + // -------------------------------------------------------------------- + // Variables + // -------------------------------------------------------------------- + + /** + * Check if the developer called {@link #addVariable(String, String)} early enough + * to be registered + * + * @param key the key of the variable + * @return true if called + */ + final boolean hasVariable(String key) { + return this.sqlVariables.containsKey(key); + } + + /** + * Adds a new variable you can then use in your queries. + * The variable name will be added {} brackets automatically. + * + * @param name the name of the variable + * @param value the value + */ + protected final void addVariable(final String name, final String value) { + this.sqlVariables.put(name, value); + } + + /** + * Replace the {@link #sqlVariables} in the sql query + * + * @param sql the query + * @return the variables-replaced query + */ + protected final String replaceVariables(String sql) { + + for (final Map.Entry entry : this.sqlVariables.entrySet()){ + sql = sql.replace("{" + entry.getKey() + "}", entry.getValue()); + } + + return sql; + } + + // -------------------------------------------------------------------- + // Other + // -------------------------------------------------------------------- + + /** + * Check if there's a collation-related error and prints warning message for the user to + * update his database. + */ + private void handleError(Throwable t, String fallbackMessage) { + if (t.toString().contains("Unknown collation")) { + Common.log("You need to update your database provider driver. We switched to support unicode using 4 bits length because the previous system only supported 3 bits."); + Common.log("Some characters such as smiley or Chinese are stored in 4 bits so they would crash the 3-bit database leading to more problems. Most hosting providers have now widely adopted the utf8mb4_unicode_520_ci encoding you seem lacking. Disable database connection or update your driver to fix this."); + } + + else if (t.toString().contains("Incorrect string value")) { + Common.log("Attempted to save unicode letters (e.g. coors) to your database with invalid encoding, see https://stackoverflow.com/a/10959780 and adjust it. MariaDB may cause issues, use MySQL 8.0 for best results."); + + t.printStackTrace(); + + } else { + Common.throwError(t, fallbackMessage); + } + } + + // -------------------------------------------------------------------- + // Classes + // -------------------------------------------------------------------- + + /** + * Helps to create new database tables preventing SQL syntax errors + */ + @Getter + @RequiredArgsConstructor + protected final static class TableCreator { + + /** + * The table name + */ + private final String name; + + /** + * The table columns + */ + private final List columns = new ArrayList<>(); + + /** + * The primary column + */ + private String primaryColumn; + + /** + * Add a new column of the given name and data type + */ + public TableCreator add(String name, String dataType) { + this.columns.add(TableRow.builder().name(name).dataType(dataType).build()); + + return this; + } + + /** + * Add a new column of the given name and data type that is "NOT NULL" + */ + public TableCreator addNotNull(String name, String dataType) { + this.columns.add(TableRow.builder().name(name).dataType(dataType).notNull(true).build()); + + return this; + } + + /** + * Add a new column of the given name and data type that is "NOT NULL AUTO_INCREMENT" + */ + public TableCreator addAutoIncrement(String name, String dataType) { + this.columns.add(TableRow.builder().name(name).dataType(dataType).autoIncrement(true).build()); + + return this; + } + + /** + * Add a new column of the given name and data type that has a default value + */ + public TableCreator addDefault(String name, String dataType, String def) { + this.columns.add(TableRow.builder().name(name).dataType(dataType).defaultValue(def).build()); + + return this; + } + + /** + * Marks which column is the primary key + */ + public TableCreator setPrimaryColumn(String primaryColumn) { + this.primaryColumn = primaryColumn; + + return this; + } + + /** + * Create a new table + */ + public static TableCreator of(String name) { + return new TableCreator(name); + } + } + + /* + * Internal helper to create table rows + */ + @Data + @Builder + private final static class TableRow { + + /** + * The table row name + */ + private final String name; + + /** + * The data type + */ + private final String dataType; + + /** + * Is this row NOT NULL? + */ + private final Boolean notNull; + + /** + * Does this row have a default value? + */ + private final String defaultValue; + + /** + * Is this row NOT NULL AUTO_INCREMENT? + */ + private final Boolean autoIncrement; + } + + /** + * A helper class to read results set. (We cannot use a simple Consumer since it does not + * catch exceptions automatically.) + */ + protected interface ResultReader { + + /** + * Reads and process the given results set, we handle exceptions for you + * + * @param set + * @throws SQLException + */ + void accept(ResultSet set) throws SQLException; + } +} diff --git a/src/main/java/org/mineacademy/fo/database/SimpleDatabaseObject.java b/src/main/java/org/mineacademy/fo/database/SimpleDatabaseObject.java new file mode 100644 index 000000000..5e35da465 --- /dev/null +++ b/src/main/java/org/mineacademy/fo/database/SimpleDatabaseObject.java @@ -0,0 +1,62 @@ +package org.mineacademy.fo.database; + +import lombok.NonNull; +import org.jetbrains.annotations.NotNull; +import org.mineacademy.fo.Common; +import org.mineacademy.fo.SerializeUtil; +import org.mineacademy.fo.collection.SerializedMap; +import org.mineacademy.fo.model.ConfigSerializable; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public abstract class SimpleDatabaseObject extends SimpleDatabaseManager { + + public SimpleDatabaseObject() { + this.addVariable("table", getTableName()); + } + + public abstract String getTableName(); + public abstract Class getObjectClass(); + + public final void insert(@NotNull T object, Runnable onFinish) { + Common.runAsync(() -> { + this.insert(getTableName(), object.serialize()); + if (onFinish != null){ + onFinish.run(); + } + }); + } + + public final void insert(@NonNull SerializedMap columnsAndValues) { + this.insert(getTableName(), columnsAndValues); + } + + public final void insertBatch(@NonNull List maps) { + this.insertBatch(getTableName(), maps); + } + + public void selectAll(Consumer> consumer){ + Common.runAsync(() -> { + List objects = new ArrayList<>(); + this.selectAll(getTableName(), set -> { + T object = SerializeUtil.deserialize(SerializeUtil.Mode.YAML, getObjectClass(), set); + objects.add(object); + }); + consumer.accept(objects); + }); + } + + public final void select(String param, ResultReader consumer) { + this.select(getTableName(), param, consumer); + } + + protected final int count(Object... array) { + return this.count(getTableName(), SerializedMap.ofArray(array)); + } + + protected final int count(SerializedMap conditions) { + return this.count(getTableName(), conditions); + } +} \ No newline at end of file diff --git a/src/main/java/org/mineacademy/fo/database/SimpleFlatDatabase.java b/src/main/java/org/mineacademy/fo/database/SimpleFlatDatabase.java index b71ee17bc..a8fe15355 100644 --- a/src/main/java/org/mineacademy/fo/database/SimpleFlatDatabase.java +++ b/src/main/java/org/mineacademy/fo/database/SimpleFlatDatabase.java @@ -1,12 +1,6 @@ package org.mineacademy.fo.database; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -import javax.annotation.Nullable; - +import lombok.NonNull; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.mineacademy.fo.ChatUtil; @@ -18,7 +12,11 @@ import org.mineacademy.fo.debug.LagCatcher; import org.mineacademy.fo.settings.SimpleSettings; -import lombok.NonNull; +import javax.annotation.Nullable; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; +import java.util.concurrent.TimeUnit; /** * Represents a simple database where values are flattened and stored @@ -26,8 +24,8 @@ *

* The table structure is as follows: *

- * UUID varchar(64) | Name text | Data text | Updated bigint - * ------------------------------------------------------------ + * UUID varchar(64) | Name text | Data text | Updated bigint
+ * ------------------------------------------------------------
* Player's uuid | Last known name | {json data} | Date of last save call *

* We use JSON to flatten those values and provide convenience methods @@ -36,17 +34,17 @@ * Also see getExpirationDays(), by default we remove values not touched * within the last 90 days. *

- * For a less-restricting solution see {@link SimpleDatabase} however you will + * For a less-restricting solution see {@link SimpleDatabaseManager} however you will * need to run own queries and implement own table structure that requires MySQL * command syntax knowledge. * * @param the model you use to load/save entries, such as your player cache */ -public abstract class SimpleFlatDatabase extends SimpleDatabase { +public abstract class SimpleFlatDatabase extends SimpleDatabaseManager { /** * An internal flag to prevent dead lock so that we do not call any - * more queries within the {@link #load(UUID, Object)} or {@link #save(UUID, Object)} methods + * more queries within the {@link #load(UUID, Object)} or {@link #save(Player, Object)} methods */ private boolean isQuerying = false; @@ -93,8 +91,6 @@ private void removeOldEntries() { * for the given amount of days. *

* Default: 90 days - * - * @return */ protected int getExpirationDays() { return 90; @@ -102,9 +98,6 @@ protected int getExpirationDays() { /** * Load the data for the given unique ID and his cache - * - * @param player - * @param cache */ public final void load(final Player player, final T cache) { this.load(player.getUniqueId(), cache, null); @@ -112,9 +105,6 @@ public final void load(final Player player, final T cache) { /** * Load the data for the given unique ID and his cache - * - * @param player - * @param cache * @param runAfterLoad callback synced on the main thread */ public final void load(final Player player, final T cache, @Nullable Runnable runAfterLoad) { @@ -123,9 +113,6 @@ public final void load(final Player player, final T cache, @Nullable Runnable ru /** * Load the data for the given unique ID and his cache - * - * @param uuid - * @param cache */ public final void load(final UUID uuid, final T cache) { this.load(uuid, cache, null); @@ -133,13 +120,10 @@ public final void load(final UUID uuid, final T cache) { /** * Load the data for the given unique ID and his cache async. - * - * @param uuid - * @param cache * @param runAfterLoad callback synced on the main thread */ public final void load(final UUID uuid, final T cache, @Nullable Runnable runAfterLoad) { - if (!this.isLoaded() || this.isQuerying) + if (!this.getConnector().isLoaded() || this.isQuerying) return; LagCatcher.start("mysql"); @@ -202,11 +186,8 @@ public final void load(final UUID uuid, final T cache, @Nullable Runnable runAft /** * Save the data for the given name, unique ID and his cache - *

+ *

* If the onSave returns empty data we delete the row - * - * @param player - * @param cache */ public final void save(final Player player, final T cache) { this.save(player.getName(), player.getUniqueId(), cache); @@ -214,12 +195,8 @@ public final void save(final Player player, final T cache) { /** * Save the data for the given name, unique ID and his cache - *

+ *

* If the onSave returns empty data we delete the row - * - * @param name - * @param uuid - * @param cache */ public final void save(final String name, final UUID uuid, final T cache) { this.save(name, uuid, cache, null); @@ -227,11 +204,8 @@ public final void save(final String name, final UUID uuid, final T cache) { /** * Save the data for the given name, unique ID and his cache - *

+ *

* If the onSave returns empty data we delete the row - * - * @param player - * @param cache * @param runAfterSave sync callback to be run when save is done */ public final void save(final Player player, final T cache, @Nullable final Runnable runAfterSave) { @@ -240,16 +214,12 @@ public final void save(final Player player, final T cache, @Nullable final Runna /** * Save the data for the given name, unique ID and his cache async. - * + *

* If the onSave returns empty data we delete the row - * - * @param name - * @param uuid - * @param cache * @param runAfterSave sync callback to be run when save is done */ public final void save(final String name, final UUID uuid, final T cache, @Nullable final Runnable runAfterSave) { - if (!this.isLoaded() || this.isQuerying) + if (!this.getConnector().isLoaded() || this.isQuerying) return; LagCatcher.start("mysql"); @@ -278,7 +248,7 @@ public final void save(final String name, final UUID uuid, final T cache, @Nulla this.update("INSERT INTO {table}(UUID, Name, Data, Updated) VALUES ('" + uuid + "', '" + name + "', '" + data.toJson() + "', '" + System.currentTimeMillis() + "');"); if (runAfterSave != null) - Common.runLater(() -> runAfterSave.run()); + Common.runLater(runAfterSave); } catch (final Throwable ex) { Common.error(ex, @@ -329,11 +299,8 @@ private boolean isStored(@NonNull final UUID uuid) throws SQLException { /** * Your method to save the data for the given unique ID and his cache - *

+ *

* Return an empty data to delete the row - * - * @param data - * @return */ protected abstract SerializedMap onSave(T data); }