diff --git a/.gitignore b/.gitignore index 9f34a6e0694..8f97c236eb1 100755 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ local.properties .project .classpath -### VS CODE ### +### VS Code ### .vscode/ # External tool builders diff --git a/build.gradle b/build.gradle index 465c05465c8..db9b8795317 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,10 @@ dependencies { exclude group: 'org.bukkit', module: 'bukkit' } + // Libraries that are installed at runtime. See plugin.yml 'libraries' section. + implementation group: 'com.h2database', name: 'h2', version: project.property('h2.version') + implementation group: 'com.zaxxer', name: 'HikariCP', version: project.property('hikaricp.version') + implementation fileTree(dir: 'lib', include: '*.jar') testShadow group: 'junit', name: 'junit', version: '4.13.2' @@ -288,6 +292,15 @@ tasks.register('JUnit') { dependsOn JUnitJava17, JUnitJava21 } +// Generic replace tokens, e.g: '@version@' +tasks.withType(Copy).configureEach { + filter(ReplaceTokens, tokens: [ + 'today' : '' + LocalTime.now(), + 'h2.version' : project.property('h2.version'), + 'hikaricp.version' : project.property('hikaricp.version') + ]) +} + // Build flavor configurations task githubResources(type: ProcessResources) { from 'src/main/resources', { @@ -298,7 +311,6 @@ task githubResources(type: ProcessResources) { channel = 'prerelease' filter ReplaceTokens, tokens: [ 'version' : version, - 'today' : '' + LocalTime.now(), 'release-flavor' : 'skriptlang-github', // SkriptLang build, distributed on Github 'release-channel' : channel, // Release channel, see above 'release-updater' : 'ch.njol.skript.update.GithubChecker', // Github API client @@ -331,7 +343,6 @@ task spigotResources(type: ProcessResources) { channel = 'prerelease' filter ReplaceTokens, tokens: [ 'version' : version, - 'today' : '' + LocalTime.now(), 'release-flavor' : 'skriptlang-spigot', // SkriptLang build, distributed on Spigot resources 'release-channel' : channel, // Release channel, see above 'release-updater' : 'ch.njol.skript.update.GithubChecker', // Github API client @@ -362,7 +373,6 @@ task nightlyResources(type: ProcessResources) { version = project.property('version') + '-nightly-' + hash filter ReplaceTokens, tokens: [ 'version' : version, - 'today' : '' + LocalTime.now(), 'release-flavor' : 'skriptlang-nightly', // SkriptLang build, automatically done by CI 'release-channel' : 'prerelease', // No update checking, but these are VERY unstable 'release-updater' : 'ch.njol.skript.update.NoUpdateChecker', // No auto updates for now diff --git a/gradle.properties b/gradle.properties index bf32198dc53..f260344b4c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,3 +9,6 @@ version=2.12.0-pre2 jarName=Skript.jar testEnv=java21/paper-1.21.7 testEnvJavaVersion=21 + +hikaricp.version=5.1.0 +h2.version=2.3.232 diff --git a/lib/SQLibrary-7.1.jar b/lib/SQLibrary-7.1.jar deleted file mode 100644 index e0507266ce8..00000000000 Binary files a/lib/SQLibrary-7.1.jar and /dev/null differ diff --git a/src/main/java/ch/njol/skript/SkriptAddon.java b/src/main/java/ch/njol/skript/SkriptAddon.java index af381ff3049..ecdbf15febd 100644 --- a/src/main/java/ch/njol/skript/SkriptAddon.java +++ b/src/main/java/ch/njol/skript/SkriptAddon.java @@ -6,13 +6,14 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.Nullable; import ch.njol.skript.util.Utils; import ch.njol.skript.util.Version; +import ch.njol.skript.variables.VariableStorage; +import ch.njol.skript.variables.Variables; + import org.jetbrains.annotations.ApiStatus; import org.skriptlang.skript.localization.Localizer; import org.skriptlang.skript.registration.SyntaxRegistry; @@ -76,6 +77,20 @@ public SkriptAddon loadClasses(String basePackage, String... subPackages) throws return this; } + /** + * Register a VariableStorage class for Skript to create if the user config value matches. + * + * @param A class to extend VariableStorage. + * @param storage The class of the VariableStorage implementation. + * @param names The names used in the config of Skript to select this VariableStorage. + * @return This SkriptAddon for method chaining. + * @throws SkriptAPIException if the operation was not successful because the storage class is already registered. + */ + public SkriptAddon registerStorage(Class storage, String... names) throws SkriptAPIException { + Variables.registerStorage(this, storage, names); + return this; + } + /** * Makes Skript load language files from the specified directory, e.g. "lang" or "skript lang" if you have a lang folder yourself. Localised files will be read from the * plugin's jar and the plugin's data folder, but the default English file is only taken from the jar and must exist! diff --git a/src/main/java/ch/njol/skript/lang/Variable.java b/src/main/java/ch/njol/skript/lang/Variable.java index d626aba031d..20415854cdb 100644 --- a/src/main/java/ch/njol/skript/lang/Variable.java +++ b/src/main/java/ch/njol/skript/lang/Variable.java @@ -1,5 +1,12 @@ package ch.njol.skript.lang; +import java.lang.reflect.Array; +import java.util.*; +import java.util.Map.Entry; +import java.util.regex.Pattern; +import java.util.function.Predicate; +import java.util.function.Function; + import ch.njol.skript.Skript; import ch.njol.skript.SkriptAPIException; import ch.njol.skript.SkriptConfig; @@ -7,6 +14,10 @@ import ch.njol.skript.classes.Changer.ChangeMode; import ch.njol.skript.classes.Changer.ChangerUtils; import ch.njol.skript.classes.ClassInfo; +import ch.njol.skript.variables.VariableStorage; +import org.skriptlang.skript.lang.arithmetic.Arithmetics; +import org.skriptlang.skript.lang.arithmetic.OperationInfo; +import org.skriptlang.skript.lang.arithmetic.Operator; import ch.njol.skript.lang.SkriptParser.ParseResult; import ch.njol.skript.lang.parser.ParserInstance; import ch.njol.skript.lang.util.SimpleExpression; @@ -28,21 +39,12 @@ import org.bukkit.event.Event; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.skriptlang.skript.lang.arithmetic.Arithmetics; -import org.skriptlang.skript.lang.arithmetic.OperationInfo; -import org.skriptlang.skript.lang.arithmetic.Operator; import org.skriptlang.skript.lang.comparator.Comparators; import org.skriptlang.skript.lang.comparator.Relation; import org.skriptlang.skript.lang.converter.Converters; import org.skriptlang.skript.lang.script.Script; import org.skriptlang.skript.lang.script.ScriptWarning; -import java.lang.reflect.Array; -import java.util.*; -import java.util.Map.Entry; -import java.util.function.Function; -import java.util.function.Predicate; - public class Variable implements Expression, KeyReceiverExpression, KeyProviderExpression { private final static String SINGLE_SEPARATOR_CHAR = ":"; diff --git a/src/main/java/ch/njol/skript/registrations/Classes.java b/src/main/java/ch/njol/skript/registrations/Classes.java index 75352ef7bf7..a577d18d8ad 100644 --- a/src/main/java/ch/njol/skript/registrations/Classes.java +++ b/src/main/java/ch/njol/skript/registrations/Classes.java @@ -16,7 +16,7 @@ import ch.njol.skript.log.SkriptLogger; import ch.njol.skript.util.StringMode; import ch.njol.skript.util.Utils; -import ch.njol.skript.variables.SQLStorage; +import ch.njol.skript.variables.JdbcStorage; import ch.njol.skript.variables.SerializedVariable; import ch.njol.skript.variables.Variables; import ch.njol.util.Kleenean; @@ -67,8 +67,8 @@ public static void registerClass(final ClassInfo info) { throw new IllegalArgumentException("Can't register " + info.getC().getName() + " with the code name " + info.getCodeName() + " because that name is already used by " + classInfosByCodeName.get(info.getCodeName())); if (exactClassInfos.containsKey(info.getC())) throw new IllegalArgumentException("Can't register the class info " + info.getCodeName() + " because the class " + info.getC().getName() + " is already registered"); - if (info.getCodeName().length() > SQLStorage.MAX_CLASS_CODENAME_LENGTH) - throw new IllegalArgumentException("The codename '" + info.getCodeName() + "' is too long to be saved in a database, the maximum length allowed is " + SQLStorage.MAX_CLASS_CODENAME_LENGTH); + if (info.getCodeName().length() > JdbcStorage.MAX_CLASS_CODENAME_LENGTH) + throw new IllegalArgumentException("The codename '" + info.getCodeName() + "' is too long to be saved in a database, the maximum length allowed is " + JdbcStorage.MAX_CLASS_CODENAME_LENGTH); exactClassInfos.put(info.getC(), info); classInfosByCodeName.put(info.getCodeName(), info); tempClassInfos.add(info); diff --git a/src/main/java/ch/njol/skript/variables/FlatFileStorage.java b/src/main/java/ch/njol/skript/variables/FlatFileStorage.java index bc76ab838eb..dd5cc75481d 100644 --- a/src/main/java/ch/njol/skript/variables/FlatFileStorage.java +++ b/src/main/java/ch/njol/skript/variables/FlatFileStorage.java @@ -1,6 +1,7 @@ package ch.njol.skript.variables; import ch.njol.skript.Skript; +import ch.njol.skript.SkriptAddon; import ch.njol.skript.config.SectionNode; import ch.njol.skript.lang.Variable; import ch.njol.skript.log.SkriptLogger; @@ -40,7 +41,7 @@ * accessed. (rem: print a warning when Skript starts) * rem: store null variables (in memory) to prevent looking up the same variables over and over again */ -public class FlatFileStorage extends VariablesStorage { +public class FlatFileStorage extends VariableStorage { /** * The {@link Charset} used in the CSV storage file. @@ -108,21 +109,22 @@ public class FlatFileStorage extends VariablesStorage { /** * Create a new CSV storage of the given name. * + * @param source the source of this storage. * @param type the databse type i.e. CSV. */ - FlatFileStorage(String type) { - super(type); + FlatFileStorage(SkriptAddon source, String type) { + super(source, type); } /** * Loads the variables in the CSV file. *

