diff --git a/src/main/java/world/bentobox/bentobox/Settings.java b/src/main/java/world/bentobox/bentobox/Settings.java index 5992027fa..29b8eb992 100644 --- a/src/main/java/world/bentobox/bentobox/Settings.java +++ b/src/main/java/world/bentobox/bentobox/Settings.java @@ -38,11 +38,11 @@ public class Settings implements ConfigObject { private boolean useEconomy = true; // Database - @ConfigComment("JSON, MYSQL, MARIADB (10.2.3+), MONGODB, SQLITE and YAML(deprecated).") + @ConfigComment("JSON, MYSQL, MARIADB (10.2.3+), MONGODB, SQLITE, POSTGRESQL and YAML(deprecated).") @ConfigComment("Transition database options are:") @ConfigComment(" YAML2JSON, YAML2MARIADB, YAML2MYSQL, YAML2MONGODB, YAML2SQLITE") - @ConfigComment(" JSON2MARIADB, JSON2MYSQL, JSON2MONGODB, JSON2SQLITE") - @ConfigComment(" MYSQL2JSON, MARIADB2JSON, MONGODB2JSON, SQLITE2JSON") + @ConfigComment(" JSON2MARIADB, JSON2MYSQL, JSON2MONGODB, JSON2SQLITE, JSON2POSTGRESQL") + @ConfigComment(" MYSQL2JSON, MARIADB2JSON, MONGODB2JSON, SQLITE2JSON, POSTGRESQL2JSON") @ConfigComment("If you need others, please make a feature request.") @ConfigComment("Transition options enable migration from one database type to another. Use /bbox migrate.") @ConfigComment("YAML and JSON are file-based databases.") diff --git a/src/main/java/world/bentobox/bentobox/database/DatabaseSetup.java b/src/main/java/world/bentobox/bentobox/database/DatabaseSetup.java index 437cfbf1e..1cdc89c56 100644 --- a/src/main/java/world/bentobox/bentobox/database/DatabaseSetup.java +++ b/src/main/java/world/bentobox/bentobox/database/DatabaseSetup.java @@ -5,6 +5,7 @@ import world.bentobox.bentobox.database.mariadb.MariaDBDatabase; import world.bentobox.bentobox.database.mongodb.MongoDBDatabase; import world.bentobox.bentobox.database.mysql.MySQLDatabase; +import world.bentobox.bentobox.database.postgresql.PostgreSQLDatabase; import world.bentobox.bentobox.database.sqlite.SQLiteDatabase; import world.bentobox.bentobox.database.transition.*; import world.bentobox.bentobox.database.yaml.YamlDatabase; @@ -89,6 +90,12 @@ enum DatabaseType { */ JSON2SQLITE(new Json2SQLiteDatabase()), + /** + * Transition database, from JSON to PostgreSQL + * @since 1.6.0 + */ + JSON2POSTGRESQL(new Json2PostgreSQLDatabase()), + MYSQL(new MySQLDatabase()), /** @@ -124,7 +131,18 @@ enum DatabaseType { * Transition database, from SQLite to JSON * @since 1.6.0 */ - SQLITE2JSON(new SQLite2JsonDatabase()); + SQLITE2JSON(new SQLite2JsonDatabase()), + + /** + * @since 1.6.0 + */ + POSTGRESQL(new PostgreSQLDatabase()), + + /** + * Transition database, from PostgreSQL to JSON + * @since 1.6.0 + */ + POSTGRESQL2JSON(new PostgreSQL2JsonDatabase()); DatabaseSetup database; diff --git a/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabase.java b/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabase.java new file mode 100644 index 000000000..cea43dbc7 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabase.java @@ -0,0 +1,25 @@ +package world.bentobox.bentobox.database.postgresql; + +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.DatabaseConnectionSettingsImpl; +import world.bentobox.bentobox.database.DatabaseSetup; + +/** + * @since 1.6.0 + * @author Poslovitch + */ +public class PostgreSQLDatabase implements DatabaseSetup { + + @Override + public AbstractDatabaseHandler getHandler(Class dataObjectClass) { + BentoBox plugin = BentoBox.getInstance(); + return new PostgreSQLDatabaseHandler<>(plugin, dataObjectClass, new PostgreSQLDatabaseConnector(new DatabaseConnectionSettingsImpl( + plugin.getSettings().getDatabaseHost(), + plugin.getSettings().getDatabasePort(), + plugin.getSettings().getDatabaseName(), + plugin.getSettings().getDatabaseUsername(), + plugin.getSettings().getDatabasePassword() + ))); + } +} diff --git a/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabaseConnector.java b/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabaseConnector.java new file mode 100644 index 000000000..943208378 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabaseConnector.java @@ -0,0 +1,72 @@ +package world.bentobox.bentobox.database.postgresql; + +import org.bukkit.Bukkit; +import org.eclipse.jdt.annotation.NonNull; +import world.bentobox.bentobox.database.DatabaseConnectionSettingsImpl; +import world.bentobox.bentobox.database.DatabaseConnector; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * @since 1.6.0 + * @author Poslovitch + */ +public class PostgreSQLDatabaseConnector implements DatabaseConnector { + + private String connectionUrl; + private DatabaseConnectionSettingsImpl dbSettings; + private static Connection connection = null; + + /** + * Class for PostgreSQL database connections using the settings provided + * @param dbSettings - database settings + */ + PostgreSQLDatabaseConnector(@NonNull DatabaseConnectionSettingsImpl dbSettings) { + this.dbSettings = dbSettings; + connectionUrl = "jdbc:postgresql://" + dbSettings.getHost() + ":" + dbSettings.getPort() + "/" + dbSettings.getDatabaseName() + + "?autoReconnect=true&useSSL=false&allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8"; + } + + @Override + public Object createConnection() { + // Only make one connection to the database + if (connection == null) { + try { + connection = DriverManager.getConnection(connectionUrl, dbSettings.getUsername(), dbSettings.getPassword()); + } catch (SQLException e) { + Bukkit.getLogger().severe("Could not connect to the database! " + e.getMessage()); + } + } + return connection; + } + + @Override + public void closeConnection() { + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + Bukkit.getLogger().severe("Could not close PostgreSQL database connection"); + } + } + } + + @Override + public String getConnectionUrl() { + return connectionUrl; + } + + @Override + public @NonNull String getUniqueId(String tableName) { + // Not used + return ""; + } + + @Override + public boolean uniqueIdExists(String tableName, String key) { + // Not used + return false; + } +} diff --git a/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabaseHandler.java b/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabaseHandler.java new file mode 100644 index 000000000..ddcd815e3 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/database/postgresql/PostgreSQLDatabaseHandler.java @@ -0,0 +1,273 @@ +package world.bentobox.bentobox.database.postgresql; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitTask; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import world.bentobox.bentobox.BentoBox; +import world.bentobox.bentobox.database.DatabaseConnector; +import world.bentobox.bentobox.database.json.AbstractJSONDatabaseHandler; +import world.bentobox.bentobox.database.objects.DataObject; + +import java.beans.IntrospectionException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * + * @param + * + * @since 1.6.0 + * @author tastybento, Poslovitch + */ +public class PostgreSQLDatabaseHandler extends AbstractJSONDatabaseHandler { + + private static final String COULD_NOT_LOAD_OBJECTS = "Could not load objects "; + private static final String COULD_NOT_LOAD_OBJECT = "Could not load object "; + + /** + * Connection to the database + */ + private Connection connection; + + /** + * FIFO queue for saves or deletions. Note that the assumption here is that most database objects will be held + * in memory because loading is not handled with this queue. That means that it is theoretically + * possible to load something before it has been saved. So, in general, load your objects and then + * save them async only when you do not need the data again immediately. + */ + private Queue processQueue; + + /** + * Async save task that runs repeatedly + */ + private BukkitTask asyncSaveTask; + + /** + * Constructor + * + * @param plugin + * @param type The type of the objects that should be created and filled with + * values from the database or inserted into the database + * @param databaseConnector Contains the settings to create a connection to the database + */ + protected PostgreSQLDatabaseHandler(BentoBox plugin, Class type, DatabaseConnector databaseConnector) { + super(plugin, type, databaseConnector); + connection = (Connection) databaseConnector.createConnection(); + if (connection == null) { + plugin.logError("Are the settings in config.yml correct?"); + Bukkit.getPluginManager().disablePlugin(plugin); + return; + } + // Check if the table exists in the database and if not, create it + createSchema(); + processQueue = new ConcurrentLinkedQueue<>(); + if (plugin.isEnabled()) { + asyncSaveTask = Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + // Loop continuously + while (plugin.isEnabled() || !processQueue.isEmpty()) { + while (!processQueue.isEmpty()) { + processQueue.poll().run(); + } + // Clear the queue and then sleep + try { + Thread.sleep(25); + } catch (InterruptedException e) { + plugin.logError("Thread sleep error " + e.getMessage()); + Thread.currentThread().interrupt(); + } + } + // Cancel + asyncSaveTask.cancel(); + }); + } + } + + /** + * Creates the table in the database if it doesn't exist already + */ + private void createSchema() { + String sql = "CREATE TABLE IF NOT EXISTS `" + + dataObject.getCanonicalName() + + "` (json JSON, uniqueId VARCHAR(255) GENERATED ALWAYS AS (json->\"$.uniqueId\"), UNIQUE INDEX i (uniqueId) )"; + // Prepare and execute the database statements + try (PreparedStatement pstmt = connection.prepareStatement(sql)) { + pstmt.executeUpdate(); + } catch (SQLException e) { + plugin.logError("Problem trying to create schema for data object " + dataObject.getCanonicalName() + " " + e.getMessage()); + } + } + + @Override + public List loadObjects() throws InstantiationException, IllegalAccessException, InvocationTargetException, ClassNotFoundException, IntrospectionException, NoSuchMethodException { + try (Statement preparedStatement = connection.createStatement()) { + List list = new ArrayList<>(); + + String sb = "SELECT `json` FROM `" + + dataObject.getCanonicalName() + + "`"; + try (ResultSet resultSet = preparedStatement.executeQuery(sb)) { + // Load all the results + Gson gson = getGson(); + while (resultSet.next()) { + String json = resultSet.getString("json"); + if (json != null) { + try { + T gsonResult = gson.fromJson(json, dataObject); + if (gsonResult != null) { + list.add(gsonResult); + } + } catch (JsonSyntaxException ex) { + plugin.logError(COULD_NOT_LOAD_OBJECT + ex.getMessage()); + plugin.logError(json); + } + } + } + } catch (Exception e) { + plugin.logError(COULD_NOT_LOAD_OBJECTS + e.getMessage()); + } + return list; + } catch (SQLException e) { + plugin.logError(COULD_NOT_LOAD_OBJECTS + e.getMessage()); + } + return Collections.emptyList(); + } + + @Nullable + @Override + public T loadObject(@NonNull String uniqueId) throws InstantiationException, IllegalAccessException, InvocationTargetException, ClassNotFoundException, IntrospectionException, NoSuchMethodException { + String sb = "SELECT `json` FROM `" + dataObject.getCanonicalName() + "` WHERE uniqueId = ? LIMIT 1"; + try (PreparedStatement preparedStatement = connection.prepareStatement(sb)) { + // UniqueId needs to be placed in quotes + preparedStatement.setString(1, "\"" + uniqueId + "\""); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (resultSet.next()) { + // If there is a result, we only want/need the first one + Gson gson = getGson(); + return gson.fromJson(resultSet.getString("json"), dataObject); + } + } catch (Exception e) { + plugin.logError(COULD_NOT_LOAD_OBJECT + uniqueId + " " + e.getMessage()); + } + } catch (SQLException e) { + plugin.logError(COULD_NOT_LOAD_OBJECT + uniqueId + " " + e.getMessage()); + } + return null; + } + + @Override + public void saveObject(T instance) throws IllegalAccessException, InvocationTargetException, IntrospectionException { +// Null check + if (instance == null) { + plugin.logError("MySQL database request to store a null. "); + return; + } + if (!(instance instanceof DataObject)) { + plugin.logError("This class is not a DataObject: " + instance.getClass().getName()); + return; + } + String sb = "INSERT INTO " + + "`" + + dataObject.getCanonicalName() + + "` (json) VALUES (?) ON DUPLICATE KEY UPDATE json = ?"; + + Gson gson = getGson(); + String toStore = gson.toJson(instance); + if (plugin.isEnabled()) { + // Async + processQueue.add(() -> store(instance, toStore, sb)); + } else { + // Sync + store(instance, toStore, sb); + } + } + + private void store(T instance, String toStore, String sb) { + try (PreparedStatement preparedStatement = connection.prepareStatement(sb)) { + preparedStatement.setString(1, toStore); + preparedStatement.setString(2, toStore); + preparedStatement.execute(); + } catch (SQLException e) { + plugin.logError("Could not save object " + instance.getClass().getName() + " " + e.getMessage()); + } + } + + @Override + public void deleteObject(T instance) throws IllegalAccessException, InvocationTargetException, IntrospectionException { + // Null check + if (instance == null) { + plugin.logError("MySQL database request to delete a null."); + return; + } + if (!(instance instanceof DataObject)) { + plugin.logError("This class is not a DataObject: " + instance.getClass().getName()); + return; + } + try { + Method getUniqueId = dataObject.getMethod("getUniqueId"); + deleteID((String) getUniqueId.invoke(instance)); + } catch (Exception e) { + plugin.logError("Could not delete object " + instance.getClass().getName() + " " + e.getMessage()); + } + } + + private void delete(String uniqueId) { + String sb = "DELETE FROM `" + + dataObject.getCanonicalName() + + "` WHERE uniqueId = ?"; + try (PreparedStatement preparedStatement = connection.prepareStatement(sb)) { + // UniqueId needs to be placed in quotes + preparedStatement.setString(1, "\"" + uniqueId + "\""); + preparedStatement.execute(); + } catch (Exception e) { + plugin.logError("Could not delete object " + dataObject.getCanonicalName() + " " + uniqueId + " " + e.getMessage()); + } + } + + @Override + public boolean objectExists(String uniqueId) { + // Create the query to see if this key exists + String query = "SELECT IF ( EXISTS( SELECT * FROM `" + + dataObject.getCanonicalName() + + "` WHERE `uniqueId` = ?), 1, 0)"; + + try (PreparedStatement preparedStatement = connection.prepareStatement(query)) { + // UniqueId needs to be placed in quotes + preparedStatement.setString(1, "\"" + uniqueId + "\""); + try (ResultSet resultSet = preparedStatement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getBoolean(1); + } + } + } catch (SQLException e) { + plugin.logError("Could not check if key exists in database! " + uniqueId + " " + e.getMessage()); + } + return false; + } + + @Override + public void close() { + + } + + @Override + public void deleteID(String uniqueId) { + if (plugin.isEnabled()) { + processQueue.add(() -> delete(uniqueId)); + } else { + delete(uniqueId); + } + } +} diff --git a/src/main/java/world/bentobox/bentobox/database/postgresql/package-info.java b/src/main/java/world/bentobox/bentobox/database/postgresql/package-info.java new file mode 100644 index 000000000..ce3ee6315 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/database/postgresql/package-info.java @@ -0,0 +1,5 @@ +/** + * Contains PostgreSQL database managers. + * @since 1.6.0 + */ +package world.bentobox.bentobox.database.postgresql; \ No newline at end of file diff --git a/src/main/java/world/bentobox/bentobox/database/transition/Json2PostgreSQLDatabase.java b/src/main/java/world/bentobox/bentobox/database/transition/Json2PostgreSQLDatabase.java new file mode 100644 index 000000000..13737bbcd --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/database/transition/Json2PostgreSQLDatabase.java @@ -0,0 +1,17 @@ +package world.bentobox.bentobox.database.transition; + +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.bentobox.database.json.JSONDatabase; +import world.bentobox.bentobox.database.postgresql.PostgreSQLDatabase; + +/** + * @author Poslovitch + * @since 1.6.0 + */ +public class Json2PostgreSQLDatabase implements DatabaseSetup { + @Override + public AbstractDatabaseHandler getHandler(Class dataObjectClass) { + return new TransitionDatabaseHandler<>(dataObjectClass, new JSONDatabase().getHandler(dataObjectClass), new PostgreSQLDatabase().getHandler(dataObjectClass)); + } +} diff --git a/src/main/java/world/bentobox/bentobox/database/transition/PostgreSQL2JsonDatabase.java b/src/main/java/world/bentobox/bentobox/database/transition/PostgreSQL2JsonDatabase.java new file mode 100644 index 000000000..a3c20d237 --- /dev/null +++ b/src/main/java/world/bentobox/bentobox/database/transition/PostgreSQL2JsonDatabase.java @@ -0,0 +1,17 @@ +package world.bentobox.bentobox.database.transition; + +import world.bentobox.bentobox.database.AbstractDatabaseHandler; +import world.bentobox.bentobox.database.DatabaseSetup; +import world.bentobox.bentobox.database.json.JSONDatabase; +import world.bentobox.bentobox.database.postgresql.PostgreSQLDatabase; + +/** + * @author Poslovitch + * @since 1.6.0 + */ +public class PostgreSQL2JsonDatabase implements DatabaseSetup { + @Override + public AbstractDatabaseHandler getHandler(Class dataObjectClass) { + return new TransitionDatabaseHandler<>(dataObjectClass, new PostgreSQLDatabase().getHandler(dataObjectClass), new JSONDatabase().getHandler(dataObjectClass)); + } +}