* Doesn't lock the connection, as required by - * {@link Variables#variableLoaded(String, Object, VariablesStorage)}. + * {@link Variables#variableLoaded(String, Object, VariableStorage)}. */ - @SuppressWarnings("deprecation") @Override - protected boolean load_i(SectionNode sectionNode) { + @SuppressWarnings("deprecation") + protected final boolean load(SectionNode sectionNode) { SkriptLogger.setNode(null); if (file == null) { @@ -468,9 +470,9 @@ private void save(PrintWriter pw, String parent, TreeMap map) { if (childNode == null) continue; // Leaf node - if (childNode instanceof TreeMap) { + if (childNode instanceof TreeMap multiVariable) { // TreeMap found, recurse - save(pw, parent + childKey + Variable.SEPARATOR, (TreeMap) childNode); + save(pw, parent + childKey + Variable.SEPARATOR, multiVariable); } else { // Remove variable separator if needed String name = childKey == null ? parent.substring(0, parent.length() - Variable.SEPARATOR.length()) : parent + childKey; @@ -481,7 +483,7 @@ private void save(PrintWriter pw, String parent, TreeMap map) { try { // Loop over storages to make sure this variable is ours to store - for (VariablesStorage storage : Variables.STORAGES) { + for (VariableStorage storage : Variables.STORAGES) { if (storage.accept(name)) { if (storage == this) { // Serialize the value diff --git a/src/main/java/ch/njol/skript/variables/JdbcStorage.java b/src/main/java/ch/njol/skript/variables/JdbcStorage.java new file mode 100644 index 00000000000..bd2813d7153 --- /dev/null +++ b/src/main/java/ch/njol/skript/variables/JdbcStorage.java @@ -0,0 +1,553 @@ +package ch.njol.skript.variables; + +import java.io.File; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.jetbrains.annotations.Nullable; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import ch.njol.skript.Skript; +import ch.njol.skript.SkriptAddon; +import ch.njol.skript.classes.ClassInfo; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.log.SkriptLogger; +import ch.njol.skript.registrations.Classes; +import ch.njol.skript.util.Task; +import ch.njol.skript.util.Timespan; +import ch.njol.skript.util.Timespan.TimePeriod; +import ch.njol.util.SynchronizedReference; + +/** + * A storage for Skript variables that uses a SQL database. + *

+ * This class is abstract and should be extended to implement specific SQL database storage. + * + * The queries will select and delete variables into the database. + */ +public abstract class JdbcStorage extends VariableStorage { + + protected static final String DEFAULT_TABLE_NAME = "variables21"; + + public static final int MAX_VARIABLE_NAME_LENGTH = 380; // MySQL: 767 bytes max; cannot set max bytes, only max characters + public static final int MAX_CLASS_CODENAME_LENGTH = 50; // checked when registering a class + public static final int MAX_VALUE_SIZE = 10000; + + /** + * Params: name, type, value + *

+ * Writes a variable to the database + */ + @Nullable + private PreparedStatement WRITE_QUERY; + + /** + * Params: name + *

+ * Deletes a variable from the database + */ + @Nullable + private PreparedStatement DELETE_QUERY; + + /** + * Params: rowID + *

+ * Selects changed rows. values in order: {@value #SELECT_ORDER} + */ + @Nullable + private PreparedStatement MONITOR_QUERY; + + /** + * Params: rowID + *

+ * Deletes null variables from the database older than the given value + */ + @Nullable + private PreparedStatement MONITOR_CLEAN_UP_QUERY; + + private final String createTableQuery; + private String table; + + private final SynchronizedReference database = new SynchronizedReference<>(); + + private long monitor_interval; + private boolean monitor; + + /** + * Creates a SQLStorage with a create table query. + * + * @param name The name to be sent through this constructor when newInstance creates this class. + * @param createTableQuery The create table query to send to the SQL engine. + */ + public JdbcStorage(SkriptAddon source, String name, String createTableQuery) { + super(source, name); + this.createTableQuery = createTableQuery; + this.table = "variables21"; + } + + public final String getTableName() { + return table; + } + + public final void setTableName(String tableName) { + this.table = tableName; + } + + /** + * Build a HikariConfig from the Skript config.sk SectionNode of this database. + * + * @param config The configuration section from the config.sk that defines this database. + * @return A HikariConfig implementation. Or null if failure. + */ + @Nullable + public abstract HikariConfig configuration(SectionNode config); + + /** + * The prepared statement for replacing with this SQL database. + * Format is string (name), string (type), bytes (value), string (rowid) + * + * @return The string to be placed into a prepared statement for replacing. + */ + protected abstract String getReplaceQuery(); + + public record MonitorQueries(String monitorQuery, String cleanUpQuery) {}; + + /** + * The select statement that will insert 1. the last row ID, and 2. the generated uuid. + * So ensure this statement returns a selection for inputting those two values. + * Must have rowid and update_guid. + * + * The first string will be the monitor select of the rowid and the guid, the second string + * will be the delete monitor query where it deletes that entry. + * + * Return null if monitoring is disabled for this type. + * You only need to be monitoring when the database is external like MySQL. + * + * @return The string to be used for selecting. + */ + @Nullable + protected MonitorQueries getMonitorQueries() { + return null; + }; + + /** + * Must select name, + * + * @return The query that will be used to select the elements. + */ + protected abstract String getSelectQuery(); + + public record JdbcVariableResult(Long rowId, SerializedVariable variable) {}; + + /** + * Construct a VariableResult from the SQL ResultSet based on the extending class getSelectQuery. + * ResultSet is the current iteration and a NonNullPair should be return, long represents the rowid. + * + * Null if exception happened. + * + * @param testOperation if the request is a test operation. + * @return a VariableResult from the SQL ResultSet based on your getSelectQuery. + */ + @Nullable + protected abstract Function<@Nullable ResultSet, JdbcVariableResult> get(boolean testOperation); + + private ResultSet query(HikariDataSource source, String query) throws SQLException { + Statement statement = source.getConnection().createStatement(); + if (statement.execute(query)) { + return statement.getResultSet(); + } else { + int uc = statement.getUpdateCount(); + return source.getConnection().createStatement().executeQuery("SELECT " + uc); + } + } + + private boolean prepareQueries() { + synchronized (database) { + HikariDataSource database = this.database.get(); + assert database != null; + try { + Connection connection = database.getConnection(); + if (WRITE_QUERY != null) { + try { + WRITE_QUERY.close(); + } catch (SQLException e) {} + } + WRITE_QUERY = connection.prepareStatement(getReplaceQuery()); + + if (DELETE_QUERY != null) { + try { + DELETE_QUERY.close(); + } catch (SQLException e) {} + } + DELETE_QUERY = connection.prepareStatement("DELETE FROM " + getTableName() + " WHERE name = ?"); + + if (MONITOR_QUERY != null) { + try { + MONITOR_QUERY.close(); + if (MONITOR_CLEAN_UP_QUERY != null) + MONITOR_CLEAN_UP_QUERY.close(); + } catch (SQLException e) {} + } + + MonitorQueries queries = getMonitorQueries(); + if (queries != null) { + MONITOR_QUERY = connection.prepareStatement(queries.monitorQuery); + MONITOR_CLEAN_UP_QUERY = connection.prepareStatement(queries.cleanUpQuery); + } else { + monitor = false; + } + } catch (SQLException e) { + Skript.exception(e, "Could not prepare queries for the database '" + getDatabaseType() + "': " + e.getLocalizedMessage()); + return false; + } + } + return true; + } + + /** + * Doesn't lock the database for reading (it's not used anywhere else, and locking while loading will interfere with loaded variables being deleted by + * {@link Variables#variableLoaded(String, Object, VariableStorage)}). + */ + @Override + protected final boolean loadAbstract(SectionNode section) { + synchronized (database) { + Timespan monitorInterval = getValue(section, "monitor interval", Timespan.class); + this.monitor = monitorInterval != null; + if (monitor) + this.monitor_interval = monitorInterval.getAs(TimePeriod.MILLISECOND); + + HikariConfig configuration = configuration(section); + if (configuration == null) + return false; + + Timespan commit_changes = getOptional(section, "commit changes", Timespan.class); + if (commit_changes != null) + enablePeriodicalCommits(configuration, commit_changes.getAs(TimePeriod.MILLISECOND)); + + // Max lifetime is 30 minutes, idle lifetime is 10 minutes. This value has to be less than. + configuration.setKeepaliveTime(TimeUnit.MINUTES.toMillis(5)); + + SkriptLogger.setNode(null); + + HikariDataSource db = null; + try { + this.database.set(db = new HikariDataSource(configuration)); + } catch (Exception exception) { // MySQL can throw SQLSyntaxErrorException but not exposed from HikariDataSource. + Skript.error("Cannot connect to the database '" + getUserConfigurationName() + "'! Please make sure that all settings are correct."); + return false; + } + + if (db == null || db.isClosed()) { + Skript.error("Cannot connect to the database '" + getUserConfigurationName() + "'! Please make sure that all settings are correct."); + return false; + } + if (createTableQuery == null || !createTableQuery.contains("%s")) { + Skript.error("Could not create the variables table in the database. The query to create the variables table '" + table + "' in the database '" + getUserConfigurationName() + "' is null."); + return false; + } + + // Create the table. + try { + query(db, String.format(createTableQuery, table)); + } catch (SQLException e) { + Skript.error("Could not create the variables table '" + table + "' in the database '" + getUserConfigurationName() + "': " + e.getLocalizedMessage() + ". " + + "Please create the table yourself using the following query: " + String.format(createTableQuery, table).replace(",", ", ").replaceAll("\\s+", " ")); + return false; + } + + // Build the queries. + if (!prepareQueries()) + return false; + + // First loading. + try { + ResultSet result = query(db, getSelectQuery()); + assert result != null; + try { + loadVariables(result); + } finally { + result.close(); + } + } catch (SQLException e) { + sqlException(e); + return false; + } + return load(section); + } + } + + /** + * Override this method to load a custom configuration reading. + */ + public boolean load(SectionNode n) { + return true; + } + + /** + * Doesn't lock the database + * {@link #save(String, String, byte[])} does that as values are inserted in the database. + */ + private void loadVariables(ResultSet result) throws SQLException { + SQLException e = Task.callSync(new Callable() { + @Override + @Nullable + public SQLException call() throws Exception { + try { + @Nullable Function handle = get(false); + while (result.next()) { + @Nullable JdbcVariableResult variableResult = handle.apply(result); + if (variableResult == null) + continue; + SerializedVariable variable = variableResult.variable(); + lastRowID = variableResult.rowId(); + + if (variable.getValue() == null) { + Variables.variableLoaded(variable.getName(), null, JdbcStorage.this); + } else { + ClassInfo c = Classes.getClassInfoNoError(variable.getType()); + if (c == null || c.getSerializer() == null) { + Skript.error("Cannot load the variable {" + variable.getName() + "} from the database '" + getUserConfigurationName() + "', because the type '" + variable.getType() + "' cannot be recognised or cannot be stored in variables"); + continue; + } + Object object = Classes.deserialize(c, variable.getData()); + if (object == null) { + Skript.error("Cannot load the variable {" + variable.getName() + "} from the database '" + getUserConfigurationName() + "', because it cannot be loaded as " + c.getName().withIndefiniteArticle()); + continue; + } + Variables.variableLoaded(variable.getName(), object, JdbcStorage.this); + } + } + } catch (SQLException e) { + return e; + } + return null; + } + }); + if (e != null) + throw e; + } + + private boolean committing; + + /** + * Start a committing thread. Use this in the {@link #configuration(SectionNode)} method. + * This changes the configuration from auto commiting to periodic commiting. + * + * @param configuration The HikariConfig being represented from the SectionNode. + * @param delay The delay in milliseconds between transactions. + */ + protected void enablePeriodicalCommits(HikariConfig configuration, long delay) { + if (committing) + return; + committing = true; + configuration.setAutoCommit(false); + Skript.newThread(() -> { + long lastCommit = System.currentTimeMillis(); + while (!closed) { + synchronized (database) { + HikariDataSource database = this.database.get(); + try { + if (database != null) + database.getConnection().commit(); + lastCommit = System.currentTimeMillis(); + } catch (SQLException e) { + sqlException(e); + } + } + try { + Thread.sleep(Math.max(0, lastCommit + delay - System.currentTimeMillis())); + } catch (InterruptedException e) {} + } + }, "Skript database '" + getUserConfigurationName() + "' transaction committing thread").start(); + } + + @Override + protected void allLoaded() { + Skript.debug("Database " + getUserConfigurationName() + " loaded. Queue size = " + changesQueue.size()); + if (!monitor) + return; + Skript.newThread(() -> { + try { // variables were just downloaded, no need to check for modifications straight away. + Thread.sleep(monitor_interval); + } catch (final InterruptedException e1) {} + + long lastWarning = Long.MIN_VALUE; + int WARING_INTERVAL = 10; + + // Ignore printing error on first load due to possible large loading times. + boolean ignoreFirstIteration = true; + while (!closed) { + long next = System.currentTimeMillis() + monitor_interval; + checkDatabase(); + long now = System.currentTimeMillis(); + if (next < now && lastWarning + WARING_INTERVAL * 1000 < now) { + if (ignoreFirstIteration) { + ignoreFirstIteration = false; + } else { + Skript.warning("Cannot load variables from the database fast enough (loading took " + ((now - next + monitor_interval) / 1000.) + "s, monitor interval = " + (monitor_interval / 1000.) + "s). " + + "Please increase your monitor interval or reduce usage of variables. " + + "(this warning will be repeated at most once every " + WARING_INTERVAL + " seconds)"); + lastWarning = now; + } + } + while (System.currentTimeMillis() < next) { + try { + Thread.sleep(next - System.currentTimeMillis()); + } catch (final InterruptedException e) {} + } + } + }, "Skript database '" + getUserConfigurationName() + "' monitor thread").start(); + } + + @Override + protected File getFile(String file) { + return new File(file); + } + + @Override + protected boolean connect() { + synchronized (database) { + HikariDataSource database = this.database.get(); + if (database == null || database.isClosed()) { + Skript.exception("Cannot reconnect to the database '" + getUserConfigurationName() + "'!"); + return false; + } + return true; + } + } + + @Override + protected void disconnect() { + synchronized (database) { + HikariDataSource database = this.database.get(); + if (database != null) + database.close(); + } + } + + @Override + public boolean save(String name, @Nullable String type, byte @Nullable [] value) { + synchronized (database) { + if (name.length() > MAX_VARIABLE_NAME_LENGTH) + Skript.error("The name of the variable {" + name + "} is too long to be saved in a database (length: " + name.length() + ", maximum allowed: " + MAX_VARIABLE_NAME_LENGTH + ")! It will be truncated and won't be available under the same name again when loaded."); + if (value != null && value.length > MAX_VALUE_SIZE) + Skript.error("The variable {" + name + "} cannot be saved in the database as its value's size (" + value.length + ") exceeds the maximum allowed size of " + MAX_VALUE_SIZE + "! An attempt to save the variable will be made nonetheless."); + try { + if (type == null) { + assert value == null; + assert DELETE_QUERY != null; + DELETE_QUERY.setString(1, name); + DELETE_QUERY.executeUpdate(); + } else { + int i = 1; + assert WRITE_QUERY != null; + WRITE_QUERY.setString(i++, name); + WRITE_QUERY.setString(i++, type); + WRITE_QUERY.setBytes(i++, value); + WRITE_QUERY.executeUpdate(); + } + } catch (SQLException e) { + sqlException(e); + return false; + } + } + return true; + } + + @Override + public void close() { + synchronized (database) { + super.close(); + HikariDataSource database = this.database.get(); + if (database != null) { + try { + if (!database.isAutoCommit()) + database.getConnection().commit(); + } catch (SQLException e) { + sqlException(e); + } + database.close(); + this.database.set(null); + } + } + } + + private long lastRowID = -1; + + protected void checkDatabase() { + if (!monitor || this.lastRowID == -1) + return; + try { + long lastRowID; // local variable as this is used to clean the database below + ResultSet result = null; + try { + synchronized (database) { + if (closed || database.get() == null) + return; + lastRowID = this.lastRowID; + assert MONITOR_QUERY != null; + MONITOR_QUERY.setLong(1, lastRowID); + MONITOR_QUERY.execute(); + result = MONITOR_QUERY.getResultSet(); + assert result != null; + @Nullable HikariDataSource source = database.get(); + if (source != null && !source.isAutoCommit()) + source.getConnection().commit(); + } + if (!closed) + loadVariables(result); + } finally { + if (result != null) + result.close(); + } + + if (!closed) { // Skript may have been disabled in the meantime // TODO not fixed + new Task(Skript.getInstance(), (long) Math.ceil(2. * monitor_interval / 50) + 100, true) { // 2 times the interval + 5 seconds + @Override + public void run() { + try { + synchronized (database) { + if (closed || database.get() == null) + return; + assert MONITOR_CLEAN_UP_QUERY != null; + MONITOR_CLEAN_UP_QUERY.setLong(1, lastRowID); + MONITOR_CLEAN_UP_QUERY.executeUpdate(); + @Nullable HikariDataSource source = database.get(); + if (source != null && !source.isAutoCommit()) + source.getConnection().commit(); + } + } catch (SQLException e) { + sqlException(e); + } + } + }; + } + } catch (SQLException e) { + sqlException(e); + } + } + + JdbcVariableResult executeTestQuery() throws SQLException { + synchronized (database) { + database.get().getConnection().commit(); + } + ResultSet result = query(database.get(), getSelectQuery()); + return get(true).apply(result); + } + + void sqlException(SQLException exception) { + Skript.error("database error: " + exception.getLocalizedMessage()); + if (Skript.testing()) + exception.printStackTrace(); + prepareQueries(); // a query has to be recreated after an error + } + +} diff --git a/src/main/java/ch/njol/skript/variables/MySQLStorage.java b/src/main/java/ch/njol/skript/variables/MySQLStorage.java deleted file mode 100644 index ff5ac5d39ac..00000000000 --- a/src/main/java/ch/njol/skript/variables/MySQLStorage.java +++ /dev/null @@ -1,38 +0,0 @@ -package ch.njol.skript.variables; - -import ch.njol.skript.config.SectionNode; -import ch.njol.skript.log.SkriptLogger; -import lib.PatPeter.SQLibrary.Database; -import lib.PatPeter.SQLibrary.MySQL; - -public class MySQLStorage extends SQLStorage { - - MySQLStorage(String type) { - super(type, "CREATE TABLE IF NOT EXISTS %s (" + - "rowid BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY," + - "name VARCHAR(" + MAX_VARIABLE_NAME_LENGTH + ") NOT NULL UNIQUE," + - "type VARCHAR(" + MAX_CLASS_CODENAME_LENGTH + ")," + - "value BLOB(" + MAX_VALUE_SIZE + ")," + - "update_guid CHAR(36) NOT NULL" + - ") CHARACTER SET ucs2 COLLATE ucs2_bin"); - } - - @Override - public Database initialize(SectionNode config) { - String host = getValue(config, "host"); - Integer port = getValue(config, "port", Integer.class); - String user = getValue(config, "user"); - String password = getValue(config, "password"); - String database = getValue(config, "database"); - setTableName(config.get("table", "variables21")); - if (host == null || port == null || user == null || password == null || database == null) - return null; - return new MySQL(SkriptLogger.LOGGER, "[Skript]", host, port, database, user, password); - } - - @Override - protected boolean requiresFile() { - return false; - } - -} diff --git a/src/main/java/ch/njol/skript/variables/SQLStorage.java b/src/main/java/ch/njol/skript/variables/SQLStorage.java deleted file mode 100644 index e0ab25c3ee8..00000000000 --- a/src/main/java/ch/njol/skript/variables/SQLStorage.java +++ /dev/null @@ -1,588 +0,0 @@ -package ch.njol.skript.variables; - -import java.io.File; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.UUID; -import java.util.concurrent.Callable; - -import org.bukkit.Bukkit; -import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.Nullable; - -import ch.njol.skript.Skript; -import ch.njol.skript.classes.ClassInfo; -import ch.njol.skript.classes.Serializer; -import ch.njol.skript.config.SectionNode; -import ch.njol.skript.log.SkriptLogger; -import ch.njol.skript.registrations.Classes; -import ch.njol.skript.util.Task; -import ch.njol.skript.util.Timespan; -import ch.njol.util.SynchronizedReference; -import lib.PatPeter.SQLibrary.Database; -import lib.PatPeter.SQLibrary.DatabaseException; -import lib.PatPeter.SQLibrary.SQLibrary; - -/** - * TODO create a metadata table to store some properties (e.g. Skript version, Yggdrasil version) -- but what if some variables cannot be converted? move them to a different table? - * TODO create my own database connector or find a better one - * - * @author Peter Güttinger - */ -public abstract class SQLStorage extends VariablesStorage { - - public final static int MAX_VARIABLE_NAME_LENGTH = 380, // MySQL: 767 bytes max; cannot set max bytes, only max characters - MAX_CLASS_CODENAME_LENGTH = 50, // checked when registering a class - MAX_VALUE_SIZE = 10000; - - private final static String SELECT_ORDER = "name, type, value, rowid"; - - private final static String OLD_TABLE_NAME = "variables"; - - @Nullable - private String formattedCreateQuery; - private final String createTableQuery; - private String tableName; - - final SynchronizedReference db = new SynchronizedReference<>(null); - - private boolean monitor = false; - long monitor_interval; - - private final static String guid = UUID.randomUUID().toString(); - - /** - * The delay between transactions in milliseconds. - */ - private final static long TRANSACTION_DELAY = 500; - - /** - * Creates a SQLStorage with a create table query. - * - * @param type The database type i.e. CSV. - * @param createTableQuery The create table query to send to the SQL engine. - */ - public SQLStorage(String type, String createTableQuery) { - super(type); - this.createTableQuery = createTableQuery; - this.tableName = "variables21"; - } - - public String getTableName() { - return tableName; - } - - public void setTableName(String tableName) { - this.tableName = tableName; - } - - /** - * Initializes an SQL database with the user provided configuration section for loading the database. - * - * @param config The configuration from the config.sk that defines this database. - * @return A Database implementation from SQLibrary. - */ - @Nullable - public abstract Database initialize(SectionNode config); - - /** - * Retrieve the create query with the tableName in it - * @return the create query with the tableName in it (%s -> tableName) - */ - @Nullable - public String getFormattedCreateQuery() { - if (formattedCreateQuery == null) - formattedCreateQuery = String.format(createTableQuery, tableName); - return formattedCreateQuery; - } - - /** - * Doesn't lock the database for reading (it's not used anywhere else, and locking while loading will interfere with loaded variables being deleted by - * {@link Variables#variableLoaded(String, Object, VariablesStorage)}). - */ - @Override - protected boolean load_i(SectionNode n) { - synchronized (db) { - Plugin plugin = Bukkit.getPluginManager().getPlugin("SQLibrary"); - if (plugin == null || !(plugin instanceof SQLibrary)) { - Skript.error("You need the plugin SQLibrary in order to use a database with Skript. You can download the latest version from https://dev.bukkit.org/projects/sqlibrary/files/"); - return false; - } - - final Boolean monitor_changes = getValue(n, "monitor changes", Boolean.class); - final Timespan monitor_interval = getValue(n, "monitor interval", Timespan.class); - if (monitor_changes == null || monitor_interval == null) - return false; - monitor = monitor_changes; - this.monitor_interval = monitor_interval.getAs(Timespan.TimePeriod.MILLISECOND); - - final Database db; - try { - Database database = initialize(n); - if (database == null) - return false; - this.db.set(db = database); - } catch (final RuntimeException e) { - if (e instanceof DatabaseException) {// not in a catch clause to not produce a ClassNotFoundException when this class is loaded and SQLibrary is not present - Skript.error(e.getLocalizedMessage()); - return false; - } - throw e; - } - - SkriptLogger.setNode(null); - - if (!connect(true)) - return false; - - try { - final boolean hasOldTable = false; - final boolean hadNewTable = db.isTable(getTableName()); - - if (getFormattedCreateQuery() == null){ - Skript.error("Could not create the variables table in the database. The query to create the variables table '" + tableName + "' in the database '" + getUserConfigurationName() + "' is null."); - return false; - } - - try { - db.query(getFormattedCreateQuery()); - } catch (final SQLException e) { - Skript.error("Could not create the variables table '" + tableName + "' in the database '" + getUserConfigurationName() + "': " + e.getLocalizedMessage() + ". " - + "Please create the table yourself using the following query: " + String.format(createTableQuery, tableName).replace(",", ", ").replaceAll("\\s+", " ")); - return false; - } - - if (!prepareQueries()) { - return false; - } - - // old - // Table name support was added after the verison that used the legacy database format - - // new - final ResultSet r2 = db.query("SELECT " + SELECT_ORDER + " FROM " + getTableName()); - assert r2 != null; - try { - loadVariables(r2); - } finally { - r2.close(); - } - - // store old variables in new table and delete the old table - } catch (final SQLException e) { - sqlException(e); - return false; - } - - // periodically executes queries to keep the collection alive - Skript.newThread(new Runnable() { - @Override - public void run() { - while (!closed) { - synchronized (SQLStorage.this.db) { - try { - final Database db = SQLStorage.this.db.get(); - if (db != null) - db.query("SELECT * FROM " + getTableName() + " LIMIT 1"); - } catch (final SQLException e) {} - } - try { - Thread.sleep(1000 * 10); - } catch (final InterruptedException e) {} - } - } - }, "Skript database '" + getUserConfigurationName() + "' connection keep-alive thread").start(); - - return true; - } - } - - @Override - protected void allLoaded() { - Skript.debug("Database " + getUserConfigurationName() + " loaded. Queue size = " + changesQueue.size()); - - // start committing thread. Its first execution will also commit the first batch of changed variables. - Skript.newThread(new Runnable() { - @Override - public void run() { - long lastCommit; - while (!closed) { - synchronized (db) { - final Database db = SQLStorage.this.db.get(); - try { - if (db != null) - db.getConnection().commit(); - } catch (final SQLException e) { - sqlException(e); - } - lastCommit = System.currentTimeMillis(); - } - try { - Thread.sleep(Math.max(0, lastCommit + TRANSACTION_DELAY - System.currentTimeMillis())); - } catch (final InterruptedException e) {} - } - } - }, "Skript database '" + getUserConfigurationName() + "' transaction committing thread").start(); - - if (monitor) { - Skript.newThread(new Runnable() { - @Override - public void run() { - try { // variables were just downloaded, not need to check for modifications straight away - Thread.sleep(monitor_interval); - } catch (final InterruptedException e1) {} - - long lastWarning = Long.MIN_VALUE; - final int WARING_INTERVAL = 10; - - while (!closed) { - final long next = System.currentTimeMillis() + monitor_interval; - checkDatabase(); - final long now = System.currentTimeMillis(); - if (next < now && lastWarning + WARING_INTERVAL * 1000 < now) { - // TODO don't print this message when Skript loads (because scripts are loaded after variables and take some time) - Skript.warning("Cannot load variables from the database fast enough (loading took " + ((now - next + monitor_interval) / 1000.) + "s, monitor interval = " + (monitor_interval / 1000.) + "s). " + - "Please increase your monitor interval or reduce usage of variables. " + - "(this warning will be repeated at most once every " + WARING_INTERVAL + " seconds)"); - lastWarning = now; - } - while (System.currentTimeMillis() < next) { - try { - Thread.sleep(next - System.currentTimeMillis()); - } catch (final InterruptedException e) {} - } - } - } - }, "Skript database '" + getUserConfigurationName() + "' monitor thread").start(); - } - - } - - @Override - protected File getFile(String file) { - if (!file.endsWith(".db")) - file = file + ".db"; // required by SQLibrary - return new File(file); - } - - @Override - protected boolean connect() { - return connect(false); - } - - private final boolean connect(final boolean first) { - synchronized (db) { - // isConnected doesn't work in SQLite -// if (db.isConnected()) -// return; - final Database db = this.db.get(); - if (db == null || !db.open()) { - if (first) - Skript.error("Cannot connect to the database '" + getUserConfigurationName() + "'! Please make sure that all settings are correct");// + (type == Type.MYSQL ? " and that the database software is running" : "") + "."); - else - Skript.exception("Cannot reconnect to the database '" + getUserConfigurationName() + "'!"); - return false; - } - try { - db.getConnection().setAutoCommit(false); - } catch (final SQLException e) { - sqlException(e); - return false; - } - return true; - } - } - - /** - * (Re)creates prepared statements as they get closed as well when closing the connection - * - * @return - */ - private boolean prepareQueries() { - synchronized (db) { - final Database db = this.db.get(); - assert db != null; - try { - try { - if (writeQuery != null) - writeQuery.close(); - } catch (final SQLException e) {} - writeQuery = db.prepare("REPLACE INTO " + getTableName() + " (name, type, value, update_guid) VALUES (?, ?, ?, ?)"); - - try { - if (deleteQuery != null) - deleteQuery.close(); - } catch (final SQLException e) {} - deleteQuery = db.prepare("DELETE FROM " + getTableName() + " WHERE name = ?"); - - try { - if (monitorQuery != null) - monitorQuery.close(); - } catch (final SQLException e) {} - monitorQuery = db.prepare("SELECT " + SELECT_ORDER + " FROM " + getTableName() + " WHERE rowid > ? AND update_guid != ?"); - try { - if (monitorCleanUpQuery != null) - monitorCleanUpQuery.close(); - } catch (final SQLException e) {} - monitorCleanUpQuery = db.prepare("DELETE FROM " + getTableName() + " WHERE value IS NULL AND rowid < ?"); - } catch (final SQLException e) { - Skript.exception(e, "Could not prepare queries for the database '" + getUserConfigurationName() + "': " + e.getLocalizedMessage()); - return false; - } - } - return true; - } - - @Override - protected void disconnect() { - synchronized (db) { - final Database db = this.db.get(); -// if (!db.isConnected()) -// return; - if (db != null) - db.close(); - } - } - - /** - * Params: name, type, value, GUID - *

- * Writes a variable to the database - */ - @Nullable - private PreparedStatement writeQuery; - /** - * Params: name - *

- * Deletes a variable from the database - */ - @Nullable - private PreparedStatement deleteQuery; - /** - * Params: rowID, GUID - *

- * Selects changed rows. values in order: {@value #SELECT_ORDER} - */ - @Nullable - private PreparedStatement monitorQuery; - /** - * Params: rowID - *

- * Deletes null variables from the database older than the given value - */ - @Nullable - PreparedStatement monitorCleanUpQuery; - - @Override - protected boolean save(final String name, final @Nullable String type, final @Nullable byte[] value) { - synchronized (db) { - // REMIND get the actual maximum size from the database - if (name.length() > MAX_VARIABLE_NAME_LENGTH) - Skript.error("The name of the variable {" + name + "} is too long to be saved in a database (length: " + name.length() + ", maximum allowed: " + MAX_VARIABLE_NAME_LENGTH + ")! It will be truncated and won't bet available under the same name again when loaded."); - if (value != null && value.length > MAX_VALUE_SIZE) - Skript.error("The variable {" + name + "} cannot be saved in the database as its value's size (" + value.length + ") exceeds the maximum allowed size of " + MAX_VALUE_SIZE + "! An attempt to save the variable will be made nonetheless."); - try { - if (type == null) { - assert value == null; - final PreparedStatement deleteQuery = this.deleteQuery; - assert deleteQuery != null; - deleteQuery.setString(1, name); - deleteQuery.executeUpdate(); - } else { - int i = 1; - final PreparedStatement writeQuery = this.writeQuery; - assert writeQuery != null; - writeQuery.setString(i++, name); - writeQuery.setString(i++, type); - writeQuery.setBytes(i++, value); // SQLite desn't support setBlob - writeQuery.setString(i++, guid); - writeQuery.executeUpdate(); - } - } catch (final SQLException e) { - sqlException(e); - return false; - } - } - return true; - } - - @Override - public void close() { - synchronized (db) { - super.close(); - final Database db = this.db.get(); - if (db != null) { - try { - db.getConnection().commit(); - } catch (final SQLException e) { - sqlException(e); - } - db.close(); - this.db.set(null); - } - } - } - - long lastRowID = -1; - - protected void checkDatabase() { - try { - final long lastRowID; // local variable as this is used to clean the database below - ResultSet r = null; - try { - synchronized (db) { - if (closed || db.get() == null) - return; - lastRowID = this.lastRowID; - final PreparedStatement monitorQuery = this.monitorQuery; - assert monitorQuery != null; - monitorQuery.setLong(1, lastRowID); - monitorQuery.setString(2, guid); - monitorQuery.execute(); - r = monitorQuery.getResultSet(); - assert r != null; - } - if (!closed) - loadVariables(r); - } finally { - if (r != null) - r.close(); - } - - if (!closed) { // Skript may have been disabled in the meantime // TODO not fixed - new Task(Skript.getInstance(), (long) Math.ceil(2. * monitor_interval / 50) + 100, true) { // 2 times the interval + 5 seconds - @Override - public void run() { - try { - synchronized (db) { - if (closed || db.get() == null) - return; - final PreparedStatement monitorCleanUpQuery = SQLStorage.this.monitorCleanUpQuery; - assert monitorCleanUpQuery != null; - monitorCleanUpQuery.setLong(1, lastRowID); - monitorCleanUpQuery.executeUpdate(); - } - } catch (final SQLException e) { - sqlException(e); - } - } - }; - } - } catch (final SQLException e) { - sqlException(e); - } - } - -// private final static class VariableInfo { -// final String name; -// final byte[] value; -// final ClassInfo ci; -// -// public VariableInfo(final String name, final byte[] value, final ClassInfo ci) { -// this.name = name; -// this.value = value; -// this.ci = ci; -// } -// } - -// final static LinkedList syncDeserializing = new LinkedList(); - - /** - * Doesn't lock the database - {@link #save(String, String, byte[])} does that // what? - */ - private void loadVariables(final ResultSet r) throws SQLException { -// assert !Thread.holdsLock(db); -// synchronized (syncDeserializing) { - - final SQLException e = Task.callSync(new Callable() { - @Override - @Nullable - public SQLException call() throws Exception { - try { - while (r.next()) { - int i = 1; - final String name = r.getString(i++); - if (name == null) { - Skript.error("Variable with NULL name found in the database '" + getUserConfigurationName() + "', ignoring it"); - continue; - } - final String type = r.getString(i++); - final byte[] value = r.getBytes(i++); // Blob not supported by SQLite - lastRowID = r.getLong(i++); - if (value == null) { - Variables.variableLoaded(name, null, SQLStorage.this); - } else { - final ClassInfo c = Classes.getClassInfoNoError(type); - @SuppressWarnings("unused") - Serializer s; - if (c == null || (s = c.getSerializer()) == null) { - Skript.error("Cannot load the variable {" + name + "} from the database '" + getUserConfigurationName() + "', because the type '" + type + "' cannot be recognised or cannot be stored in variables"); - continue; - } -// if (s.mustSyncDeserialization()) { -// syncDeserializing.add(new VariableInfo(name, value, c)); -// } else { - final Object d = Classes.deserialize(c, value); - if (d == null) { - Skript.error("Cannot load the variable {" + name + "} from the database '" + getUserConfigurationName() + "', because it cannot be loaded as " + c.getName().withIndefiniteArticle()); - continue; - } - Variables.variableLoaded(name, d, SQLStorage.this); -// } - } - } - } catch (final SQLException e) { - return e; - } - return null; - } - }); - if (e != null) - throw e; - -// if (!syncDeserializing.isEmpty()) { -// Task.callSync(new Callable() { -// @Override -// @Nullable -// public Void call() throws Exception { -// synchronized (syncDeserializing) { -// for (final VariableInfo o : syncDeserializing) { -// final Object d = Classes.deserialize(o.ci, o.value); -// if (d == null) { -// Skript.error("Cannot load the variable {" + o.name + "} from the database " + getUserConfigurationName() + ", because it cannot be loaded as a " + o.ci.getName()); -// continue; -// } -// Variables.variableLoaded(o.name, d, DatabaseStorage.this); -// } -// syncDeserializing.clear(); -// return null; -// } -// } -// }); -// } -// } - } - -// private final static class OldVariableInfo { -// final String name; -// final String value; -// final ClassInfo ci; -// -// public OldVariableInfo(final String name, final String value, final ClassInfo ci) { -// this.name = name; -// this.value = value; -// this.ci = ci; -// } -// } - -// final static LinkedList oldSyncDeserializing = new LinkedList(); - - void sqlException(final SQLException e) { - Skript.error("database error: " + e.getLocalizedMessage()); - if (Skript.testing()) - e.printStackTrace(); - prepareQueries(); // a query has to be recreated after an error - } - -} diff --git a/src/main/java/ch/njol/skript/variables/SQLiteStorage.java b/src/main/java/ch/njol/skript/variables/SQLiteStorage.java deleted file mode 100644 index 57032218f95..00000000000 --- a/src/main/java/ch/njol/skript/variables/SQLiteStorage.java +++ /dev/null @@ -1,37 +0,0 @@ -package ch.njol.skript.variables; - -import java.io.File; - -import ch.njol.skript.config.SectionNode; -import ch.njol.skript.log.SkriptLogger; -import lib.PatPeter.SQLibrary.Database; -import lib.PatPeter.SQLibrary.SQLite; - -public class SQLiteStorage extends SQLStorage { - - SQLiteStorage(String type) { - super(type, "CREATE TABLE IF NOT EXISTS %s (" + - "name VARCHAR(" + MAX_VARIABLE_NAME_LENGTH + ") NOT NULL PRIMARY KEY," + - "type VARCHAR(" + MAX_CLASS_CODENAME_LENGTH + ")," + - "value BLOB(" + MAX_VALUE_SIZE + ")," + - "update_guid CHAR(36) NOT NULL" + - ")"); - } - - @Override - public Database initialize(SectionNode config) { - File f = file; - if (f == null) - return null; - setTableName(config.get("table", "variables21")); - String name = f.getName(); - assert name.endsWith(".db"); - return new SQLite(SkriptLogger.LOGGER, "[Skript]", f.getParent(), name.substring(0, name.length() - ".db".length())); - } - - @Override - protected boolean requiresFile() { - return true; - } - -} diff --git a/src/main/java/ch/njol/skript/variables/SerializedVariable.java b/src/main/java/ch/njol/skript/variables/SerializedVariable.java index 258a251a9b5..e2fd222510c 100644 --- a/src/main/java/ch/njol/skript/variables/SerializedVariable.java +++ b/src/main/java/ch/njol/skript/variables/SerializedVariable.java @@ -1,5 +1,6 @@ package ch.njol.skript.variables; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; /** @@ -10,15 +11,23 @@ public class SerializedVariable { /** * The name of the variable. + *

+ * @deprecated Marked as internal, as this field will soon be private. */ + @Deprecated + @ApiStatus.Internal // Remove the internal status when the field is private. public final String name; /** * The serialized value of the variable. *

* A value of {@code null} indicates the variable will be deleted. + *

+ * @deprecated Marked as internal, as this field will soon be private. */ @Nullable + @Deprecated + @ApiStatus.Internal // Remove the internal status when the field is private. public final Value value; /** @@ -32,6 +41,33 @@ public SerializedVariable(String name, @Nullable Value value) { this.value = value; } + public SerializedVariable(String name, String type, byte[] value) { + this(name, new Value(type, value)); + } + + @Nullable + public String getType() { + if (value == null) + return null; + return value.type; + } + + @Nullable + public byte[] getData() { + if (value == null) + return null; + return value.data; + } + + @Nullable + public Value getValue() { + return value; + } + + public String getName() { + return name; + } + /** * A serialized value of a variable. */ diff --git a/src/main/java/ch/njol/skript/variables/UnloadedStorage.java b/src/main/java/ch/njol/skript/variables/UnloadedStorage.java new file mode 100644 index 00000000000..7cc3ccaedac --- /dev/null +++ b/src/main/java/ch/njol/skript/variables/UnloadedStorage.java @@ -0,0 +1,63 @@ +package ch.njol.skript.variables; + +import ch.njol.skript.SkriptAddon; + +/** + * Represents an unloaded storage type for variables. + * This class stores all the data from register time to be used if this database is selected. + */ +public class UnloadedStorage { + + private final Class storage; + private final SkriptAddon source; + private final String[] names; + + /** + * Construct an unloaded storage that contains the register data of a storage type. + * + * @param source The SkriptAddon that is registering this storage type. + * @param storage The class of the actual VariableStorage to initalize with. + * @param names The possible user input names from the config.sk to match this storage. + */ + public UnloadedStorage(SkriptAddon source, Class storage, String... names) { + this.storage = storage; + this.source = source; + this.names = names; + } + + /** + * @return the storage class + */ + public Class getStorageClass() { + return storage; + } + + /** + * @return the SkriptAddon source that registered this storage. + */ + public SkriptAddon getSource() { + return source; + } + + /** + * @return the possible user input names + */ + public String[] getNames() { + return names; + } + + /** + * Checks if a user input matches this storage input names. + * + * @param input The name to check against. + * @return true if this storage matches the user input, otherwise false. + */ + public boolean matches(String input) { + for (String name : names) { + if (name.equalsIgnoreCase(input)) + return true; + } + return false; + } + +} diff --git a/src/main/java/ch/njol/skript/variables/VariablesStorage.java b/src/main/java/ch/njol/skript/variables/VariableStorage.java similarity index 81% rename from src/main/java/ch/njol/skript/variables/VariablesStorage.java rename to src/main/java/ch/njol/skript/variables/VariableStorage.java index 10c4bf99d01..3e368d8da91 100644 --- a/src/main/java/ch/njol/skript/variables/VariablesStorage.java +++ b/src/main/java/ch/njol/skript/variables/VariableStorage.java @@ -2,7 +2,6 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.HashSet; import java.util.Set; import java.util.concurrent.LinkedBlockingQueue; @@ -10,8 +9,8 @@ import java.util.regex.PatternSyntaxException; import org.jetbrains.annotations.Nullable; - import ch.njol.skript.Skript; +import ch.njol.skript.SkriptAddon; import ch.njol.skript.config.SectionNode; import ch.njol.skript.lang.ParseContext; import ch.njol.skript.log.ParseLogHandler; @@ -20,6 +19,7 @@ import ch.njol.skript.util.FileUtils; import ch.njol.skript.util.Task; import ch.njol.skript.util.Timespan; +import ch.njol.skript.util.Timespan.TimePeriod; import ch.njol.skript.variables.SerializedVariable.Value; import ch.njol.util.Closeable; @@ -33,7 +33,7 @@ */ // FIXME ! large databases (>25 MB) cause the server to be unresponsive instead of loading slowly @SuppressWarnings({"SuspiciousIndentAfterControlStatement", "removal"}) -public abstract class VariablesStorage implements Closeable { +public abstract class VariableStorage implements Closeable { /** * The size of the variable changes queue. @@ -81,31 +81,34 @@ public abstract class VariablesStorage implements Closeable { */ // created in the constructor, started in load() private final Thread writeThread; + private final SkriptAddon source; /** * Creates a new variable storage with the given name. *

* This will also create the {@link #writeThread}, but it must be started - * with {@link #load(SectionNode)}. + * with {@link #loadConfig(SectionNode)}. * + * @param source the SkriptAddon instance that registered this VariableStorage. * @param type the database type i.e. CSV. */ - protected VariablesStorage(String type) { + protected VariableStorage(SkriptAddon source, String type) { assert type != null; - databaseType = type; + this.databaseType = type; + this.source = source; writeThread = Skript.newThread(() -> { while (!closed) { try { // Take a variable from the queue and process it SerializedVariable variable = changesQueue.take(); - Value value = variable.value; + Value value = variable.getValue(); // Actually save the variable if (value != null) - save(variable.name, value.type, value.data); + save(variable.getName(), value.type, value.data); else - save(variable.name, null, null); + save(variable.getName(), null, null); } catch (InterruptedException ignored) { // Ignored as the `closed` field will indicate whether the thread actually needs to stop } @@ -129,12 +132,20 @@ protected final String getUserConfigurationName() { /** * Get the config type of a database - * @return type of databse + * + * @return type of database */ protected final String getDatabaseType() { return databaseType; } + /** + * @return The SkriptAddon instance that registered this VariableStorage. + */ + public final SkriptAddon getRegisterSource() { + return source; + } + /** * Gets the string value at the given key of the given section node. * @@ -144,7 +155,7 @@ protected final String getDatabaseType() { * or not found. */ @Nullable - protected String getValue(SectionNode sectionNode, String key) { + protected final String getValue(SectionNode sectionNode, String key) { return getValue(sectionNode, key, String.class); } @@ -160,18 +171,51 @@ protected String getValue(SectionNode sectionNode, String key) { * @param the type. */ @Nullable - protected T getValue(SectionNode sectionNode, String key, Class type) { + protected final T getValue(SectionNode sectionNode, String key, Class type) { + return getValue(sectionNode, key, type, true); + } + + /** + * Gets the value at the given key of the given section node, + * parsed with the given type. Prints no errors, but can return null. + * + * @param sectionNode the section node. + * @param key the key. + * @param type the type. + * @return the parsed value, or {@code null} if the value was invalid, + * or not found. + * @param the type. + */ + @Nullable + protected final T getOptional(SectionNode sectionNode, String key, Class type) { + return getValue(sectionNode, key, type, false); + } + + /** + * Gets the value at the given key of the given section node, + * parsed with the given type. + * + * @param sectionNode the section node. + * @param key the key. + * @param type the type. + * @param error if Skript should print errors and stop loading. + * @return the parsed value, or {@code null} if the value was invalid, + * or not found. + * @param the type. + */ + @Nullable + private T getValue(SectionNode sectionNode, String key, Class type, boolean error) { String rawValue = sectionNode.getValue(key); - // Section node doesn't have this key if (rawValue == null) { - Skript.error("The config is missing the entry for '" + key + "' in the database '" + databaseName + "'"); + if (error) + Skript.error("The config is missing the entry for '" + key + "' in the database '" + databaseName + "'"); return null; } try (ParseLogHandler log = SkriptLogger.startParseLogHandler()) { T parsedValue = Classes.parse(rawValue, type, ParseContext.CONFIG); - if (parsedValue == null) + if (parsedValue == null && error) // Parsing failed log.printError("The entry for '" + key + "' in the database '" + databaseName + "' must be " + Classes.getSuperClassInfo(type).getName().withIndefiniteArticle()); @@ -186,14 +230,14 @@ protected T getValue(SectionNode sectionNode, String key, Class type) { /** * Loads the configuration for this variable storage - * from the given section node. + * from the given section node. Loads internal required values first in loadConfig. + * {@link #load(SectionNode)} is for extending classes. * * @param sectionNode the section node. * @return whether the loading succeeded. */ - public final boolean load(SectionNode sectionNode) { + public final boolean loadConfig(SectionNode sectionNode) { databaseName = sectionNode.getKey(); - String pattern = getValue(sectionNode, "pattern"); if (pattern == null) return false; @@ -269,22 +313,32 @@ public final boolean load(SectionNode sectionNode) { } // Load the entries custom to the variable storage - if (!load_i(sectionNode)) + if (!loadAbstract(sectionNode)) return false; writeThread.start(); Skript.closeOnDisable(this); - return true; } + /** + * Used for abstract extending classes intercepting the + * configuration before sending to the final implementation class. + * + * Override to use this method in AnotherAbstractClass; + * VariablesStorage -> AnotherAbstractClass -> FinalImplementation + */ + protected boolean loadAbstract(SectionNode sectionNode) { + return load(sectionNode); + } + /** * Loads variables stored here. * * @return Whether the database could be loaded successfully, * i.e. whether the config is correct and all variables could be loaded. */ - protected abstract boolean load_i(SectionNode n); + protected abstract boolean load(SectionNode n); /** * Called after all storages have been loaded, and variables @@ -321,7 +375,7 @@ public final boolean load(SectionNode sectionNode) { /** * (Re)connects to the database. *

- * Not called on the first connect: do this in {@link #load_i(SectionNode)}. + * Not called on the first connect: do this in {@link #load(SectionNode)}. * An error should be printed by this method * prior to returning {@code false}. * @@ -347,9 +401,10 @@ public final boolean load(SectionNode sectionNode) { */ public void startBackupTask(Timespan backupInterval, boolean removeBackups, int toKeep) { // File is null or backup interval is invalid - if (file == null || backupInterval.getAs(Timespan.TimePeriod.TICK) == 0) + var backupIntervalTicks = backupInterval.getAs(TimePeriod.TICK); + if (file == null || backupIntervalTicks <= 0) return; - backupTask = new Task(Skript.getInstance(), backupInterval.getAs(Timespan.TimePeriod.TICK), backupInterval.getAs(Timespan.TimePeriod.TICK), true) { + backupTask = new Task(Skript.getInstance(), backupIntervalTicks, backupIntervalTicks, true) { @Override public void run() { synchronized (connectionLock) { @@ -488,7 +543,7 @@ public void close() { * Only used if all variables are saved immediately * after calling this method. */ - protected void clearChangesQueue() { + protected final void clearChangesQueue() { changesQueue.clear(); } diff --git a/src/main/java/ch/njol/skript/variables/Variables.java b/src/main/java/ch/njol/skript/variables/Variables.java index 7411ef75e8d..21f9beb29fe 100644 --- a/src/main/java/ch/njol/skript/variables/Variables.java +++ b/src/main/java/ch/njol/skript/variables/Variables.java @@ -12,11 +12,7 @@ import ch.njol.skript.log.SkriptLogger; import ch.njol.skript.registrations.Classes; import ch.njol.skript.variables.SerializedVariable.Value; -import ch.njol.util.Kleenean; -import ch.njol.util.NonNullPair; -import ch.njol.util.Pair; -import ch.njol.util.StringUtils; -import ch.njol.util.SynchronizedReference; +import ch.njol.util.*; import ch.njol.util.coll.iterator.EmptyIterator; import ch.njol.yggdrasil.Yggdrasil; import com.google.common.collect.HashMultimap; @@ -53,13 +49,19 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.regex.Pattern; +import org.skriptlang.skript.variables.storage.H2Storage; +import org.skriptlang.skript.variables.storage.MySQLStorage; +import org.skriptlang.skript.variables.storage.SQLiteStorage; + +import ch.njol.skript.SkriptAddon; + /** * Handles all things related to variables. * * @see #setVariable(String, Object, Event, boolean) * @see #getVariable(String, Event, boolean) */ -public class Variables { +public final class Variables { /** * The version of {@link Yggdrasil} this class is using. @@ -82,13 +84,21 @@ public class Variables { */ private static final String CONFIGURATION_SERIALIZABLE_PREFIX = "ConfigurationSerializable_"; - private final static Multimap, String> TYPES = HashMultimap.create(); + private final static Multimap, String> TYPES = HashMultimap.create(); + + private final static List UNLOADED_STORAGES = new ArrayList<>(); // Register some things with Yggdrasil static { - registerStorage(FlatFileStorage.class, "csv", "file", "flatfile"); - registerStorage(SQLiteStorage.class, "sqlite"); - registerStorage(MySQLStorage.class, "mysql"); + SkriptAddon source = Skript.getAddonInstance(); + registerStorage(source, FlatFileStorage.class, "csv", "file", "flatfile"); + if (Skript.classExists("com.zaxxer.hikari.HikariConfig")) { + registerStorage(source, SQLiteStorage.class, "sqlite"); + registerStorage(source, MySQLStorage.class, "mysql"); + registerStorage(source, H2Storage.class, "h2"); + } else { + Skript.warning("SpigotLibraryLoader failed to load HikariCP. No JDBC databases were enabled."); + } yggdrasil.registerSingleClass(Kleenean.class, "Kleenean"); // Register ConfigurationSerializable, Bukkit's serialization system yggdrasil.registerClassResolver(new ConfigurationSerializer() { @@ -96,7 +106,7 @@ public class Variables { //noinspection unchecked info = (ClassInfo) (ClassInfo) Classes.getExactClassInfo(Object.class); // Info field is mostly unused in superclass, due to methods overridden below, - // so this illegal cast is fine + // so this illegal cast is fine } @Override @@ -123,14 +133,14 @@ public Class getClass(@NotNull String id) { } /** - * The variable storages configured. + * The loaded variable storages that the user wants to use. */ - static final List STORAGES = new ArrayList<>(); + static final List STORAGES = new ArrayList<>(); /** * @return a copy of the list of variable storage handlers */ - public static @UnmodifiableView List getStores() { + public static @UnmodifiableView List getLoadedStorages() { return Collections.unmodifiableList(STORAGES); } @@ -140,17 +150,19 @@ public Class getClass(@NotNull String id) { * @param A class to extend VariableStorage. * @param storage The class of the VariableStorage implementation. * @param names The names used in the config of Skript to select this VariableStorage. - * @return if the operation was successful, or if it's already registered. - */ - public static boolean registerStorage(Class storage, String... names) { - if (TYPES.containsKey(storage)) - return false; - for (String name : names) { - if (TYPES.containsValue(name.toLowerCase(Locale.ENGLISH))) - return false; + * @return if the operation was successful, or false if the class is already registered. + * @throws SkriptAPIException if the operation was not successful because the storage class is already registered. + */ + public static boolean registerStorage(SkriptAddon source, Class storage, String... names) { + for (UnloadedStorage registered : UNLOADED_STORAGES) { + if (registered.getStorageClass().isAssignableFrom(storage)) + throw new SkriptAPIException("Storage class '" + storage.getName() + "' cannot be registered because '" + registered.getStorageClass().getName() + "' is a superclass or equal class"); + for (String name : names) { + if (registered.matches(name)) + return false; + } } - for (String name : names) - TYPES.put(storage, name.toLowerCase(Locale.ENGLISH)); + UNLOADED_STORAGES.add(new UnloadedStorage(source, storage, names)); return true; } @@ -186,7 +198,7 @@ public static boolean load() { } catch (InterruptedException ignored) {} synchronized (TEMP_VARIABLES) { - Map> tvs = TEMP_VARIABLES.get(); + Map> tvs = TEMP_VARIABLES.get(); if (tvs != null) Skript.info("Loaded " + tvs.size() + " variables so far..."); else @@ -200,9 +212,7 @@ public static boolean load() { boolean successful = true; for (Node node : (SectionNode) databases) { - if (node instanceof SectionNode) { - SectionNode sectionNode = (SectionNode) node; - + if (node instanceof SectionNode sectionNode) { String type = sectionNode.getValue("type"); if (type == null) { Skript.error("Missing entry 'type' in database definition"); @@ -214,10 +224,9 @@ public static boolean load() { assert name != null; // Initiate the right VariablesStorage class - VariablesStorage variablesStorage; - Optional optional = TYPES.entries().stream() - .filter(entry -> entry.getValue().equalsIgnoreCase(type)) - .map(Entry::getKey) + VariableStorage variablesStorage; + Optional optional = UNLOADED_STORAGES.stream() + .filter(registered -> registered.matches(type)) .findFirst(); if (!optional.isPresent()) { if (!type.equalsIgnoreCase("disabled") && !type.equalsIgnoreCase("none")) { @@ -227,22 +236,29 @@ public static boolean load() { continue; } + UnloadedStorage unloadedStorage = optional.get(); try { - @SuppressWarnings("unchecked") - Class storageClass = (Class) optional.get(); - Constructor constructor = storageClass.getDeclaredConstructor(String.class); + Class storageClass = unloadedStorage.getStorageClass(); + Constructor constructor = storageClass.getDeclaredConstructor(SkriptAddon.class, String.class); constructor.setAccessible(true); - variablesStorage = (VariablesStorage) constructor.newInstance(type); + variablesStorage = (VariableStorage) constructor.newInstance(unloadedStorage.getSource(), type); } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { - Skript.error("Failed to initialize database `" + name + "`"); + Skript.exception(e, "API Failed to initalize database '" + name + "' ensure constructors are properly created."); successful = false; continue; } + if (variablesStorage instanceof SQLiteStorage) { + Skript.warning( + "Please be advised that SQL is single threaded and synchronous.\n" + + "Meaning you will be losing out on drastic performance.\n" + + "Due to this, Skript is deprecating SQLite. Please consider using H2."); + } + // Get the amount of variables currently loaded int totalVariablesLoaded; synchronized (TEMP_VARIABLES) { - Map> tvs = TEMP_VARIABLES.get(); + Map> tvs = TEMP_VARIABLES.get(); assert tvs != null; totalVariablesLoaded = tvs.size(); } @@ -252,15 +268,16 @@ public static boolean load() { Skript.info("Loading database '" + node.getKey() + "'..."); // Load the variables - if (variablesStorage.load(sectionNode)) + if (variablesStorage.loadConfig(sectionNode)) { STORAGES.add(variablesStorage); - else + } else { successful = false; + } // Get the amount of variables loaded by this variables storage object int newVariablesLoaded; synchronized (TEMP_VARIABLES) { - Map> tvs = TEMP_VARIABLES.get(); + Map> tvs = TEMP_VARIABLES.get(); assert tvs != null; newVariablesLoaded = tvs.size() - totalVariablesLoaded; } @@ -295,7 +312,6 @@ public static boolean load() { // Interrupt the loading logger thread to make it exit earlier loadingLoggerThread.interrupt(); - saveThread.start(); } return true; @@ -688,7 +704,7 @@ static void processChangeQueue() { *

* Access must be synchronised. */ - private static final SynchronizedReference>> TEMP_VARIABLES = + private static final SynchronizedReference>> TEMP_VARIABLES = new SynchronizedReference<>(new HashMap<>()); /** @@ -713,7 +729,7 @@ static void processChangeQueue() { * Must only be used while variables are loaded * when Skript is starting. Must be called on Bukkit's main thread. * This method directly invokes - * {@link VariablesStorage#save(String, String, byte[])}, + * {@link VariableStorage#save(String, String, byte[])}, * i.e. you should not be holding any database locks or such * when calling this! * @@ -722,20 +738,20 @@ static void processChangeQueue() { * @param source the storage the variable came from. * @return Whether the variable was stored somewhere. Not valid while storages are loading. */ - static boolean variableLoaded(String name, @Nullable Object value, VariablesStorage source) { + static boolean variableLoaded(String name, @Nullable Object value, VariableStorage source) { assert Bukkit.isPrimaryThread(); // required by serialisation if (value == null) return false; synchronized (TEMP_VARIABLES) { - Map> tvs = TEMP_VARIABLES.get(); + Map> tvs = TEMP_VARIABLES.get(); if (tvs != null) { - NonNullPair existingVariable = tvs.get(name); + NonNullPair existingVariable = tvs.get(name); // Check for conflicts with other storages conflict: if (existingVariable != null) { - VariablesStorage existingVariableStorage = existingVariable.getSecond(); + VariableStorage existingVariableStorage = existingVariable.getSecond(); if (existingVariableStorage == source) { // No conflict if from the same storage @@ -776,7 +792,7 @@ static boolean variableLoaded(String name, @Nullable Object value, VariablesStor // Move the variable to the right storage try { - for (VariablesStorage variablesStorage : STORAGES) { + for (VariableStorage variablesStorage : STORAGES) { if (variablesStorage.accept(name)) { if (variablesStorage != source) { // Serialize and set value in new storage @@ -809,7 +825,6 @@ static boolean variableLoaded(String name, @Nullable Object value, VariablesStor * @return the amount of variables * that don't have a storage that accepts them. */ - @SuppressWarnings("null") private static int onStoragesLoaded() { if (loadConflicts > MAX_CONFLICT_WARNINGS) Skript.warning("A total of " + loadConflicts + " variables were loaded more than once from different databases"); @@ -817,7 +832,7 @@ private static int onStoragesLoaded() { Skript.debug("Databases loaded, setting variables..."); synchronized (TEMP_VARIABLES) { - Map> tvs = TEMP_VARIABLES.get(); + Map> tvs = TEMP_VARIABLES.get(); TEMP_VARIABLES.set(null); assert tvs != null; @@ -825,12 +840,12 @@ private static int onStoragesLoaded() { try { // Calculate the amount of variables that don't have a storage int unstoredVariables = 0; - for (Entry> tv : tvs.entrySet()) { + for (Entry> tv : tvs.entrySet()) { if (!variableLoaded(tv.getKey(), tv.getValue().getFirst(), tv.getValue().getSecond())) unstoredVariables++; } - for (VariablesStorage variablesStorage : STORAGES) + for (VariableStorage variablesStorage : STORAGES) variablesStorage.allLoaded(); Skript.debug("Variables set. Queue size = " + saveQueue.size()); @@ -876,7 +891,6 @@ public static SerializedVariable serialize(String name, @Nullable Object value) */ public static SerializedVariable.@Nullable Value serialize(@Nullable Object value) { assert Bukkit.isPrimaryThread(); - return Classes.serialize(value); } @@ -912,8 +926,8 @@ private static void saveVariableChange(String name, @Nullable Object value) { // Save one variable change SerializedVariable variable = saveQueue.take(); - for (VariablesStorage variablesStorage : STORAGES) { - if (variablesStorage.accept(variable.name)) { + for (VariableStorage variablesStorage : STORAGES) { + if (variablesStorage.accept(variable.getName())) { variablesStorage.save(variable); break; diff --git a/src/main/java/ch/njol/skript/variables/VariablesMap.java b/src/main/java/ch/njol/skript/variables/VariablesMap.java index 9934f46eba5..acb7ff679f8 100644 --- a/src/main/java/ch/njol/skript/variables/VariablesMap.java +++ b/src/main/java/ch/njol/skript/variables/VariablesMap.java @@ -124,6 +124,7 @@ final class VariablesMap { * The map that stores all non-list variables. */ final HashMap hashMap = new HashMap<>(); + /** * The tree of variables, branched by the list structure of the variables. */ @@ -139,8 +140,8 @@ final class VariablesMap { * {@code Map} for a list variable, * or {@code null} if the variable is not set. */ - @SuppressWarnings("unchecked") @Nullable + @SuppressWarnings("unchecked") Object getVariable(String name) { if (!name.endsWith("*")) { // Not a list variable, quick access from the hash map diff --git a/src/main/java/org/skriptlang/skript/variables/storage/H2Storage.java b/src/main/java/org/skriptlang/skript/variables/storage/H2Storage.java new file mode 100644 index 00000000000..2bc8db99d72 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/variables/storage/H2Storage.java @@ -0,0 +1,93 @@ +package org.skriptlang.skript.variables.storage; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.function.Function; + +import org.jetbrains.annotations.Nullable; + +import com.zaxxer.hikari.HikariConfig; + +import ch.njol.skript.Skript; +import ch.njol.skript.SkriptAddon; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.variables.JdbcStorage; +import ch.njol.skript.variables.SerializedVariable; + +public class H2Storage extends JdbcStorage { + + /** + * Creates a new H2 storage. + * + * @param source The source of the storage. + * @param name The database name. + */ + H2Storage(SkriptAddon source, String type) { + super(source, type, + "CREATE TABLE IF NOT EXISTS %s (" + + "`name` VARCHAR(" + MAX_VARIABLE_NAME_LENGTH + ") NOT NULL PRIMARY KEY," + + "`type` VARCHAR(" + MAX_CLASS_CODENAME_LENGTH + ")," + + "`value` BINARY LARGE OBJECT(" + MAX_VALUE_SIZE + ")" + + ");" + ); + } + + @Override + @Nullable + public final HikariConfig configuration(SectionNode config) { + if (file == null) + return null; + HikariConfig configuration = new HikariConfig(); + configuration.setPoolName("H2-Pool"); + configuration.setDataSourceClassName("org.h2.jdbcx.JdbcDataSource"); + configuration.setConnectionTestQuery("VALUES 1"); + + String url = ""; + if (config.get("memory", "false").equalsIgnoreCase("true")) + url += "mem:"; + url += "file:" + file.getAbsolutePath(); + configuration.addDataSourceProperty("URL", "jdbc:h2:" + url); + configuration.addDataSourceProperty("user", config.get("user", "")); + configuration.addDataSourceProperty("password", config.get("password", "")); + configuration.addDataSourceProperty("description", config.get("description", "")); + return configuration; + } + + @Override + protected boolean requiresFile() { + return true; + } + + @Override + protected String getReplaceQuery() { + return "MERGE INTO " + getTableName() + " KEY(name) VALUES (?, ?, ?)"; + } + + @Override + protected String getSelectQuery() { + return "SELECT `name`, `type`, `value` FROM " + getTableName(); + } + + @Override + protected @Nullable Function<@Nullable ResultSet, JdbcVariableResult> get(boolean testOperation) { + return result -> { + if (result == null) + return null; + int i = 1; + try { + String name = result.getString(i++); + if (name == null) { + Skript.error("Variable with a NULL name found in the database '" + getUserConfigurationName() + "', ignoring it"); + return null; + } + String type = result.getString(i++); + byte[] value = result.getBytes(i++); + return new JdbcVariableResult(-1L, new SerializedVariable(name, type, value)); + } catch (SQLException e) { + Skript.exception(e, "Failed to collect variable from database."); + return null; + } + }; + } + +} diff --git a/src/main/java/org/skriptlang/skript/variables/storage/MySQLStorage.java b/src/main/java/org/skriptlang/skript/variables/storage/MySQLStorage.java new file mode 100644 index 00000000000..ddbf833e46c --- /dev/null +++ b/src/main/java/org/skriptlang/skript/variables/storage/MySQLStorage.java @@ -0,0 +1,102 @@ +package org.skriptlang.skript.variables.storage; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.function.Function; + +import ch.njol.skript.Skript; +import ch.njol.skript.SkriptAddon; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.variables.JdbcStorage; +import ch.njol.skript.variables.SerializedVariable; + +import com.zaxxer.hikari.HikariConfig; + +import org.jetbrains.annotations.Nullable; + +public class MySQLStorage extends JdbcStorage { + + /** + * Creates a new MySQL storage. + * + * @param source The source of the storage. + * @param type The database type. + */ + MySQLStorage(SkriptAddon source, String type) { + super(source, type, + "CREATE TABLE IF NOT EXISTS %s (" + + "rowid BIGINT NOT NULL AUTO_INCREMENT," + + "name VARCHAR(" + MAX_VARIABLE_NAME_LENGTH + ") NOT NULL," + + "type VARCHAR(" + MAX_CLASS_CODENAME_LENGTH + ")," + + "value BLOB(" + MAX_VALUE_SIZE + ")," + + "PRIMARY KEY(rowid)," + + "UNIQUE KEY(name)" + + ") CHARACTER SET ucs2 COLLATE ucs2_bin;" + ); + } + + @Override + @Nullable + public final HikariConfig configuration(SectionNode section) { + String host = getValue(section, "host"); + Integer port = getValue(section, "port", Integer.class); + String database = getValue(section, "database"); + if (host == null || port == null || database == null) + return null; + + HikariConfig configuration = new HikariConfig(); + configuration.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database); + configuration.setUsername(getValue(section, "user")); + configuration.setPassword(getValue(section, "password")); + + setTableName(section.get("table", DEFAULT_TABLE_NAME)); + return configuration; + } + + @Override + protected boolean requiresFile() { + return false; + } + + @Override + protected String getReplaceQuery() { + return "REPLACE INTO " + getTableName() + " (name, type, value) VALUES (?, ?, ?)"; + } + + @Override + protected MonitorQueries getMonitorQueries() { + return new MonitorQueries( + "SELECT rowid, name, type, value FROM " + getTableName() + " WHERE rowid > ?", + "DELETE FROM " + getTableName() + " WHERE value IS NULL AND rowid < ?" + ); + } + + @Override + protected String getSelectQuery() { + return "SELECT rowid, name, type, value from " + getTableName(); + } + + @Override + protected @Nullable Function<@Nullable ResultSet, JdbcVariableResult> get(boolean testOperation) { + return result -> { + if (result == null) + return null; + int i = 1; + try { + long rowid = result.getLong(i++); // rowid is used for monitor changes. + String name = result.getString(i++); + if (name == null) { + Skript.error("Variable with NULL name found in the database '" + getUserConfigurationName() + "', ignoring it"); + return null; + } + String type = result.getString(i++); + byte[] value = result.getBytes(i++); + return new JdbcVariableResult(rowid, new SerializedVariable(name, type, value)); + } catch (SQLException e) { + Skript.exception(e, "Failed to collect variable from database."); + return null; + } + }; + } + +} diff --git a/src/main/java/org/skriptlang/skript/variables/storage/SQLiteStorage.java b/src/main/java/org/skriptlang/skript/variables/storage/SQLiteStorage.java new file mode 100644 index 00000000000..c6d4a4e0d25 --- /dev/null +++ b/src/main/java/org/skriptlang/skript/variables/storage/SQLiteStorage.java @@ -0,0 +1,105 @@ +package org.skriptlang.skript.variables.storage; + +import java.io.File; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.function.Function; + +import ch.njol.skript.Skript; +import ch.njol.skript.SkriptAddon; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.variables.JdbcStorage; +import ch.njol.skript.variables.SerializedVariable; + +import com.zaxxer.hikari.HikariConfig; + +import org.jetbrains.annotations.ApiStatus.ScheduledForRemoval; +import org.jetbrains.annotations.Nullable; + +/** + * SQLite storage for Skript variables. + *

+ * This class is deprecated and scheduled for removal in future versions of Skript. + * Use {@link JdbcStorage} with a different database like H2Storage or MySQLStorage instead. + */ +@Deprecated +@ScheduledForRemoval +public class SQLiteStorage extends JdbcStorage { + + /** + * Creates a new SQLite storage. + * + * @param source The source of the storage. + * @param type The database type. + */ + SQLiteStorage(SkriptAddon source, String type) { + super(source, type, + "CREATE TABLE IF NOT EXISTS %s (" + + "name VARCHAR(" + MAX_VARIABLE_NAME_LENGTH + ") NOT NULL," + + "type VARCHAR(" + MAX_CLASS_CODENAME_LENGTH + ")," + + "value BLOB(" + MAX_VALUE_SIZE + ")" + + ");" + ); + } + + @Override + @Nullable + public final HikariConfig configuration(SectionNode config) { + File file = this.file; + if (file == null) + return null; + setTableName(config.get("table", DEFAULT_TABLE_NAME)); + String name = file.getName(); + if (!name.endsWith(".db")) + name = name + ".db"; + + HikariConfig configuration = new HikariConfig(); + configuration.setJdbcUrl("jdbc:sqlite:" + (file == null ? ":memory:" : file.getAbsolutePath())); + return configuration; + } + + @Override + protected boolean requiresFile() { + return true; + } + + @Override + protected File getFile(String file) { + if (!file.endsWith(".db")) + file = file + ".db"; // required by SQLite + return new File(file); + } + + @Override + protected String getReplaceQuery() { + return "REPLACE INTO " + getTableName() + " (name, type, value) VALUES (?, ?, ?)"; + } + + @Override + protected String getSelectQuery() { + return "SELECT name, type, value from " + getTableName(); + } + + @Override + protected @Nullable Function<@Nullable ResultSet, JdbcVariableResult> get(boolean testOperation) { + return result -> { + if (result == null) + return null; + int i = 1; + try { + String name = result.getString(i++); + if (name == null) { + Skript.error("Variable with NULL name found in the database '" + getUserConfigurationName() + "', ignoring it"); + return null; + } + String type = result.getString(i++); + byte[] value = result.getBytes(i++); + return new JdbcVariableResult(-1L, new SerializedVariable(name, type, value)); + } catch (SQLException e) { + Skript.exception(e, "Failed to collect variable from database."); + return null; + } + }; + } + +} diff --git a/src/main/resources/config.sk b/src/main/resources/config.sk index 0544dde5fd9..1d01096450a 100644 --- a/src/main/resources/config.sk +++ b/src/main/resources/config.sk @@ -360,27 +360,34 @@ databases: database: skript table: variables21 - monitor changes: true monitor interval: 20 seconds - SQLite example: - # An SQLite database example. + #commit changes: 0.5 seconds + # If you want to change how frequently SQL changes are commited. + # If this is disabled, auto commit will be enabled. + # Pros to this would be on external databases such as MySQL where it gives other servers time to react to changes. + + H2 example: + # An H2 database example. type: disabled # change to line below to enable this database - # type: SQLite + # type: H2 - pattern: db_.* # this pattern will save all variables that start with 'db_' in this SQLite database. + pattern: db_.* # this pattern will save all variables that start with 'db_' in this H2 database. - file: ./plugins/Skript/variables.db - # SQLite databases must end in '.db' + file: ./plugins/Skript/variables + # H2 suffixes the database file with '.mv.db' #table: variables21 # Usually not required, if omitted defaults to variables21 (see above for more details) - backup interval: 0 # 0 = don't create backups - monitor changes: false - monitor interval: 20 seconds + # optional H2 settings if you want. + #user: skript + #password: password + #description: skript - backups to keep: -1 + # If H2 should run in ram memory only mode. + #memory: true + backup interval: 0 # 0 = don't create backups default: # The default "database" is a simple text file, with each variable on a separate line and the variable's name, type, and value separated by commas. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d5602bc642f..bea1ccc0e95 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -31,6 +31,10 @@ main: ch.njol.skript.Skript version: @version@ api-version: 1.19 +libraries: + - 'com.h2database:h2:@h2.version@' + - 'com.zaxxer:HikariCP:@hikaricp.version@' + commands: skript: description: Skript's main command. Type '/skript help' for more information. diff --git a/src/test/java/ch/njol/skript/variables/StorageAccessor.java b/src/test/java/ch/njol/skript/variables/StorageAccessor.java new file mode 100644 index 00000000000..2539ff58307 --- /dev/null +++ b/src/test/java/ch/njol/skript/variables/StorageAccessor.java @@ -0,0 +1,12 @@ +package ch.njol.skript.variables; + +/** + * Class used by Variable tests to access package exclusive methods. + */ +public class StorageAccessor { + + public static void clearVariableStorages() { + Variables.STORAGES.clear(); + } + +} diff --git a/src/test/java/org/skriptlang/skript/variables/storage/H2StorageTest.java b/src/test/java/org/skriptlang/skript/variables/storage/H2StorageTest.java new file mode 100644 index 00000000000..5282c9db3b3 --- /dev/null +++ b/src/test/java/org/skriptlang/skript/variables/storage/H2StorageTest.java @@ -0,0 +1,65 @@ +package org.skriptlang.skript.variables.storage; + +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.sql.SQLException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.junit.Before; +import org.junit.Test; + +import ch.njol.skript.Skript; +import ch.njol.skript.config.Config; +import ch.njol.skript.config.ConfigReader; +import ch.njol.skript.config.EntryNode; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.registrations.Classes; +import ch.njol.skript.variables.StorageAccessor; + +public class H2StorageTest { + + private static final boolean ENABLED = Skript.classExists("com.zaxxer.hikari.HikariConfig"); + private final String testSection = + "h2:\n" + + "\tpattern: .*\n" + + "\tfile: ./plugins/Skript/variables\n" + + "\tbackup interval: 0"; + + private H2Storage database; + + @Before + public void setup() { + if (!ENABLED) + return; + Config config; + try { + config = new Config(new ByteArrayInputStream(testSection.getBytes(ConfigReader.UTF_8)), "h2-junit.sk", false, false, ":"); + } catch (IOException e) { + e.printStackTrace(); + return; + } + assertTrue(config != null); + StorageAccessor.clearVariableStorages(); + database = new H2Storage(Skript.getAddonInstance(), "H2"); + SectionNode section = new SectionNode("h2", "", config.getMainNode(), 0); + section.add(new EntryNode("pattern", ".*", section)); + section.add(new EntryNode("file", "./plugins/Skript/variables", section)); + section.add(new EntryNode("backup interval", "0", section)); + assertTrue(database.loadConfig(section)); + } + + @Test + public void testStorage() throws SQLException, InterruptedException, ExecutionException, TimeoutException { + if (!ENABLED) + return; + synchronized (database) { + assertTrue(database.save("testing", "string", Classes.serialize("Hello World!").data)); +// SerializedVariable result = database.executeTestQuery(); +// assertTrue(result != null); + } + } + +} diff --git a/src/test/java/org/skriptlang/skript/variables/storage/SQLiteStorageTest.java b/src/test/java/org/skriptlang/skript/variables/storage/SQLiteStorageTest.java new file mode 100644 index 00000000000..e2b2f9e6286 --- /dev/null +++ b/src/test/java/org/skriptlang/skript/variables/storage/SQLiteStorageTest.java @@ -0,0 +1,67 @@ +package org.skriptlang.skript.variables.storage; + +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.sql.SQLException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.junit.Before; +import org.junit.Test; +import ch.njol.skript.Skript; +import ch.njol.skript.config.Config; +import ch.njol.skript.config.ConfigReader; +import ch.njol.skript.config.EntryNode; +import ch.njol.skript.config.SectionNode; +import ch.njol.skript.registrations.Classes; +import ch.njol.skript.variables.StorageAccessor; + +public class SQLiteStorageTest { + + private static final boolean ENABLED = Skript.classExists("com.zaxxer.hikari.HikariConfig"); + private final String testSection = + "sqlite:\n" + + "\tpattern: .*\n" + + "\tmonitor interval: 30 seconds\n" + + "\tfile: ./plugins/Skript/variables.db\n" + + "\tbackup interval: 0"; + + private SQLiteStorage database; + + @Before + public void setup() { + if (!ENABLED) + return; + Config config; + try { + config = new Config(new ByteArrayInputStream(testSection.getBytes(ConfigReader.UTF_8)), "sqlite-junit.sk", false, false, ":"); + } catch (IOException e) { + e.printStackTrace(); + return; + } + assertTrue(config != null); + StorageAccessor.clearVariableStorages(); + database = new SQLiteStorage(Skript.getAddonInstance(), "H2"); + SectionNode section = new SectionNode("sqlite", "", config.getMainNode(), 0); + section.add(new EntryNode("pattern", ".*", section)); + section.add(new EntryNode("monitor interval", "30 seconds", section)); + section.add(new EntryNode("file", "./plugins/Skript/variables.db", section)); + section.add(new EntryNode("backup interval", "0", section)); + assertTrue(database.loadConfig(section)); + } + + @Test + public void testStorage() throws SQLException, InterruptedException, ExecutionException, TimeoutException { + if (!ENABLED) + return; + synchronized (database) { + assertTrue(database.save("testing", "string", Classes.serialize("Hello World!").data)); +// SerializedVariable result = database.executeTestQuery(); +// assertTrue(result != null); +// System.out.println(result.getName()); + } + } + +}