diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml
index 366d21c6c..4b6c4f656 100644
--- a/AdvancedCore/pom.xml
+++ b/AdvancedCore/pom.xml
@@ -387,6 +387,12 @@
5.22.0
test
+
+ org.xerial
+ sqlite-jdbc
+ 3.53.4.0
+ test
+
com.nickuc.login
api
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java
new file mode 100644
index 000000000..3fde49890
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java
@@ -0,0 +1,388 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.SQLDataException;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.simpleapi.sql.Column;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueBoolean;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+import com.bencodez.simpleapi.sql.data.DataValueString;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+
+final class JdbcSqlUserStorage implements SqlUserStorage {
+ enum Dialect {
+ SQLITE, MYSQL, POSTGRESQL;
+ static Dialect fromDbType(DbType type) {
+ return switch (Objects.requireNonNull(type, "type")) {
+ case MYSQL, MARIADB -> MYSQL;
+ case POSTGRESQL -> POSTGRESQL;
+ };
+ }
+ String quote(String identifier) {
+ if (identifier == null || identifier.isBlank() || identifier.indexOf('\0') >= 0) throw new IllegalArgumentException("SQL identifier cannot be blank or contain NUL");
+ String delimiter = this == POSTGRESQL ? "\"" : "`";
+ return delimiter + identifier.replace(delimiter, delimiter + delimiter) + delimiter;
+ }
+ void bindUuid(PreparedStatement statement, int index, UUID uuid) throws SQLException {
+ if (this == POSTGRESQL) statement.setObject(index, uuid); else statement.setString(index, uuid.toString());
+ }
+ }
+
+ private enum BooleanStorage { TEXT, NATIVE, NUMERIC, POSTGRES_BIT }
+ @FunctionalInterface interface ConnectionProvider { Connection open() throws SQLException; }
+
+ private final UserStorage storage;
+ private final UUID uuid;
+ private final String tableName;
+ private final SqlUserSchema schema;
+ private final ConnectionProvider connections;
+ private final Dialect dialect;
+ private final SqlBackendLogger logger;
+
+ JdbcSqlUserStorage(UserStorage storage, UUID uuid, String tableName, SqlUserSchema schema,
+ ConnectionProvider connections, Dialect dialect, SqlBackendLogger logger) {
+ this.storage = Objects.requireNonNull(storage, "storage");
+ this.uuid = Objects.requireNonNull(uuid, "uuid");
+ this.tableName = Objects.requireNonNull(tableName, "tableName");
+ this.schema = Objects.requireNonNull(schema, "schema");
+ this.connections = Objects.requireNonNull(connections, "connections");
+ this.dialect = Objects.requireNonNull(dialect, "dialect");
+ this.logger = Objects.requireNonNull(logger, "logger");
+ }
+
+ @Override public List readRow(UserStorage requestedStorage) {
+ requireStorage(requestedStorage);
+ String sql = "SELECT * FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=?";
+ try (Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql)) {
+ dialect.bindUuid(statement, 1, uuid);
+ try (ResultSet result = statement.executeQuery()) {
+ if (!result.next()) return new ArrayList<>();
+ ResultSetMetaData metadata = result.getMetaData();
+ ArrayList columns = new ArrayList<>(metadata.getColumnCount());
+ for (int i = 1; i <= metadata.getColumnCount(); i++) {
+ String name = metadata.getColumnLabel(i);
+ SqlUserSchema.ColumnDefinition definition = schema.column(name);
+ DataType type = definition == null ? DataType.STRING : definition.dataType();
+ Column column = new Column(definition == null ? name : definition.name(), type);
+ column.setValue(readValue(result, i, type));
+ columns.add(column);
+ }
+ return columns;
+ }
+ } catch (SQLException e) { throw failure("read user row", e); }
+ }
+
+ @Override public boolean contains(UserStorage requestedStorage) {
+ requireStorage(requestedStorage);
+ String sql = "SELECT 1 FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=? LIMIT 1";
+ try (Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql)) {
+ dialect.bindUuid(statement, 1, uuid);
+ try (ResultSet result = statement.executeQuery()) { return result.next(); }
+ } catch (SQLException e) { throw failure("check user row", e); }
+ }
+
+ @Override public void delete(UserStorage requestedStorage) {
+ requireStorage(requestedStorage);
+ String sql = "DELETE FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=?";
+ try (Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql)) {
+ dialect.bindUuid(statement, 1, uuid); statement.executeUpdate();
+ } catch (SQLException e) { throw failure("delete user row", e); }
+ }
+
+ @Override public void write(UserStorage requestedStorage, String key, DataValue value) {
+ requireStorage(requestedStorage);
+ if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(key)) throw new IllegalArgumentException("uuid is immutable through SqlUserStorage");
+ HashMap values = new HashMap<>(); values.put(key, value); writeValues(requestedStorage, values);
+ }
+
+ @Override public void writeValues(UserStorage requestedStorage, HashMap values) {
+ requireStorage(requestedStorage);
+ Objects.requireNonNull(values, "values");
+ Map updates = canonicalize(values);
+ if (updates.isEmpty()) return;
+ boolean committed = false;
+ try (Connection connection = connections.open()) {
+ boolean autoCommit = connection.getAutoCommit();
+ connection.setAutoCommit(false);
+ boolean transactionEnded = false;
+ Throwable transactionFailure = null;
+ try {
+ boolean updateExistingRow = ensureRow(connection, updates);
+ if (updateExistingRow) updateValues(connection, updates);
+ connection.commit(); committed = true; transactionEnded = true;
+ } catch (SQLException | RuntimeException | Error e) {
+ transactionFailure = e;
+ try { connection.rollback(); transactionEnded = true; }
+ catch (SQLException | RuntimeException rollbackFailure) { suppress(e, rollbackFailure); }
+ throw e;
+ } finally {
+ if (transactionEnded) {
+ try { connection.setAutoCommit(autoCommit); }
+ catch (SQLException | RuntimeException restoreFailure) {
+ if (transactionFailure != null) suppress(transactionFailure, restoreFailure); else committedCleanupFailure("restore auto-commit", restoreFailure);
+ }
+ }
+ }
+ } catch (SQLException e) {
+ if (committed) committedCleanupFailure("close SQL connection", e); else throw failure("write user values", e);
+ } catch (RuntimeException e) {
+ if (committed) committedCleanupFailure("close SQL connection", e); else throw e;
+ }
+ }
+
+ private Map canonicalize(Map values) {
+ Map updates = new LinkedHashMap<>();
+ for (Map.Entry entry : values.entrySet()) {
+ String key = Objects.requireNonNull(entry.getKey(), "key");
+ if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(key)) continue;
+ SqlUserSchema.ColumnDefinition definition = schema.column(key);
+ if (definition == null) throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key);
+ if (updates.containsKey(definition.name())) throw new IllegalArgumentException("Duplicate SQL column in write batch: " + definition.name());
+ updates.put(definition.name(), entry.getValue());
+ }
+ return updates;
+ }
+
+ private void committedCleanupFailure(String operation, Exception error) {
+ try { logger.warn("User values committed, but failed to " + operation + " for " + uuid, error); }
+ catch (RuntimeException loggingFailure) { suppress(error, loggingFailure); }
+ }
+ private static void suppress(Throwable primary, Throwable secondary) { if (primary != secondary) primary.addSuppressed(secondary); }
+
+ /** @return true when the row already existed and still needs the batch UPDATE. */
+ private boolean ensureRow(Connection connection, Map updates) throws SQLException {
+ if (dialect != Dialect.SQLITE && rowExists(connection)) return true;
+ Map definitions = retainedDefinitions(connection, updates);
+ StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN));
+ StringBuilder parameters = new StringBuilder("?");
+ for (String key : updates.keySet()) {
+ names.append(", ").append(quote(key));
+ parameters.append(", ").append(parameterExpression(definitions.get(key)));
+ }
+ String prefix = dialect == Dialect.SQLITE ? "INSERT OR IGNORE INTO " : "INSERT INTO ";
+ String sql = prefix + quote(tableName) + " (" + names + ") VALUES (" + parameters + ")";
+ if (dialect == Dialect.POSTGRESQL) sql += " ON CONFLICT (" + quote(SqlUserSchema.UUID_COLUMN) + ") DO NOTHING";
+ int inserted;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ dialect.bindUuid(statement, 1, uuid);
+ int index = 2;
+ for (Map.Entry entry : updates.entrySet()) bind(statement, index++, entry.getValue(), definitions.get(entry.getKey()));
+ inserted = statement.executeUpdate();
+ } catch (SQLException insertFailure) {
+ if (dialect == Dialect.MYSQL && isDuplicateKey(insertFailure) && rowExists(connection)) return true;
+ throw insertFailure;
+ }
+ if (inserted > 0) return false;
+ if (!rowExists(connection)) throw new SQLException("SQL user row was not created");
+ return true;
+ }
+
+ private boolean isDuplicateKey(SQLException failure) { return failure.getErrorCode() == 1062 || "23000".equals(failure.getSQLState()); }
+
+ private boolean rowExists(Connection connection) throws SQLException {
+ String sql = "SELECT 1 FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=? LIMIT 1" + (dialect == Dialect.SQLITE ? "" : " FOR UPDATE");
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ dialect.bindUuid(statement, 1, uuid);
+ try (ResultSet result = statement.executeQuery()) { return result.next(); }
+ }
+ }
+
+ private void updateValues(Connection connection, Map updates) throws SQLException {
+ Map definitions = retainedDefinitions(connection, updates);
+ StringBuilder sql = new StringBuilder("UPDATE ").append(quote(tableName)).append(" SET ");
+ boolean first = true;
+ for (String key : updates.keySet()) {
+ if (!first) sql.append(", ");
+ first = false;
+ sql.append(quote(key)).append('=').append(parameterExpression(definitions.get(key)));
+ }
+ sql.append(" WHERE ").append(quote(SqlUserSchema.UUID_COLUMN)).append("=?");
+ try (PreparedStatement statement = connection.prepareStatement(sql.toString())) {
+ int index = 1;
+ for (Map.Entry entry : updates.entrySet()) bind(statement, index++, entry.getValue(), definitions.get(entry.getKey()));
+ dialect.bindUuid(statement, index, uuid); statement.executeUpdate();
+ }
+ }
+
+ private void requireStorage(UserStorage requestedStorage) { if (requestedStorage != storage) throw new IllegalArgumentException("Storage mismatch: backend=" + storage + ", requested=" + requestedStorage); }
+
+ private DataValue readValue(ResultSet result, int index, DataType type) throws SQLException {
+ if (type == DataType.INTEGER) {
+ try { int value = result.getInt(index); return new DataValueInt(result.wasNull() ? 0 : value); }
+ catch (SQLException invalidInteger) {
+ String state = invalidInteger.getSQLState();
+ if (invalidInteger instanceof SQLDataException || (state != null && state.startsWith("22"))) return new DataValueInt(0);
+ throw invalidInteger;
+ }
+ }
+ if (type == DataType.BOOLEAN) {
+ String value = result.getString(index);
+ if (value == null) return new DataValueBoolean(false);
+ String normalized = value.strip();
+ if ("true".equalsIgnoreCase(normalized) || "t".equalsIgnoreCase(normalized)
+ || "yes".equalsIgnoreCase(normalized) || "y".equalsIgnoreCase(normalized)
+ || "on".equalsIgnoreCase(normalized)) return new DataValueBoolean(true);
+ if ("false".equalsIgnoreCase(normalized) || "f".equalsIgnoreCase(normalized)
+ || "no".equalsIgnoreCase(normalized) || "n".equalsIgnoreCase(normalized)
+ || "off".equalsIgnoreCase(normalized) || normalized.isEmpty()) return new DataValueBoolean(false);
+ try { return new DataValueBoolean(new java.math.BigDecimal(normalized).signum() != 0); }
+ catch (NumberFormatException ignored) { return new DataValueBoolean(false); }
+ }
+ return new DataValueString(result.getString(index));
+ }
+
+ private void bind(PreparedStatement statement, int index, DataValue value, SqlUserSchema.ColumnDefinition definition) throws SQLException {
+ if (value == null) statement.setObject(index, null);
+ else if (value.isString()) statement.setString(index, value.getString());
+ else if (value.isInt()) {
+ // PostgreSQL's unspecified parameter type is inferred from the target
+ // column. This preserves writes to both current integer columns and
+ // legacy text columns retained by schema discovery.
+ if (dialect == Dialect.POSTGRESQL) statement.setObject(index, Integer.toString(value.getInt()), Types.OTHER);
+ else statement.setInt(index, value.getInt());
+ }
+ else if (value.isBoolean()) {
+ BooleanStorage booleanStorage = booleanStorage(definition);
+ // PostgreSQL accepts an unspecified parameter using the retained
+ // target column's input function. This matters when an existing
+ // installation still has a legacy VARCHAR boolean column even
+ // though the current schema declaration is BOOLEAN: setBoolean
+ // sends a typed boolean parameter which PostgreSQL will not assign
+ // to VARCHAR. Keep BIT explicit below because its width is part of
+ // the value contract.
+ if (booleanStorage == BooleanStorage.NATIVE && dialect == Dialect.POSTGRESQL)
+ statement.setObject(index, Boolean.toString(value.getBoolean()), Types.OTHER);
+ else if (booleanStorage == BooleanStorage.NATIVE) statement.setBoolean(index, value.getBoolean());
+ else if (booleanStorage == BooleanStorage.NUMERIC) statement.setInt(index, value.getBoolean() ? 1 : 0);
+ else if (booleanStorage == BooleanStorage.POSTGRES_BIT) statement.setString(index, value.getBoolean() ? "1" : "0");
+ else statement.setString(index, Boolean.toString(value.getBoolean()));
+ } else statement.setObject(index, value.toString());
+ }
+
+ private String parameterExpression(SqlUserSchema.ColumnDefinition definition) {
+ BooleanStorage storage = booleanStorage(definition);
+ if (storage != BooleanStorage.POSTGRES_BIT) return "?";
+ return "CAST(? AS " + postgresBitType(definition) + ")";
+ }
+
+ private Map retainedDefinitions(Connection connection, Map updates) throws SQLException {
+ Map definitions = new HashMap<>();
+ for (String key : updates.keySet()) {
+ SqlUserSchema.ColumnDefinition definition = schema.column(key);
+ definitions.put(key, retainedDefinition(connection, definition));
+ }
+ return definitions;
+ }
+
+ private SqlUserSchema.ColumnDefinition retainedDefinition(Connection connection, SqlUserSchema.ColumnDefinition definition) throws SQLException {
+ if (definition == null || definition.dataType() != DataType.BOOLEAN) return definition;
+ java.sql.DatabaseMetaData metadata = connection.getMetaData();
+ if (metadata == null) return definition;
+ String catalog = dialect == Dialect.POSTGRESQL ? null : connection.getCatalog();
+ try (ResultSet columns = metadata.getColumns(catalog, metadataPattern(metadata, metadataSchema(connection)),
+ metadataPattern(metadata, tableName), metadataPattern(metadata, definition.name()))) {
+ if (columns.next()) {
+ String type = columns.getString("TYPE_NAME");
+ if (type != null && !type.isBlank()) {
+ String normalized = type.strip().toUpperCase(Locale.ROOT);
+ if (dialect == Dialect.POSTGRESQL
+ && (startsType(normalized, "BIT") || startsType(normalized, "VARBIT"))) {
+ int width = columns.getInt("COLUMN_SIZE");
+ if (columns.wasNull() || width <= 0) width = 1;
+ // PgJDBC exposes this as VARBIT on supported versions, but
+ // accept the SQL spelling too so preserving a legacy column
+ // does not accidentally turn BIT VARYING(n) into BIT(n).
+ String retainedType = postgresVaryingBit(normalized)
+ ? "BIT VARYING(" + width + ")" : "BIT(" + width + ")";
+ return new SqlUserSchema.ColumnDefinition(definition.name(), retainedType, DataType.BOOLEAN);
+ }
+ // The current logical schema may say BOOLEAN while an existing
+ // server still has a VARCHAR or numeric column. Bind according
+ // to the retained physical type instead of sending a typed
+ // boolean that PostgreSQL cannot assign to that column.
+ return new SqlUserSchema.ColumnDefinition(definition.name(), type, DataType.BOOLEAN);
+ }
+ }
+ }
+ return definition;
+ }
+
+ /**
+ * JDBC metadata accepts SQL LIKE patterns rather than exact identifiers.
+ * Treat these user-table identifiers literally so an underscore or percent
+ * sign cannot select a similarly named table/column's physical type.
+ */
+ private String metadataPattern(java.sql.DatabaseMetaData metadata, String identifier) throws SQLException {
+ if (identifier == null) return null;
+ String escape = metadata.getSearchStringEscape();
+ if (escape == null || escape.isEmpty()) return identifier;
+ return identifier.replace(escape, escape + escape)
+ .replace("_", escape + "_")
+ .replace("%", escape + "%");
+ }
+
+ private boolean postgresVaryingBit(String normalizedType) {
+ return startsType(normalizedType, "VARBIT") || normalizedType.matches("^BIT\\s+VARYING(?:\\s*\\(\\s*\\d+\\s*\\))?(?:\\s+.*)?$");
+ }
+
+ private String metadataSchema(Connection connection) throws SQLException {
+ if (dialect != Dialect.POSTGRESQL) return null;
+ String regclass = '"' + tableName.replace("\"", "\"\"") + '"';
+ String sql = "SELECT n.nspname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n "
+ + "ON n.oid=c.relnamespace WHERE c.oid=pg_catalog.to_regclass(?)";
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, regclass);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? result.getString(1) : null;
+ }
+ }
+ }
+
+ private String postgresBitType(SqlUserSchema.ColumnDefinition definition) {
+ String sqlType = definition.sqlType().strip();
+ java.util.regex.Matcher varbit = java.util.regex.Pattern
+ .compile("(?i)^VARBIT(?:\\s*\\(\\s*(\\d+)\\s*\\))?(?:\\s+.*)?$").matcher(sqlType);
+ if (varbit.matches()) return "BIT VARYING" + (varbit.group(1) == null ? "" : "(" + varbit.group(1) + ")");
+ java.util.regex.Matcher type = java.util.regex.Pattern
+ .compile("(?i)^BIT(\\s+VARYING)?(?:\\s*\\(\\s*(\\d+)\\s*\\))?(?:\\s+.*)?$")
+ .matcher(sqlType);
+ if (!type.matches()) {
+ throw new IllegalArgumentException("Unsupported PostgreSQL bit type: " + definition.sqlType());
+ }
+ return (type.group(1) == null ? "BIT" : "BIT VARYING")
+ + (type.group(2) == null ? "" : "(" + type.group(2) + ")");
+ }
+
+ private BooleanStorage booleanStorage(SqlUserSchema.ColumnDefinition definition) {
+ if (definition == null || definition.dataType() != DataType.BOOLEAN) return BooleanStorage.TEXT;
+ String sqlType = definition.sqlType().strip().toUpperCase(Locale.ROOT);
+ if (startsType(sqlType, "BOOLEAN") || startsType(sqlType, "BOOL")) return BooleanStorage.NATIVE;
+ if (startsType(sqlType, "BIT") || startsType(sqlType, "VARBIT")) return dialect == Dialect.POSTGRESQL ? BooleanStorage.POSTGRES_BIT : BooleanStorage.NUMERIC;
+ if (startsType(sqlType, "TINYINT") || startsType(sqlType, "SMALLINT") || startsType(sqlType, "MEDIUMINT") || startsType(sqlType, "INT") || startsType(sqlType, "INTEGER") || startsType(sqlType, "BIGINT")) return BooleanStorage.NUMERIC;
+ return BooleanStorage.TEXT;
+ }
+ private boolean startsType(String sqlType, String type) { if (!sqlType.startsWith(type)) return false; if (sqlType.length() == type.length()) return true; char next = sqlType.charAt(type.length()); return Character.isWhitespace(next) || next == '('; }
+ private String quote(String identifier) { return dialect.quote(identifier); }
+ private IllegalStateException failure(String operation, SQLException error) {
+ try { logger.warn("Failed to " + operation + " for " + uuid, error); }
+ catch (RuntimeException loggingFailure) { suppress(error, loggingFailure); }
+ return new IllegalStateException("Failed to " + operation + " for " + uuid, error);
+ }
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java
new file mode 100644
index 000000000..24cd41307
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java
@@ -0,0 +1,452 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.simpleapi.sql.Column;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.mysql.AbstractSqlTable;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+public final class MysqlUserBackend implements SqlUserBackend {
+ private static final int USER_PAGE_SIZE = 512;
+ private final SqlUserSchema schema;
+ private final SqlBackendLogger logger;
+ private final HeadlessUserTable table;
+ private final AtomicBoolean open = new AtomicBoolean(true);
+ private final AtomicBoolean invalidUuidWarningLogged = new AtomicBoolean();
+ private final ReentrantReadWriteLock operations = new ReentrantReadWriteLock(true);
+ private volatile boolean tableClosed;
+
+ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema schema, SqlBackendLogger logger) {
+ this.schema = Objects.requireNonNull(schema, "schema");
+ this.logger = logger == null ? SqlBackendLogger.NO_OP : logger;
+ this.table = new HeadlessUserTable(baseTableName, Objects.requireNonNull(config, "config"), schema, this.logger);
+ try { ensureRegisteredColumns(); }
+ catch (RuntimeException | Error failure) {
+ open.set(false);
+ try { table.close(); tableClosed = true; }
+ catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); }
+ throw failure;
+ }
+ }
+
+ @Override public UserStorage storageType() { return UserStorage.MYSQL; }
+
+ @Override
+ public SqlUserStorage user(UUID uuid) {
+ requireAdmissionOpen();
+ JdbcSqlUserStorage.Dialect dialect = JdbcSqlUserStorage.Dialect.fromDbType(table.getMysql().getConnectionManager().getDbType());
+ SqlUserStorage delegate = new JdbcSqlUserStorage(UserStorage.MYSQL, uuid, table.getTableName(), schema,
+ () -> table.getMysql().getConnectionManager().getConnection(), dialect, logger);
+ return new SqlUserStorage() {
+ @Override public List readRow(UserStorage storage) { return withOperation(() -> delegate.readRow(storage)); }
+ @Override public boolean contains(UserStorage storage) { return withOperation(() -> delegate.contains(storage)); }
+ @Override public void delete(UserStorage storage) { withOperation(() -> { delegate.delete(storage); return null; }); }
+ @Override public void write(UserStorage storage, String key, DataValue value) { withOperation(() -> { delegate.write(storage, key, value); return null; }); }
+ @Override public void writeValues(UserStorage storage, HashMap values) { withOperation(() -> { delegate.writeValues(storage, values); return null; }); }
+ };
+ }
+
+ @Override public List enumerateUsers() {
+ ArrayList users = new ArrayList<>();
+ forEachUser(uuid -> {
+ if (users.size() >= MAX_MATERIALIZED_USERS) throw new IllegalStateException("User enumeration exceeds " + MAX_MATERIALIZED_USERS + " entries; use forEachUser for streaming access");
+ users.add(uuid);
+ });
+ return users;
+ }
+
+ @Override public void forEachUser(Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer");
+ withOperation(() -> {
+ String cursor = null;
+ while (true) {
+ List page = readUserPage(cursor);
+ if (page.isEmpty()) return null;
+ cursor = page.get(page.size() - 1).cursor();
+ for (UserPageEntry entry : page) if (entry.uuid() != null) consumer.accept(entry.uuid());
+ if (page.size() < USER_PAGE_SIZE) return null;
+ }
+ });
+ }
+
+ private List readUserPage(String cursor) {
+ String uuidColumn = table.quote(SqlUserSchema.UUID_COLUMN);
+ String sql = "SELECT " + uuidColumn + " FROM " + table.quote(table.getTableName())
+ + " WHERE " + uuidColumn + " IS NOT NULL"
+ + (cursor == null ? "" : " AND " + uuidColumn + " > ?")
+ + " ORDER BY " + uuidColumn + " ASC LIMIT ?";
+ JdbcSqlUserStorage.Dialect dialect = JdbcSqlUserStorage.Dialect.fromDbType(table.getDbType());
+ try (Connection connection = table.getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) {
+ int index = 1;
+ if (cursor != null) {
+ if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) dialect.bindUuid(statement, index++, UUID.fromString(cursor)); else statement.setString(index++, cursor);
+ }
+ statement.setInt(index, USER_PAGE_SIZE);
+ ArrayList page = new ArrayList<>(USER_PAGE_SIZE);
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ String value = result.getString(1);
+ if (value == null) continue;
+ UUID parsed = null;
+ try {
+ parsed = UUID.fromString(value);
+ if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Non-canonical UUID");
+ }
+ catch (IllegalArgumentException invalid) {
+ parsed = null;
+ if (invalidUuidWarningLogged.compareAndSet(false, true)) {
+ logger.warn("Skipping malformed UUID entries while enumerating SQL users; further diagnostics suppressed",
+ new IllegalArgumentException("Malformed SQL UUID value"));
+ }
+ }
+ page.add(new UserPageEntry(value, parsed));
+ }
+ }
+ return page;
+ } catch (IllegalArgumentException invalidCursor) { throw new IllegalStateException("Failed to advance SQL user enumeration cursor", invalidCursor); }
+ catch (SQLException failure) { throw new IllegalStateException("Failed to enumerate MySQL users", failure); }
+ }
+
+ private record UserPageEntry(String cursor, UUID uuid) {}
+
+ @Override public boolean isOpen() { return open.get(); }
+
+ @Override public void close() {
+ if (operations.getReadHoldCount() != 0) throw new IllegalStateException("Cannot close MySQL from inside an active storage operation");
+ open.set(false);
+ operations.writeLock().lock();
+ try {
+ if (!tableClosed) { table.close(); tableClosed = true; }
+ } finally { operations.writeLock().unlock(); }
+ }
+
+ private T withOperation(Supplier operation) {
+ requireAdmissionOpen();
+ operations.readLock().lock();
+ try {
+ requireAdmissionOpen();
+ return operation.get();
+ } finally { operations.readLock().unlock(); }
+ }
+
+ private void ensureRegisteredColumns() {
+ table.ensureUuidType();
+ table.ensureUuidUnique();
+ for (SqlUserSchema.ColumnDefinition column : schema.columns()) if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column);
+ }
+
+ private void requireAdmissionOpen() {
+ if (!open.get()) throw new IllegalStateException("MySQL user backend is closed");
+ }
+
+ private static final class HeadlessUserTable extends AbstractSqlTable {
+ private final SqlUserSchema schema;
+ private final SqlBackendLogger logger;
+
+ HeadlessUserTable(String baseTableName, MysqlConfig config, SqlUserSchema schema, SqlBackendLogger logger) {
+ super(baseTableName, config, config.isDebug(), true);
+ this.schema = schema;
+ this.logger = logger;
+ try { init(); }
+ catch (RuntimeException | Error failure) {
+ try { if (getMysql() != null) getMysql().disconnect(); }
+ catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); }
+ throw failure;
+ }
+ }
+
+ @Override public String getPrimaryKeyColumn() { return SqlUserSchema.UUID_COLUMN; }
+ @Override public String buildCreateTableSql(DbType dbType) {
+ StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS ").append(quote(tableName)).append(" (");
+ boolean first = true;
+ for (SqlUserSchema.ColumnDefinition column : schema.columns()) {
+ if (!first) sql.append(", "); first = false;
+ String type = SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name()) ? bestUuidType() : normaliseTypeForDb(column.sqlType());
+ sql.append(quote(column.name())).append(' ').append(type);
+ }
+ sql.append(", PRIMARY KEY (").append(quote(SqlUserSchema.UUID_COLUMN)).append("));");
+ return sql.toString();
+ }
+
+ @Override public void logSevere(String message) { logger.warn(message, null); }
+ @Override public void logInfo(String message) { logger.info(message); }
+ @Override public void debug(Throwable error) { logger.warn(error == null ? "SQL debug" : error.getMessage(), error); }
+ @Override public void debug(String message) { logger.info(message); }
+
+ void ensureUuidType() {
+ try {
+ String uuidType = bestUuidType();
+ if (!columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) return;
+ String uuidColumn = quote(SqlUserSchema.UUID_COLUMN);
+ String sql = getDbType() == DbType.POSTGRESQL
+ ? "ALTER TABLE " + quote(tableName) + " ALTER COLUMN " + uuidColumn + " TYPE "
+ + uuidType + " USING NULLIF(" + uuidColumn + ", '')::uuid;"
+ : "ALTER TABLE " + quote(tableName) + " MODIFY " + uuidColumn + " "
+ + normaliseTypeForDb(uuidType) + ";";
+ try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); }
+ catch (SQLException ddlFailure) {
+ try { if (columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) throw ddlFailure; }
+ catch (SQLException inspectionFailure) {
+ if (inspectionFailure != ddlFailure) ddlFailure.addSuppressed(inspectionFailure);
+ throw ddlFailure;
+ }
+ }
+ } catch (SQLException failure) { throw new IllegalStateException("Failed to initialize SQL UUID column", failure); }
+ }
+
+ void ensureUuidUnique() {
+ try (Connection connection = getMysql().getConnectionManager().getConnection()) {
+ java.sql.DatabaseMetaData metadata = connection.getMetaData();
+ if (metadata == null || hasUniqueUuidConstraint(connection, metadata)) return;
+ String indexName = tableName + "_uuid_unique";
+ String sql = "ALTER TABLE " + quote(tableName) + " ADD CONSTRAINT " + quote(indexName)
+ + " UNIQUE (" + quote(SqlUserSchema.UUID_COLUMN) + ")";
+ try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); }
+ catch (SQLException ddlFailure) {
+ // Another opener may have created the constraint after our metadata
+ // check. Re-inspect the database before treating that race as fatal.
+ if (!hasUniqueUuidConstraint(connection, connection.getMetaData())) throw ddlFailure;
+ }
+ } catch (SQLException failure) {
+ throw new IllegalStateException("Failed to initialize SQL UUID uniqueness", failure);
+ }
+ }
+
+ private boolean hasUniqueUuidConstraint(Connection connection, java.sql.DatabaseMetaData metadata) throws SQLException {
+ String catalog = metadata.getConnection() == null ? null : metadata.getConnection().getCatalog();
+ String schema = resolvedMetadataSchema(connection, metadata);
+ try (ResultSet keys = metadata.getPrimaryKeys(catalog, schema, tableName)) {
+ String keyName = null; int count = 0; boolean uuid = false;
+ while (keys.next()) { keyName = keys.getString("PK_NAME"); count++; uuid |= SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(keys.getString("COLUMN_NAME")); }
+ if (count == 1 && uuid) return true;
+ }
+ try (ResultSet indexes = metadata.getIndexInfo(catalog, schema, tableName, true, false)) {
+ Map counts = new HashMap<>(); Map uuids = new HashMap<>();
+ while (indexes.next()) {
+ String name = indexes.getString("INDEX_NAME");
+ if (name == null) continue;
+ // PostgreSQL exposes partial indexes through FILTER_CONDITION. They
+ // only constrain a subset of rows and cannot protect user identity.
+ if (indexes.getString("FILTER_CONDITION") != null) continue;
+ String column = indexes.getString("COLUMN_NAME");
+ counts.merge(name, 1, Integer::sum);
+ uuids.merge(name, column != null && SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column), Boolean::logicalOr);
+ }
+ for (String name : counts.keySet()) if (counts.get(name) == 1 && uuids.getOrDefault(name, false)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * PostgreSQL treats a null schema in DatabaseMetaData calls as a wildcard.
+ * Resolve the table selected by search_path first, otherwise a same-named
+ * table in another schema can make us accept the wrong uniqueness metadata.
+ */
+ private String resolvedMetadataSchema(Connection connection, java.sql.DatabaseMetaData metadata) throws SQLException {
+ if (getDbType() != DbType.POSTGRESQL) return null;
+ try (PreparedStatement statement = connection.prepareStatement(
+ "SELECT table_schema FROM information_schema.tables "
+ + "WHERE table_name=? AND table_schema=ANY(current_schemas(false)) "
+ + "ORDER BY array_position(current_schemas(false), table_schema) LIMIT 1")) {
+ statement.setString(1, tableName);
+ try (ResultSet result = statement.executeQuery()) {
+ if (result.next()) return result.getString(1);
+ }
+ }
+ // Views and unusual metadata implementations may not appear in
+ // information_schema.tables; getSchema is still narrower than null.
+ String current = connection.getSchema();
+ return current == null || current.isBlank() ? metadata.getUserName() : current;
+ }
+
+ void ensureColumn(SqlUserSchema.ColumnDefinition column) {
+ synchronized (checkColumnLock) {
+ try {
+ String storedName = findRegisteredColumn(column.name());
+ if (storedName != null) {
+ if (column.dataType() == DataType.STRING)
+ migrateRetainedColumnToString(storedName, column);
+ if (getDbType() == DbType.POSTGRESQL && !storedName.equals(column.name())) renamePostgresColumn(storedName, column.name());
+ rememberColumn(column); return;
+ }
+ String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + " " + normaliseTypeForDb(column.sqlType()) + ";";
+ try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); }
+ catch (SQLException ddlFailure) {
+ if (!isDuplicateColumn(ddlFailure)) throw ddlFailure;
+ try {
+ String raced = findRegisteredColumn(column.name());
+ if (raced == null) throw ddlFailure;
+ if (column.dataType() == DataType.STRING)
+ migrateRetainedColumnToString(raced, column);
+ if (getDbType() == DbType.POSTGRESQL && !raced.equals(column.name())) renamePostgresColumn(raced, column.name());
+ } catch (SQLException inspectionFailure) {
+ if (inspectionFailure != ddlFailure) ddlFailure.addSuppressed(inspectionFailure);
+ throw ddlFailure;
+ }
+ }
+ rememberColumn(column);
+ } catch (SQLException failure) { throw new IllegalStateException("Failed to initialize registered SQL column: " + column.name(), failure); }
+ }
+ }
+
+ private void renamePostgresColumn(String storedName, String requestedName) throws SQLException {
+ String sql = "ALTER TABLE " + quote(tableName) + " RENAME COLUMN " + quote(storedName) + " TO " + quote(requestedName) + ";";
+ try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); }
+ catch (SQLException renameFailure) { String current = findRegisteredColumn(requestedName); if (!requestedName.equals(current)) throw renameFailure; }
+ }
+
+ private void migrateRetainedColumnToString(String storedName,
+ SqlUserSchema.ColumnDefinition definition) throws SQLException {
+ int jdbcType = registeredColumnType(storedName);
+ if (jdbcType != java.sql.Types.TINYINT && jdbcType != java.sql.Types.SMALLINT
+ && jdbcType != java.sql.Types.INTEGER && jdbcType != java.sql.Types.BIGINT
+ && jdbcType != java.sql.Types.REAL && jdbcType != java.sql.Types.FLOAT
+ && jdbcType != java.sql.Types.DOUBLE && jdbcType != java.sql.Types.NUMERIC
+ && jdbcType != java.sql.Types.DECIMAL && jdbcType != java.sql.Types.BOOLEAN
+ && jdbcType != java.sql.Types.BIT) return;
+ String column = quote(storedName);
+ if (getDbType() != DbType.POSTGRESQL) {
+ MysqlColumnAttributes attributes = mysqlColumnAttributes(storedName);
+ if (attributes.extra() != null && !attributes.extra().isBlank()) {
+ throw new SQLException("Cannot safely migrate SQL column with generated or automatic attributes: "
+ + storedName);
+ }
+ String sql = "ALTER TABLE " + quote(tableName) + " MODIFY COLUMN " + column + " "
+ + normaliseTypeForDb(definition.sqlType())
+ + (attributes.nullable() ? " NULL" : " NOT NULL")
+ + (attributes.defaultValue() == null ? ""
+ : " DEFAULT '" + quoteMysqlLiteral(attributes.defaultValue()) + "'")
+ + (attributes.comment() == null || attributes.comment().isEmpty() ? ""
+ : " COMMENT '" + quoteMysqlLiteral(attributes.comment()) + "'")
+ + ";";
+ try (Connection connection = getMysql().getConnectionManager().getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.executeUpdate();
+ }
+ return;
+ }
+ String defaultExpression = postgresColumnDefault(storedName);
+ StringBuilder sql = new StringBuilder("ALTER TABLE ").append(quote(tableName));
+ // PostgreSQL does not apply TYPE ... USING to a column default. Drop and
+ // recreate it in the same transactional ALTER TABLE so a legacy numeric
+ // DEFAULT does not make an otherwise-safe value conversion fail.
+ if (defaultExpression != null) sql.append(" ALTER COLUMN ").append(column).append(" DROP DEFAULT,");
+ sql.append(" ALTER COLUMN ").append(column).append(" TYPE ")
+ .append(normaliseTypeForDb(definition.sqlType())).append(" USING ").append(column).append("::text");
+ if (defaultExpression != null) sql.append(", ALTER COLUMN ").append(column)
+ .append(" SET DEFAULT (").append(defaultExpression).append(")::text");
+ sql.append(';');
+ try (Connection connection = getMysql().getConnectionManager().getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql.toString())) {
+ statement.executeUpdate();
+ }
+ }
+
+ private MysqlColumnAttributes mysqlColumnAttributes(String name) throws SQLException {
+ String sql = "SELECT IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT "
+ + "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?";
+ try (Connection connection = getMysql().getConnectionManager().getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, tableName);
+ statement.setString(2, name);
+ try (ResultSet result = statement.executeQuery()) {
+ if (!result.next()) throw new SQLException(
+ "Registered SQL column disappeared during attribute inspection: " + name);
+ String nullable = result.getString(1);
+ if (!"YES".equalsIgnoreCase(nullable) && !"NO".equalsIgnoreCase(nullable)) {
+ throw new SQLException("Cannot determine SQL column nullability during migration: " + name);
+ }
+ String defaultValue = result.getString(2);
+ if (defaultValue != null && !defaultValue.matches(
+ "(?i)(?:true|false|[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?)")) {
+ throw new SQLException("Cannot safely preserve SQL column default during migration: " + name);
+ }
+ String comment = result.getString(4);
+ if (comment != null && comment.indexOf('\\') >= 0) {
+ throw new SQLException("Cannot safely preserve SQL column comment during migration: " + name);
+ }
+ return new MysqlColumnAttributes("YES".equalsIgnoreCase(nullable),
+ defaultValue, result.getString(3), comment);
+ }
+ }
+ }
+
+ private static String quoteMysqlLiteral(String value) {
+ return value.replace("'", "''");
+ }
+
+ private record MysqlColumnAttributes(boolean nullable, String defaultValue, String extra, String comment) { }
+
+ private String postgresColumnDefault(String name) throws SQLException {
+ String regclass = '"' + tableName.replace("\"", "\"\"") + '"';
+ String sql = "SELECT pg_catalog.pg_get_expr(default_value.adbin, default_value.adrelid) "
+ + "FROM pg_catalog.pg_attribute attribute "
+ + "LEFT JOIN pg_catalog.pg_attrdef default_value ON default_value.adrelid=attribute.attrelid "
+ + "AND default_value.adnum=attribute.attnum "
+ + "WHERE attribute.attrelid=pg_catalog.to_regclass(?) AND attribute.attname=? "
+ + "AND attribute.attnum>0 AND NOT attribute.attisdropped";
+ try (Connection connection = getMysql().getConnectionManager().getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, regclass);
+ statement.setString(2, name);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? result.getString(1) : null;
+ }
+ }
+ }
+
+ private int registeredColumnType(String name) throws SQLException {
+ try (Connection connection = getMysql().getConnectionManager().getConnection();
+ PreparedStatement statement = connection.prepareStatement(
+ "SELECT * FROM " + quote(tableName) + " WHERE 1=0");
+ ResultSet result = statement.executeQuery()) {
+ ResultSetMetaData metadata = result.getMetaData();
+ for (int i = 1; i <= metadata.getColumnCount(); i++) {
+ if (name.equals(metadata.getColumnName(i))) return metadata.getColumnType(i);
+ }
+ throw new SQLException("Registered SQL column disappeared during type inspection: " + name);
+ }
+ }
+
+ private void rememberColumn(SqlUserSchema.ColumnDefinition column) {
+ columns.removeIf(existing -> existing.equalsIgnoreCase(column.name())); columns.add(column.name());
+ intColumns.removeIf(existing -> existing.equalsIgnoreCase(column.name())); if (column.dataType() == DataType.INTEGER) intColumns.add(column.name());
+ }
+
+ private String findRegisteredColumn(String name) throws SQLException {
+ try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT * FROM " + quote(tableName) + " WHERE 1=0"); ResultSet result = statement.executeQuery()) {
+ ResultSetMetaData metadata = result.getMetaData(); String exactMatch = null; String foldedMatch = null; int foldedMatches = 0;
+ for (int i = 1; i <= metadata.getColumnCount(); i++) {
+ String storedName = metadata.getColumnName(i);
+ if (name.equals(storedName)) exactMatch = storedName;
+ if (name.equalsIgnoreCase(storedName)) { foldedMatches++; if (foldedMatch == null) foldedMatch = storedName; }
+ }
+ if (getDbType() == DbType.POSTGRESQL && foldedMatches > 1)
+ throw new SQLException("Ambiguous case-folded SQL columns for registered name: " + name);
+ return exactMatch == null ? foldedMatch : exactMatch;
+ }
+ }
+
+ private boolean isDuplicateColumn(SQLException failure) { return getDbType() == DbType.POSTGRESQL ? "42701".equals(failure.getSQLState()) : failure.getErrorCode() == 1060 && "42S21".equals(failure.getSQLState()); }
+ String quote(String identifier) { return qi(identifier); }
+ }
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlBackendLogger.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlBackendLogger.java
new file mode 100644
index 000000000..d7edff211
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlBackendLogger.java
@@ -0,0 +1,12 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+public interface SqlBackendLogger {
+ SqlBackendLogger NO_OP = new SqlBackendLogger() {
+ @Override public void info(String message) {}
+ @Override public void warn(String message, Throwable error) {}
+ };
+
+ void info(String message);
+
+ void warn(String message, Throwable error);
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java
new file mode 100644
index 000000000..20062bbe5
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java
@@ -0,0 +1,34 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+
+public interface SqlUserBackend extends AutoCloseable {
+ /** Materialized compatibility API is intentionally bounded by concrete SQL backends. */
+ int MAX_MATERIALIZED_USERS = 100_000;
+
+ UserStorage storageType();
+
+ SqlUserStorage user(UUID uuid);
+
+ List enumerateUsers();
+
+ /**
+ * Streaming enumeration for large historical user tables. Concrete SQL backends
+ * override this to avoid first materializing every UUID in heap.
+ */
+ default void forEachUser(Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer");
+ enumerateUsers().forEach(consumer);
+ }
+
+ boolean isOpen();
+
+ @Override
+ void close();
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackendFactory.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackendFactory.java
new file mode 100644
index 000000000..114ff26de
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackendFactory.java
@@ -0,0 +1,30 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Objects;
+
+import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+/**
+ * Constructs SQL user backends from explicit platform-neutral inputs. Native
+ * platform adapters are responsible for translating their configuration and
+ * data-directory concepts before calling this factory.
+ */
+public final class SqlUserBackendFactory {
+ private SqlUserBackendFactory() {
+ }
+
+ public static SqliteUserBackend sqlite(Path dataDirectory, String databaseName, String tableName,
+ Collection extends UserDataKey> keys, SqlBackendLogger logger) {
+ Objects.requireNonNull(keys, "keys");
+ return new SqliteUserBackend(dataDirectory, databaseName, tableName, SqlUserSchema.fromKeys(keys), logger);
+ }
+
+ public static MysqlUserBackend mysql(String baseTableName, MysqlConfig config,
+ Collection extends UserDataKey> keys, SqlBackendLogger logger) {
+ Objects.requireNonNull(keys, "keys");
+ return new MysqlUserBackend(baseTableName, config, SqlUserSchema.fromKeys(keys), logger);
+ }
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchema.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchema.java
new file mode 100644
index 000000000..842a7b9b9
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchema.java
@@ -0,0 +1,81 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey;
+import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyBoolean;
+import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt;
+import com.bencodez.simpleapi.sql.DataType;
+
+public final class SqlUserSchema {
+ public static final String UUID_COLUMN = "uuid";
+
+ public record ColumnDefinition(String name, String sqlType, DataType dataType) {
+ public ColumnDefinition {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(sqlType, "sqlType");
+ Objects.requireNonNull(dataType, "dataType");
+ if (name.isBlank()) throw new IllegalArgumentException("Column name cannot be blank");
+ if (sqlType.isBlank()) throw new IllegalArgumentException("SQL type cannot be blank");
+ }
+ }
+
+ private final Map columnsByLowerName;
+
+ private SqlUserSchema(Map columnsByLowerName) {
+ this.columnsByLowerName = Collections.unmodifiableMap(new LinkedHashMap<>(columnsByLowerName));
+ }
+
+ public static Builder builder() { return new Builder(); }
+
+ public static SqlUserSchema fromKeys(Collection extends UserDataKey> keys) {
+ Builder builder = builder();
+ for (UserDataKey key : Objects.requireNonNull(keys, "keys")) {
+ DataType type = DataType.STRING;
+ if (key instanceof UserDataKeyInt) type = DataType.INTEGER;
+ else if (key instanceof UserDataKeyBoolean) type = DataType.BOOLEAN;
+ builder.column(key.getKey(), key.getColumnType(), type);
+ }
+ return builder.build();
+ }
+
+ public List columns() { return new ArrayList<>(columnsByLowerName.values()); }
+
+ public ColumnDefinition column(String name) {
+ if (name == null) return null;
+ return columnsByLowerName.get(name.toLowerCase(Locale.ROOT));
+ }
+
+ public boolean contains(String name) { return column(name) != null; }
+
+ public static final class Builder {
+ private final Map columns = new LinkedHashMap<>();
+
+ private Builder() {
+ columns.put(UUID_COLUMN, new ColumnDefinition(UUID_COLUMN, "VARCHAR(37)", DataType.STRING));
+ }
+
+ public Builder column(String name, String sqlType, DataType dataType) {
+ Objects.requireNonNull(name, "name");
+ if (UUID_COLUMN.equalsIgnoreCase(name)) {
+ throw new IllegalArgumentException("Column name 'uuid' is reserved for user identity");
+ }
+ String canonical = name.toLowerCase(Locale.ROOT);
+ ColumnDefinition existing = columns.get(canonical);
+ if (existing != null) {
+ throw new IllegalArgumentException("Duplicate SQL column name: " + name + " conflicts with " + existing.name());
+ }
+ columns.put(canonical, new ColumnDefinition(name, sqlType, dataType));
+ return this;
+ }
+
+ public SqlUserSchema build() { return new SqlUserSchema(columns); }
+ }
+}
diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java
new file mode 100644
index 000000000..5a7ed8b44
--- /dev/null
+++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java
@@ -0,0 +1,302 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.simpleapi.sql.Column;
+import com.bencodez.simpleapi.sql.data.DataValue;
+
+public final class SqliteUserBackend implements SqlUserBackend {
+ private static final int USER_PAGE_SIZE = 512;
+ private final Path databaseFile;
+ private final String tableName;
+ private final SqlUserSchema schema;
+ private final SqlBackendLogger logger;
+ private final AtomicBoolean open = new AtomicBoolean();
+ private final AtomicBoolean invalidUuidWarningLogged = new AtomicBoolean();
+ private final ReentrantReadWriteLock operations = new ReentrantReadWriteLock(true);
+
+ public SqliteUserBackend(Path dataDirectory, String databaseName, String tableName, SqlUserSchema schema, SqlBackendLogger logger) {
+ Objects.requireNonNull(dataDirectory, "dataDirectory");
+ Objects.requireNonNull(databaseName, "databaseName");
+ quote(tableName);
+ this.tableName = tableName;
+ this.schema = Objects.requireNonNull(schema, "schema");
+ this.logger = logger == null ? SqlBackendLogger.NO_OP : logger;
+ if (databaseName.isBlank() || databaseName.contains("/") || databaseName.contains("\\")) throw new IllegalArgumentException("databaseName must be a simple file name");
+ this.databaseFile = dataDirectory.resolve(databaseName + ".db");
+ initialize();
+ }
+
+ public Path databaseFile() { return databaseFile; }
+ @Override public UserStorage storageType() { return UserStorage.SQLITE; }
+
+ @Override
+ public SqlUserStorage user(UUID uuid) {
+ requireAdmissionOpen();
+ SqlUserStorage delegate = new JdbcSqlUserStorage(UserStorage.SQLITE, uuid, tableName, schema,
+ this::openConnection, JdbcSqlUserStorage.Dialect.SQLITE, logger);
+ return new SqlUserStorage() {
+ @Override public List readRow(UserStorage storage) { return withOperation(() -> delegate.readRow(storage)); }
+ @Override public boolean contains(UserStorage storage) { return withOperation(() -> delegate.contains(storage)); }
+ @Override public void delete(UserStorage storage) { withOperation(() -> { delegate.delete(storage); return null; }); }
+ @Override public void write(UserStorage storage, String key, DataValue value) { withOperation(() -> { delegate.write(storage, key, value); return null; }); }
+ @Override public void writeValues(UserStorage storage, HashMap values) { withOperation(() -> { delegate.writeValues(storage, values); return null; }); }
+ };
+ }
+
+ @Override
+ public List enumerateUsers() {
+ ArrayList users = new ArrayList<>();
+ forEachUser(uuid -> {
+ if (users.size() >= MAX_MATERIALIZED_USERS) throw new IllegalStateException("User enumeration exceeds "
+ + MAX_MATERIALIZED_USERS + " entries; use forEachUser for streaming access");
+ users.add(uuid);
+ });
+ return users;
+ }
+
+ @Override
+ public void forEachUser(Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer");
+ withOperation(() -> {
+ String cursor = null;
+ while (true) {
+ List page = readUserPage(cursor);
+ if (page.isEmpty()) return null;
+ cursor = page.get(page.size() - 1).cursor();
+ for (UserPageEntry entry : page) {
+ if (entry.uuid() != null) consumer.accept(entry.uuid());
+ }
+ if (page.size() < USER_PAGE_SIZE) return null;
+ }
+ });
+ }
+
+ private List readUserPage(String cursor) {
+ String uuidColumn = quote(SqlUserSchema.UUID_COLUMN);
+ String sql = "SELECT " + uuidColumn + " FROM " + quote(tableName)
+ + " WHERE " + uuidColumn + " IS NOT NULL"
+ + (cursor == null ? "" : " AND " + uuidColumn + " > ?")
+ + " ORDER BY " + uuidColumn + " ASC LIMIT ?";
+ try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) {
+ int index = 1;
+ if (cursor != null) statement.setString(index++, cursor);
+ statement.setInt(index, USER_PAGE_SIZE);
+ ArrayList page = new ArrayList<>(USER_PAGE_SIZE);
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ String value = result.getString(1);
+ if (value == null) continue;
+ UUID parsed = null;
+ try {
+ parsed = UUID.fromString(value);
+ if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Non-canonical UUID");
+ }
+ catch (IllegalArgumentException invalid) {
+ parsed = null;
+ if (invalidUuidWarningLogged.compareAndSet(false, true)) {
+ logger.warn("Skipping malformed UUID entries while enumerating SQLite users; further diagnostics suppressed",
+ new IllegalArgumentException("Malformed SQLite UUID value"));
+ }
+ }
+ page.add(new UserPageEntry(value, parsed));
+ }
+ }
+ return page;
+ } catch (SQLException failure) {
+ throw new IllegalStateException("Failed to enumerate SQLite users", failure);
+ }
+ }
+
+ private record UserPageEntry(String cursor, UUID uuid) {}
+
+ @Override public boolean isOpen() { return open.get(); }
+
+ @Override
+ public void close() {
+ if (operations.getReadHoldCount() != 0) throw new IllegalStateException("Cannot close SQLite from inside an active storage operation");
+ open.set(false);
+ operations.writeLock().lock();
+ try { } finally { operations.writeLock().unlock(); }
+ }
+
+ private T withOperation(Supplier operation) {
+ requireAdmissionOpen();
+ operations.readLock().lock();
+ try {
+ // This is the actual admission point. A caller that passed the first
+ // check but lost the race to close must not become a new active operation.
+ requireAdmissionOpen();
+ return operation.get();
+ } finally {
+ operations.readLock().unlock();
+ }
+ }
+
+ private void initialize() {
+ try {
+ Files.createDirectories(databaseFile.getParent());
+ Class.forName("org.sqlite.JDBC");
+ open.set(true);
+ try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(createTableSql())) { statement.executeUpdate(); }
+ ensureRegisteredColumns();
+ } catch (IOException | ClassNotFoundException | SQLException | RuntimeException e) {
+ open.set(false);
+ throw new IllegalStateException("Failed to initialize SQLite user backend at " + databaseFile, e);
+ }
+ }
+
+ private void ensureRegisteredColumns() throws SQLException {
+ // CREATE TABLE IF NOT EXISTS deliberately leaves a pre-existing table
+ // untouched. Do not report a usable backend when that table cannot
+ // support the immutable user identity used by every operation. Adding
+ // an identity column here would be an unsafe migration because existing
+ // rows have no unambiguous UUID to populate.
+ if (!hasColumn(SqlUserSchema.UUID_COLUMN)) {
+ throw new SQLException("SQLite user table is missing required UUID column");
+ }
+ if (!hasCompatibleUuidType()) {
+ throw new SQLException("SQLite user table UUID column must use a text-compatible type");
+ }
+ if (!hasUniqueUuidConstraint()) {
+ throw new SQLException("SQLite user table UUID column must be PRIMARY KEY or UNIQUE");
+ }
+ for (SqlUserSchema.ColumnDefinition column : schema.columns()) {
+ if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name()) || hasColumn(column.name())) continue;
+ String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + " " + column.sqlType();
+ try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); }
+ catch (SQLException addFailure) { if (!isDuplicateColumn(addFailure) || !hasColumn(column.name())) throw addFailure; }
+ }
+ }
+
+ private boolean isDuplicateColumn(SQLException failure) {
+ String message = failure.getMessage();
+ return message != null && message.toLowerCase(Locale.ROOT).contains("duplicate column name");
+ }
+
+ private boolean hasColumn(String name) throws SQLException {
+ String sql = "PRAGMA table_info(" + quote(tableName) + ")";
+ try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery()) {
+ while (result.next()) if (name.equalsIgnoreCase(result.getString("name"))) return true;
+ return false;
+ }
+ }
+
+ private boolean hasCompatibleUuidType() throws SQLException {
+ String sql = "PRAGMA table_info(" + quote(tableName) + ")";
+ try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(result.getString("name"))) continue;
+ String type = result.getString("type");
+ if (type == null || type.isBlank()) return true;
+ String normalized = type.strip().toUpperCase(Locale.ROOT);
+ return normalized.contains("CHAR") || normalized.contains("CLOB")
+ || normalized.contains("TEXT") || normalized.contains("STRING");
+ }
+ return false;
+ }
+ }
+
+ private boolean hasUniqueUuidConstraint() throws SQLException {
+ String uuid = SqlUserSchema.UUID_COLUMN;
+ String tableInfo = "PRAGMA table_info(" + quote(tableName) + ")";
+ try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(tableInfo);
+ ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ if (uuid.equalsIgnoreCase(result.getString("name")) && result.getInt("pk") > 0) {
+ // A composite primary key is not sufficient: another row
+ // could still share the same UUID. Require UUID to be the
+ // sole primary-key column.
+ int primaryKeyPosition = result.getInt("pk");
+ if (primaryKeyPosition == 1 && !hasOtherPrimaryKeyColumn(connection)) return true;
+ }
+ }
+ }
+
+ String indexes = "PRAGMA index_list(" + quote(tableName) + ")";
+ try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(indexes);
+ ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ if (result.getInt("unique") == 0 || hasPartialIndex(result)) continue;
+ String indexName = result.getString("name");
+ String indexInfo = "PRAGMA index_info(" + quote(indexName) + ")";
+ try (PreparedStatement indexStatement = connection.prepareStatement(indexInfo);
+ ResultSet columns = indexStatement.executeQuery()) {
+ int count = 0;
+ boolean uuidColumn = false;
+ while (columns.next()) {
+ count++;
+ uuidColumn |= uuid.equalsIgnoreCase(columns.getString("name"));
+ }
+ if (count == 1 && uuidColumn) return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean hasOtherPrimaryKeyColumn(Connection connection) throws SQLException {
+ String tableInfo = "PRAGMA table_info(" + quote(tableName) + ")";
+ try (PreparedStatement statement = connection.prepareStatement(tableInfo); ResultSet result = statement.executeQuery()) {
+ while (result.next()) {
+ if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(result.getString("name")) && result.getInt("pk") > 0) return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean hasPartialIndex(ResultSet index) throws SQLException {
+ try {
+ return index.getInt("partial") != 0;
+ } catch (SQLException missingColumn) {
+ // Older SQLite drivers may omit the optional PRAGMA column. A
+ // unique partial index is not a substitute for identity
+ // uniqueness, so fail closed when its presence cannot be proven.
+ return true;
+ }
+ }
+
+ private String createTableSql() {
+ StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS ").append(quote(tableName)).append(" (");
+ boolean first = true;
+ for (SqlUserSchema.ColumnDefinition column : schema.columns()) {
+ if (!first) sql.append(", ");
+ first = false;
+ sql.append(quote(column.name())).append(' ').append(column.sqlType());
+ }
+ sql.append(", PRIMARY KEY (").append(quote(SqlUserSchema.UUID_COLUMN)).append("))");
+ return sql.toString();
+ }
+
+ private Connection openConnection() throws SQLException {
+ // Nested JDBC work belonging to an already-admitted operation may finish
+ // while close waits on the write lock. New top-level operations cannot get here.
+ if (!open.get() && operations.getReadHoldCount() == 0) throw new IllegalStateException("SQLite user backend is closed");
+ return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath());
+ }
+
+ private void requireAdmissionOpen() {
+ if (!open.get()) throw new IllegalStateException("SQLite user backend is closed");
+ }
+
+ private static String quote(String identifier) { return JdbcSqlUserStorage.Dialect.SQLITE.quote(identifier); }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcDuplicateCanonicalColumnTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcDuplicateCanonicalColumnTest.java
new file mode 100644
index 000000000..5b42f8450
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcDuplicateCanonicalColumnTest.java
@@ -0,0 +1,36 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import java.sql.Connection;
+import java.util.HashMap;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+
+class JdbcDuplicateCanonicalColumnTest {
+ @Test
+ void caseVariantAliasesCannotOverwriteOneCanonicalColumn() throws Exception {
+ Connection connection = mock(Connection.class);
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.SQLITE, UUID.randomUUID(), "Users",
+ SqlUserSchema.builder().column("Points", "INTEGER", DataType.INTEGER).build(),
+ () -> connection, JdbcSqlUserStorage.Dialect.SQLITE, SqlBackendLogger.NO_OP);
+ HashMap values = new HashMap<>();
+ values.put("Points", new DataValueInt(1));
+ values.put("points", new DataValueInt(2));
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> storage.writeValues(UserStorage.SQLITE, values));
+ assertTrue(failure.getMessage().contains("Points"));
+ verify(connection, never()).getAutoCommit();
+ verify(connection, never()).commit();
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcPostgresBitBooleanTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcPostgresBitBooleanTest.java
new file mode 100644
index 000000000..3e114aa1d
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcPostgresBitBooleanTest.java
@@ -0,0 +1,264 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValueBoolean;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+
+class JdbcPostgresBitBooleanTest {
+ private static final UUID USER = UUID.fromString("51829cd0-c37c-45bf-9910-57914800e0a1");
+
+ @Test
+ void postgresIntegerBindingIsTargetTypedForLegacyTextColumns() throws Exception {
+ Connection connection = mock(Connection.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(exists.executeQuery()).thenReturn(missing);
+ when(connection.prepareStatement(anyString())).thenReturn(exists, insert);
+ when(insert.executeUpdate()).thenReturn(1);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INT DEFAULT '0'", DataType.INTEGER).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ storage.write(UserStorage.MYSQL, "Points", new DataValueInt(17));
+
+ verify(insert).setObject(2, "17", Types.OTHER);
+ verify(insert, never()).setInt(2, 17);
+ }
+
+ @Test
+ void postgresBooleanBindingIsTargetTypedForRetainedLegacyTextColumns() throws Exception {
+ Connection connection = mock(Connection.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(exists.executeQuery()).thenReturn(missing);
+ when(connection.prepareStatement(anyString())).thenReturn(exists, insert);
+ when(insert.executeUpdate()).thenReturn(1);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Enabled", "BOOLEAN", DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ storage.write(UserStorage.MYSQL, "Enabled", new DataValueBoolean(true));
+
+ verify(insert).setObject(2, "true", Types.OTHER);
+ verify(insert, never()).setBoolean(2, true);
+ }
+
+ @Test
+ void postgresBitUsesExplicitBitCastAndStringBindingOnInsert() throws Exception {
+ Connection connection = mock(Connection.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ List sql = new ArrayList<>();
+ List statements = new ArrayList<>();
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String query = call.getArgument(0, String.class);
+ sql.add(query);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ statements.add(statement);
+ when(statement.executeQuery()).thenReturn(mock(ResultSet.class));
+ when(statement.executeUpdate()).thenReturn(1);
+ return statement;
+ });
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag", "BIT(1)", DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ storage.write(UserStorage.MYSQL, "Flag", new DataValueBoolean(true));
+
+ assertEquals(List.of(
+ "SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE",
+ "INSERT INTO \"Users\" (\"uuid\", \"Flag\") VALUES (?, CAST(? AS BIT(1))) ON CONFLICT (\"uuid\") DO NOTHING"), sql);
+ verify(statements.get(1)).setString(2, "1");
+ verify(statements.get(1), never()).setInt(2, 1);
+ }
+
+ @Test
+ void postgresVarbitDeclarationUsesTheEquivalentVaryingBitCast() throws Exception {
+ Connection connection = mock(Connection.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(exists.executeQuery()).thenReturn(missing);
+ when(insert.executeUpdate()).thenReturn(1);
+ when(connection.prepareStatement("SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE"))
+ .thenReturn(exists);
+ when(connection.prepareStatement("INSERT INTO \"Users\" (\"uuid\", \"Flag\") VALUES (?, CAST(? AS BIT VARYING(5))) ON CONFLICT (\"uuid\") DO NOTHING"))
+ .thenReturn(insert);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag", "VARBIT(5)", DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ storage.write(UserStorage.MYSQL, "Flag", new DataValueBoolean(true));
+
+ verify(insert).setString(2, "1");
+ }
+
+ @Test
+ void postgresBitDeclarationsAllowWhitespaceBeforeWidthModifiers() throws Exception {
+ for (String declaration : List.of("BIT (5)", "VARBIT (5)", "BIT VARYING (5)")) {
+ Connection connection = mock(Connection.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(exists.executeQuery()).thenReturn(missing);
+ when(insert.executeUpdate()).thenReturn(1);
+ when(connection.prepareStatement("SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE"))
+ .thenReturn(exists);
+ String castType = declaration.startsWith("BIT ") && !declaration.startsWith("BIT VARYING")
+ ? "BIT(5)" : "BIT VARYING(5)";
+ when(connection.prepareStatement("INSERT INTO \"Users\" (\"uuid\", \"Flag\") VALUES (?, CAST(? AS "
+ + castType + ")) ON CONFLICT (\"uuid\") DO NOTHING")).thenReturn(insert);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag", declaration, DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ storage.write(UserStorage.MYSQL, "Flag", new DataValueBoolean(true));
+
+ verify(insert).setString(2, "1");
+ }
+ }
+
+ @Test
+ void postgresRetainsResolvedSchemaVarbitWidthForBooleanWrites() throws Exception {
+ Connection connection = mock(Connection.class);
+ DatabaseMetaData metadata = mock(DatabaseMetaData.class);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ PreparedStatement schemaLookup = mock(PreparedStatement.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ ResultSet resolvedSchema = mock(ResultSet.class);
+ ResultSet retainedColumn = mock(ResultSet.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ when(connection.getMetaData()).thenReturn(metadata);
+ when(exists.executeQuery()).thenReturn(missing);
+ when(schemaLookup.executeQuery()).thenReturn(resolvedSchema);
+ when(resolvedSchema.next()).thenReturn(true);
+ when(resolvedSchema.getString(1)).thenReturn("tenant");
+ when(retainedColumn.next()).thenReturn(true);
+ when(retainedColumn.getString("TYPE_NAME")).thenReturn("bit varying");
+ when(retainedColumn.getInt("COLUMN_SIZE")).thenReturn(5);
+ when(retainedColumn.wasNull()).thenReturn(false);
+ when(metadata.getColumns(null, "tenant", "Users", "Flag")).thenReturn(retainedColumn);
+ when(connection.prepareStatement("SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE"))
+ .thenReturn(exists);
+ when(connection.prepareStatement("SELECT n.nspname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n "
+ + "ON n.oid=c.relnamespace WHERE c.oid=pg_catalog.to_regclass(?)")).thenReturn(schemaLookup);
+ when(connection.prepareStatement("INSERT INTO \"Users\" (\"uuid\", \"Flag\") VALUES (?, CAST(? AS BIT VARYING(5))) ON CONFLICT (\"uuid\") DO NOTHING"))
+ .thenReturn(insert);
+ when(insert.executeUpdate()).thenReturn(1);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag", "BOOLEAN", DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ storage.write(UserStorage.MYSQL, "Flag", new DataValueBoolean(true));
+
+ verify(metadata).getColumns(null, "tenant", "Users", "Flag");
+ verify(insert).setString(2, "1");
+ }
+
+ @Test
+ void postgresRetainedMetadataTreatsWildcardCharactersAsLiteralIdentifiers() throws Exception {
+ Connection connection = mock(Connection.class);
+ DatabaseMetaData metadata = mock(DatabaseMetaData.class);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ PreparedStatement schemaLookup = mock(PreparedStatement.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ ResultSet resolvedSchema = mock(ResultSet.class);
+ ResultSet retainedColumn = mock(ResultSet.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ when(connection.getMetaData()).thenReturn(metadata);
+ when(metadata.getSearchStringEscape()).thenReturn("\\");
+ when(exists.executeQuery()).thenReturn(missing);
+ when(schemaLookup.executeQuery()).thenReturn(resolvedSchema);
+ when(resolvedSchema.next()).thenReturn(true);
+ when(resolvedSchema.getString(1)).thenReturn("tenant_%");
+ when(retainedColumn.next()).thenReturn(true);
+ when(retainedColumn.getString("TYPE_NAME")).thenReturn("boolean");
+ when(metadata.getColumns(null, "tenant\\_\\%", "Users\\_\\%", "Flag\\_\\%"))
+ .thenReturn(retainedColumn);
+ when(connection.prepareStatement("SELECT 1 FROM \"Users_%\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE"))
+ .thenReturn(exists);
+ when(connection.prepareStatement("SELECT n.nspname FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n "
+ + "ON n.oid=c.relnamespace WHERE c.oid=pg_catalog.to_regclass(?)")).thenReturn(schemaLookup);
+ when(connection.prepareStatement("INSERT INTO \"Users_%\" (\"uuid\", \"Flag_%\") VALUES (?, ?) ON CONFLICT (\"uuid\") DO NOTHING"))
+ .thenReturn(insert);
+ when(insert.executeUpdate()).thenReturn(1);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag_%", "BOOLEAN", DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users_%", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ storage.write(UserStorage.MYSQL, "Flag_%", new DataValueBoolean(true));
+
+ verify(metadata).getColumns(null, "tenant\\_\\%", "Users\\_\\%", "Flag\\_\\%");
+ verify(insert).setObject(2, "true", Types.OTHER);
+ }
+
+ @Test
+ void postgresBitReadsOneAsTrue() throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(1);
+ when(metadata.getColumnLabel(1)).thenReturn("Flag");
+ when(result.getString(1)).thenReturn("1");
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag", "BIT(1)", DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ assertTrue(storage.readRow(UserStorage.MYSQL).get(0).getValue().getBoolean());
+ verify(result, never()).getInt(1);
+ verify(result, never()).getBoolean(1);
+ }
+
+ @Test
+ void postgresFixedWidthBitReadsAnyNonzeroValueAsTrue() throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(1);
+ when(metadata.getColumnLabel(1)).thenReturn("Flag");
+ when(result.getString(1)).thenReturn("10");
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag", "BIT(2)", DataType.BOOLEAN).build();
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+
+ assertTrue(storage.readRow(UserStorage.MYSQL).get(0).getValue().getBoolean());
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcReviewRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcReviewRegressionTest.java
new file mode 100644
index 000000000..bc351d004
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcReviewRegressionTest.java
@@ -0,0 +1,139 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.SQLDataException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.InOrder;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+
+/** SQL clause, ordering and failure contracts against mocked JDBC. */
+class JdbcReviewRegressionTest {
+ private static final UUID USER = UUID.fromString("542b75a0-5333-4f44-828c-94676443cf5d");
+
+ @Test void locksAnExistingPostgresRowUntilAfterTheUpdate() throws Exception {
+ Connection connection = mock(Connection.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ PreparedStatement lock = mock(PreparedStatement.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ ResultSet found = mock(ResultSet.class);
+ when(found.next()).thenReturn(true);
+ when(lock.executeQuery()).thenReturn(found);
+ when(update.executeUpdate()).thenReturn(1);
+ when(connection.prepareStatement("SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE"))
+ .thenReturn(lock);
+ when(connection.prepareStatement("UPDATE \"Users\" SET \"Points\"=? WHERE \"uuid\"=?"))
+ .thenReturn(update);
+ storage(connection).write(UserStorage.MYSQL, "Points", new DataValueInt(42));
+ verify(lock).setObject(1, USER);
+ verify(update).setObject(2, USER);
+ InOrder order = inOrder(connection, lock, update);
+ order.verify(connection).setAutoCommit(false);
+ order.verify(lock).executeQuery();
+ order.verify(update).executeUpdate();
+ order.verify(connection).commit();
+ order.verify(connection).close();
+ verify(found).close();
+ verify(lock).close();
+ verify(update).close();
+ }
+
+ @Test void locksTheRowWonByAConcurrentInsertToo() throws Exception {
+ Connection connection = mock(Connection.class);
+ List sqls = new ArrayList<>();
+ int[] reads = {0};
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0, String.class);
+ sqls.add(sql);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ if (sql.startsWith("SELECT 1")) {
+ assertTrue(sql.endsWith("FOR UPDATE"));
+ ResultSet result = mock(ResultSet.class);
+ when(result.next()).thenReturn(++reads[0] > 1);
+ when(statement.executeQuery()).thenReturn(result);
+ }
+ // INSERT's zero update count models ON CONFLICT DO NOTHING.
+ return statement;
+ });
+ storage(connection).write(UserStorage.MYSQL, "Points", new DataValueInt(42));
+ assertEquals(2, reads[0]);
+ assertEquals(4, sqls.size());
+ assertTrue(sqls.get(1).startsWith("INSERT INTO"));
+ assertTrue(sqls.get(2).endsWith("FOR UPDATE"));
+ assertTrue(sqls.get(3).startsWith("UPDATE"));
+ verify(connection).commit();
+ }
+
+ @Test void ordinaryContainsDoesNotTakeAWriteLock() throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ when(connection.prepareStatement("SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1"))
+ .thenReturn(statement);
+ when(statement.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ assertTrue(storage(connection).contains(UserStorage.MYSQL));
+ verify(connection, never()).setAutoCommit(false);
+ }
+
+ @Test void malformedLegacyIntegerTextFallsBackWithoutDiscardingTheRow() throws Exception {
+ readInteger(new SQLException("bad legacy integer", "22P02"), 0);
+ }
+
+ @Test void driverDataConversionExceptionsWithoutAStateAlsoFallBack() throws Exception {
+ readInteger(new SQLDataException("out of range"), 0);
+ }
+
+ @Test void validIntegersRetainTheirValue() throws Exception {
+ readInteger(null, 42);
+ }
+
+ @Test void connectionFailureIsNotTreatedAsLegacyIntegerText() throws Exception {
+ SQLException failure = new SQLException("connection lost", "08006");
+ assertSame(failure, assertThrows(IllegalStateException.class,
+ () -> readInteger(failure, 0)).getCause());
+ }
+
+ private void readInteger(SQLException failure, int expected) throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true, false);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(2);
+ when(metadata.getColumnLabel(1)).thenReturn("Points");
+ when(metadata.getColumnLabel(2)).thenReturn("Note");
+ when(result.getString(2)).thenReturn("still readable");
+ if (failure == null) when(result.getInt(1)).thenReturn(42);
+ else when(result.getInt(1)).thenThrow(failure);
+ var row = storage(connection).readRow(UserStorage.MYSQL);
+ assertEquals(expected, row.get(0).getValue().getInt());
+ assertEquals("still readable", row.get(1).getValue().getString());
+ verify(result).close();
+ verify(statement).close();
+ verify(connection).close();
+ }
+
+ private JdbcSqlUserStorage storage(Connection connection) {
+ return new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", SqlUserSchema.builder()
+ .column("Points", "INT DEFAULT '0'", DataType.INTEGER).build(),
+ () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java
new file mode 100644
index 000000000..5a5cf6a04
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java
@@ -0,0 +1,222 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValueBoolean;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+class JdbcSqlUserStorageDialectTest {
+ private static final UUID UUID_VALUE = UUID.fromString("542b75a0-5333-4f44-828c-94676443cf5d");
+
+ @Test void rowReadsReturnRegisteredColumnCasing() throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(1);
+ when(metadata.getColumnLabel(1)).thenReturn("points");
+ when(result.getInt(1)).thenReturn(7);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INT", DataType.INTEGER).build();
+
+ List columns = new JdbcSqlUserStorage(UserStorage.MYSQL, UUID_VALUE,
+ "Users", schema, () -> connection, JdbcSqlUserStorage.Dialect.MYSQL, SqlBackendLogger.NO_OP)
+ .readRow(UserStorage.MYSQL);
+
+ assertEquals("Points", columns.get(0).getName());
+ assertEquals(7, columns.get(0).getValue().getInt());
+ }
+
+ @Test void postgresqlNewRowUsesTheAtomicInsertWithoutARedundantUpdate() throws Exception {
+ RecordingJdbc jdbc = new RecordingJdbc();
+ SqlUserSchema schema = SqlUserSchema.builder().column("Vote \"Flag\"", "VARCHAR(5)", DataType.BOOLEAN).build();
+ SqlUserStorage user = new JdbcSqlUserStorage(UserStorage.MYSQL, UUID_VALUE, "User \"Data\"", schema,
+ () -> jdbc.connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+ assertTrue(user.readRow(UserStorage.MYSQL).isEmpty());
+ assertFalse(user.contains(UserStorage.MYSQL));
+ user.delete(UserStorage.MYSQL);
+ user.write(UserStorage.MYSQL, "vote \"flag\"", new DataValueBoolean(true));
+ assertEquals(List.of(
+ "SELECT * FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=?",
+ "SELECT 1 FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=? LIMIT 1",
+ "DELETE FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=?",
+ "SELECT 1 FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE",
+ "INSERT INTO \"User \"\"Data\"\"\" (\"uuid\", \"Vote \"\"Flag\"\"\") VALUES (?, ?) ON CONFLICT (\"uuid\") DO NOTHING"), jdbc.sql);
+ for (int i = 0; i < 5; i++) {
+ verify(jdbc.statements.get(i)).setObject(1, UUID_VALUE);
+ verify(jdbc.statements.get(i), never()).setString(1, UUID_VALUE.toString());
+ }
+ verify(jdbc.statements.get(4)).setString(2, "true");
+ verify(jdbc.connection).commit();
+ verify(jdbc.connection, times(4)).close();
+ for (PreparedStatement statement : jdbc.statements) verify(statement).close();
+ }
+
+ @Test void mysqlAndMariaDbNewRowsUseConstraintSafeInsertWithoutARedundantUpdate() throws Exception {
+ for (DbType type : List.of(DbType.MYSQL, DbType.MARIADB)) {
+ RecordingJdbc jdbc = new RecordingJdbc();
+ SqlUserSchema schema = SqlUserSchema.builder().column("Vote `Flag`", "VARCHAR(5)", DataType.BOOLEAN).build();
+ SqlUserStorage user = new JdbcSqlUserStorage(UserStorage.MYSQL, UUID_VALUE, "User `Data`", schema,
+ () -> jdbc.connection, JdbcSqlUserStorage.Dialect.fromDbType(type), SqlBackendLogger.NO_OP);
+ user.write(UserStorage.MYSQL, "Vote `Flag`", new DataValueBoolean(false));
+ assertEquals(List.of(
+ "SELECT 1 FROM `User ``Data``` WHERE `uuid`=? LIMIT 1 FOR UPDATE",
+ "INSERT INTO `User ``Data``` (`uuid`, `Vote ``Flag```) VALUES (?, ?)"), jdbc.sql);
+ verify(jdbc.statements.get(0)).setString(1, UUID_VALUE.toString());
+ verify(jdbc.statements.get(1)).setString(1, UUID_VALUE.toString());
+ verify(jdbc.statements.get(1)).setString(2, "false");
+ verify(jdbc.connection).commit();
+ verify(jdbc.connection).close();
+ }
+ }
+
+ @Test void booleanReadsUseARepresentationIndependentParser() throws Exception {
+ Connection writeConnection = mock(Connection.class);
+ when(writeConnection.getAutoCommit()).thenReturn(true);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(exists.executeQuery()).thenReturn(missing);
+ when(insert.executeUpdate()).thenReturn(1);
+ when(writeConnection.prepareStatement(anyString())).thenAnswer(call -> call.getArgument(0, String.class).startsWith("SELECT 1") ? exists : insert);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Flag", "TINYINT(1)", DataType.BOOLEAN).build();
+ SqlUserStorage writer = new JdbcSqlUserStorage(UserStorage.MYSQL, UUID_VALUE, "Users", schema,
+ () -> writeConnection, JdbcSqlUserStorage.Dialect.MYSQL, SqlBackendLogger.NO_OP);
+ writer.write(UserStorage.MYSQL, "Flag", new DataValueBoolean(true));
+ verify(insert).setInt(2, 1);
+
+ Connection readConnection = mock(Connection.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(readConnection.prepareStatement(anyString())).thenReturn(read);
+ when(read.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(1);
+ when(metadata.getColumnLabel(1)).thenReturn("Flag");
+ // An existing SQLite/PostgreSQL text column can outlive a newer numeric
+ // declaration. Decoding the returned representation keeps its true
+ // value instead of asking the driver to coerce it to an integer.
+ when(result.getString(1)).thenReturn("true");
+ SqlUserStorage reader = new JdbcSqlUserStorage(UserStorage.MYSQL, UUID_VALUE, "Users", schema,
+ () -> readConnection, JdbcSqlUserStorage.Dialect.MYSQL, SqlBackendLogger.NO_OP);
+ assertTrue(reader.readRow(UserStorage.MYSQL).get(0).getValue().getBoolean());
+ verify(result, never()).getInt(1);
+ verify(result, never()).getBoolean(1);
+ }
+
+ @Test void mysqlBooleanWritesUseTheRetainedPhysicalColumnType() throws Exception {
+ Connection connection = mock(Connection.class);
+ when(connection.getAutoCommit()).thenReturn(true);
+ when(connection.getCatalog()).thenReturn("votes");
+ DatabaseMetaData metadata = mock(DatabaseMetaData.class);
+ when(connection.getMetaData()).thenReturn(metadata);
+ ResultSet columns = mock(ResultSet.class);
+ when(columns.next()).thenReturn(true, false);
+ when(columns.getString("TYPE_NAME")).thenReturn("TINYINT");
+ when(metadata.getColumns(any(), any(), anyString(), anyString())).thenReturn(columns);
+ PreparedStatement exists = mock(PreparedStatement.class);
+ ResultSet existing = mock(ResultSet.class);
+ when(existing.next()).thenReturn(true);
+ when(exists.executeQuery()).thenReturn(existing);
+ PreparedStatement update = mock(PreparedStatement.class);
+ when(update.executeUpdate()).thenReturn(1);
+ when(connection.prepareStatement(anyString())).thenAnswer(call ->
+ call.getArgument(0, String.class).startsWith("SELECT 1") ? exists : update);
+ SqlUserSchema schema = SqlUserSchema.builder()
+ .column("Flag", "VARCHAR(5)", DataType.BOOLEAN).build();
+
+ new JdbcSqlUserStorage(UserStorage.MYSQL, UUID_VALUE, "Users", schema,
+ () -> connection, JdbcSqlUserStorage.Dialect.MYSQL, SqlBackendLogger.NO_OP)
+ .write(UserStorage.MYSQL, "Flag", new DataValueBoolean(true));
+
+ verify(update).setInt(1, 1);
+ verify(update, never()).setString(1, "true");
+ }
+
+ @Test void backendUsesConnectionManagerDialectAndExistingUuidSchemaType() throws Exception {
+ for (DbType type : List.of(DbType.POSTGRESQL, DbType.MYSQL, DbType.MARIADB)) {
+ RecordingJdbc jdbc = new RecordingJdbc();
+ MysqlConfig config = new MysqlConfig();
+ config.setDbType(type); config.setDatabase("test_database"); config.setMaxThreads(1);
+ config.setTablePrefix("prefix-"); config.setTableName("User Data");
+ try (MockedConstruction managers = mockConstruction(ConnectionManager.class,
+ (manager, context) -> {
+ when(manager.open()).thenReturn(true); when(manager.getDbType()).thenReturn(type);
+ when(manager.getConnection()).thenReturn(jdbc.connection);
+ })) {
+ try (MysqlUserBackend backend = new MysqlUserBackend("ignored", config, SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP)) {
+ backend.user(UUID_VALUE).contains(UserStorage.MYSQL);
+ PreparedStatement lookup = jdbc.statements.get(jdbc.statements.size() - 1);
+ if (type == DbType.POSTGRESQL) {
+ assertTrue(jdbc.sql.contains("CREATE TABLE IF NOT EXISTS \"prefix-User Data\" (\"uuid\" UUID, PRIMARY KEY (\"uuid\"));"));
+ assertEquals("SELECT 1 FROM \"prefix-User Data\" WHERE \"uuid\"=? LIMIT 1", jdbc.sql.get(jdbc.sql.size() - 1));
+ verify(lookup).setObject(1, UUID_VALUE);
+ } else {
+ assertTrue(jdbc.sql.contains("CREATE TABLE IF NOT EXISTS `prefix-User Data` (`uuid` VARCHAR(37), PRIMARY KEY (`uuid`));"));
+ verify(lookup).setString(1, UUID_VALUE.toString());
+ }
+ }
+ assertEquals(1, managers.constructed().size()); verify(managers.constructed().get(0)).close();
+ }
+ }
+ }
+
+ @Test void dialectRejectsInvalidIdentifiersWithoutRejectingQuotedNames() {
+ for (JdbcSqlUserStorage.Dialect dialect : JdbcSqlUserStorage.Dialect.values()) {
+ assertThrows(IllegalArgumentException.class, () -> dialect.quote(null));
+ assertThrows(IllegalArgumentException.class, () -> dialect.quote(""));
+ assertThrows(IllegalArgumentException.class, () -> dialect.quote(" "));
+ assertThrows(IllegalArgumentException.class, () -> dialect.quote("invalid\0name"));
+ assertTrue(dialect.quote("custom-name with space").contains("custom-name with space"));
+ }
+ }
+
+ private static final class RecordingJdbc {
+ final Connection connection = mock(Connection.class);
+ final List sql = new ArrayList<>();
+ final List statements = new ArrayList<>();
+ RecordingJdbc() throws SQLException {
+ when(connection.getAutoCommit()).thenReturn(true);
+ when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
+ sql.add(invocation.getArgument(0, String.class));
+ PreparedStatement statement = mock(PreparedStatement.class); statements.add(statement);
+ when(statement.executeQuery()).thenReturn(mock(ResultSet.class)); when(statement.executeUpdate()).thenReturn(1);
+ return statement;
+ });
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageWriteOutcomeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageWriteOutcomeTest.java
new file mode 100644
index 000000000..9341a29f8
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageWriteOutcomeTest.java
@@ -0,0 +1,191 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+import com.bencodez.simpleapi.sql.data.DataValueString;
+
+class JdbcSqlUserStorageWriteOutcomeTest {
+ private static final UUID USER = UUID.fromString("c17d7784-00ce-421f-a38b-a30ed419e1a4");
+
+ @Test void committedWriteSurvivesAutoCommitRestorationFailure() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException restore = new SQLException("restore failed");
+ doThrow(restore).when(jdbc.connection).setAutoCommit(true);
+ assertDoesNotThrow(() -> jdbc.write()); verify(jdbc.connection).commit(); verify(jdbc.connection, never()).rollback();
+ verify(jdbc.connection).close(); verify(jdbc.logger).warn(anyString(), same(restore));
+ }
+
+ @Test void committedWriteSurvivesConnectionCloseFailure() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException close = new SQLException("close failed");
+ doThrow(close).when(jdbc.connection).close();
+ assertDoesNotThrow(() -> jdbc.write()); verify(jdbc.connection).commit(); verify(jdbc.connection, never()).rollback();
+ verify(jdbc.connection).setAutoCommit(true); verify(jdbc.logger).warn(anyString(), same(close));
+ }
+
+ @Test void bothPostCommitCleanupFailuresAreReportedWithoutRetry() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException restore = new SQLException("restore failed"); IllegalStateException close = new IllegalStateException("driver close failed");
+ doThrow(restore).when(jdbc.connection).setAutoCommit(true); doThrow(close).when(jdbc.connection).close();
+ assertDoesNotThrow(() -> jdbc.write()); verify(jdbc.connection).commit(); verify(jdbc.connection).close();
+ verify(jdbc.logger).warn(anyString(), same(restore)); verify(jdbc.logger).warn(anyString(), same(close));
+ }
+
+ @Test void failedWriteRetainsSqlCauseAndSuppressedCleanupFailures() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException write = new SQLException("update failed"); SQLException restore = new SQLException("restore failed"); SQLException close = new SQLException("close failed");
+ when(jdbc.update.executeUpdate()).thenThrow(write); doThrow(restore).when(jdbc.connection).setAutoCommit(true); doThrow(close).when(jdbc.connection).close();
+ IllegalStateException result = assertThrows(IllegalStateException.class, () -> jdbc.write());
+ assertSame(write, result.getCause()); assertEquals(List.of(restore, close), Arrays.asList(write.getSuppressed()));
+ verify(jdbc.connection).rollback(); verify(jdbc.connection, never()).commit(); verify(jdbc.connection).close();
+ }
+
+ @Test void runtimeFailureIsNotReplacedByRestorationFailure() throws Exception {
+ Jdbc jdbc = new Jdbc(); IllegalArgumentException write = new IllegalArgumentException("value conversion failed"); SQLException restore = new SQLException("restore failed");
+ when(jdbc.update.executeUpdate()).thenThrow(write); doThrow(restore).when(jdbc.connection).setAutoCommit(true);
+ assertSame(write, assertThrows(IllegalArgumentException.class, () -> jdbc.write()));
+ assertEquals(List.of(restore), Arrays.asList(write.getSuppressed())); verify(jdbc.connection).rollback();
+ verify(jdbc.connection, never()).commit(); verify(jdbc.connection).close();
+ }
+
+ @Test void failedRollbackNeverEnablesAutoCommitOnPartialBatch() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException write = new SQLException("update failed"); SQLException rollback = new SQLException("rollback failed");
+ when(jdbc.update.executeUpdate()).thenThrow(write); doThrow(rollback).when(jdbc.connection).rollback();
+ IllegalStateException result = assertThrows(IllegalStateException.class, () -> jdbc.write());
+ assertSame(write, result.getCause()); assertEquals(List.of(rollback), Arrays.asList(write.getSuppressed()));
+ verify(jdbc.connection, never()).setAutoCommit(true); verify(jdbc.connection, never()).commit(); verify(jdbc.connection).close();
+ }
+
+ @Test void commitFailureRemainsAFailureAndAttemptsRollback() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException commit = new SQLException("commit failed"); SQLException restore = new SQLException("restore failed");
+ doThrow(commit).when(jdbc.connection).commit(); doThrow(restore).when(jdbc.connection).setAutoCommit(true);
+ IllegalStateException result = assertThrows(IllegalStateException.class, () -> jdbc.write());
+ assertSame(commit, result.getCause()); assertEquals(List.of(restore), Arrays.asList(commit.getSuppressed()));
+ verify(jdbc.connection).rollback(); verify(jdbc.connection).close();
+ }
+
+ @Test void setupFailureStillClosesConnectionAndKeepsOriginalCause() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException setup = new SQLException("cannot inspect auto-commit"); SQLException close = new SQLException("close failed");
+ when(jdbc.connection.getAutoCommit()).thenThrow(setup); doThrow(close).when(jdbc.connection).close();
+ IllegalStateException result = assertThrows(IllegalStateException.class, () -> jdbc.write());
+ assertSame(setup, result.getCause()); assertEquals(List.of(close), Arrays.asList(setup.getSuppressed()));
+ verify(jdbc.connection, never()).commit(); verify(jdbc.connection, never()).rollback(); verify(jdbc.connection).close();
+ }
+
+ @Test void throwingLoggerCannotMakeCommittedCleanupRetryable() throws Exception {
+ Jdbc jdbc = new Jdbc(); SQLException restore = new SQLException("restore failed"); IllegalStateException logging = new IllegalStateException("logger failed");
+ doThrow(restore).when(jdbc.connection).setAutoCommit(true); doThrow(logging).when(jdbc.logger).warn(anyString(), same(restore));
+ assertDoesNotThrow(() -> jdbc.write()); assertEquals(List.of(logging), Arrays.asList(restore.getSuppressed())); verify(jdbc.connection).commit();
+ }
+
+ @Test void throwingLoggerCannotReplaceSqlOperationFailure() throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ SQLException sqlFailure = new SQLException("query failed");
+ IllegalStateException loggingFailure = new IllegalStateException("logger failed");
+ SqlBackendLogger logger = mock(SqlBackendLogger.class);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeQuery()).thenThrow(sqlFailure);
+ doThrow(loggingFailure).when(logger).warn(anyString(), same(sqlFailure));
+ JdbcSqlUserStorage storage = new JdbcSqlUserStorage(UserStorage.SQLITE, USER, "Users",
+ SqlUserSchema.builder().build(), () -> connection, JdbcSqlUserStorage.Dialect.SQLITE, logger);
+
+ IllegalStateException result = assertThrows(IllegalStateException.class,
+ () -> storage.contains(UserStorage.SQLITE));
+
+ assertSame(sqlFailure, result.getCause());
+ assertEquals(List.of(loggingFailure), Arrays.asList(sqlFailure.getSuppressed()));
+ }
+
+ @Test void uuidMetadataCannotChangeBoundIdentityInAnyDialect() throws Exception {
+ for (JdbcSqlUserStorage.Dialect dialect : JdbcSqlUserStorage.Dialect.values()) {
+ Jdbc jdbc = new Jdbc(); UserStorage type = dialect == JdbcSqlUserStorage.Dialect.SQLITE ? UserStorage.SQLITE : UserStorage.MYSQL;
+ JdbcSqlUserStorage user = jdbc.user(type, dialect);
+ HashMap values = new HashMap<>();
+ values.put("uuid", new DataValueString(UUID.randomUUID().toString())); values.put("UUID", null);
+ values.put("UuId", new DataValueString("not an identity")); values.put("Points", new DataValueInt(17));
+ HashMap before = new HashMap<>(values); user.writeValues(type, values); assertEquals(before, values);
+ assertEquals(dialect == JdbcSqlUserStorage.Dialect.SQLITE ? 1 : 2, jdbc.sql.size());
+ assertTrue(jdbc.sql.stream().noneMatch(sql -> sql.startsWith("UPDATE") || sql.contains("SET " + dialect.quote("uuid"))));
+ if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) verify(jdbc.insert).setObject(1, USER); else verify(jdbc.insert).setString(1, USER.toString());
+ if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) verify(jdbc.insert).setObject(2, "17", Types.OTHER);
+ else verify(jdbc.insert).setInt(2, 17);
+ verify(jdbc.connection).commit();
+ }
+ }
+
+ @Test void bulkValuesUseOneAtomicUpdateStatementForAnExistingRow() throws Exception {
+ Connection connection = mock(Connection.class); when(connection.getAutoCommit()).thenReturn(true);
+ PreparedStatement statement = mock(PreparedStatement.class); ResultSet existing = mock(ResultSet.class); when(existing.next()).thenReturn(true); when(statement.executeQuery()).thenReturn(existing);
+ List sql = new ArrayList<>(); when(connection.prepareStatement(anyString())).thenAnswer(call -> { sql.add(call.getArgument(0, String.class)); return statement; });
+ SqlUserSchema schema = SqlUserSchema.builder().column("A", "INTEGER", DataType.INTEGER).column("B", "INTEGER", DataType.INTEGER).build();
+ JdbcSqlUserStorage user = new JdbcSqlUserStorage(UserStorage.MYSQL, USER, "Users", schema, () -> connection, JdbcSqlUserStorage.Dialect.POSTGRESQL, SqlBackendLogger.NO_OP);
+ HashMap values = new LinkedHashMap<>(); values.put("A", new DataValueInt(2)); values.put("B", new DataValueInt(2));
+ user.writeValues(UserStorage.MYSQL, values);
+ assertEquals(List.of("SELECT 1 FROM \"Users\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE", "UPDATE \"Users\" SET \"A\"=?, \"B\"=? WHERE \"uuid\"=?"), sql);
+ verify(statement).setObject(1, "2", Types.OTHER); verify(statement).setObject(2, "2", Types.OTHER); verify(statement).setObject(3, USER);
+ }
+
+ @Test void uuidOnlyBulkIsNoOpButExplicitIdentityMutationIsRejected() throws Exception {
+ Jdbc jdbc = new Jdbc(); JdbcSqlUserStorage user = jdbc.user(UserStorage.SQLITE, JdbcSqlUserStorage.Dialect.SQLITE);
+ HashMap values = new HashMap<>(); values.put("UUID", new DataValueString(UUID.randomUUID().toString()));
+ user.writeValues(UserStorage.SQLITE, values); user.writeValues(UserStorage.SQLITE, new HashMap<>());
+ assertThrows(IllegalArgumentException.class, () -> user.write(UserStorage.SQLITE, "uuid", values.get("UUID")));
+ assertThrows(IllegalArgumentException.class, () -> user.writeValues(UserStorage.MYSQL, values));
+ assertTrue(jdbc.sql.isEmpty()); verify(jdbc.connection, never()).getAutoCommit(); verify(jdbc.connection, never()).commit();
+ }
+
+ @Test void postgresPartialUpdateDoesNotReinsertAnExistingRequiredColumnRow() throws Exception {
+ Jdbc jdbc = new Jdbc(); ResultSet row = mock(ResultSet.class); when(row.next()).thenReturn(true); when(jdbc.update.executeQuery()).thenReturn(row);
+ jdbc.user(UserStorage.MYSQL, JdbcSqlUserStorage.Dialect.POSTGRESQL).write(UserStorage.MYSQL, "Points", new DataValueInt(23));
+ assertTrue(jdbc.sql.stream().noneMatch(sql -> sql.startsWith("INSERT"))); verify(jdbc.update).setObject(1, "23", Types.OTHER); verify(jdbc.connection).commit(); verify(row).close();
+ }
+
+ private static final class Jdbc {
+ final Connection connection = mock(Connection.class);
+ final PreparedStatement insert = mock(PreparedStatement.class);
+ final PreparedStatement update = mock(PreparedStatement.class);
+ final SqlBackendLogger logger = mock(SqlBackendLogger.class);
+ final List sql = new ArrayList<>();
+ Jdbc() throws SQLException {
+ when(connection.getAutoCommit()).thenReturn(true); when(insert.executeUpdate()).thenReturn(1);
+ when(update.executeQuery()).thenReturn(mock(ResultSet.class));
+ when(connection.prepareStatement(anyString())).thenAnswer(invocation -> { String query = invocation.getArgument(0, String.class); sql.add(query); return query.startsWith("INSERT") ? insert : update; });
+ }
+ JdbcSqlUserStorage user(UserStorage type, JdbcSqlUserStorage.Dialect dialect) {
+ return new JdbcSqlUserStorage(type, USER, "Users", SqlUserSchema.builder().column("Points", "INTEGER", DataType.INTEGER).build(), () -> connection, dialect, logger);
+ }
+ void write() {
+ try {
+ when(insert.executeUpdate()).thenReturn(0);
+ ResultSet existing = mock(ResultSet.class); when(existing.next()).thenReturn(true); when(update.executeQuery()).thenReturn(existing);
+ } catch (SQLException impossible) { throw new AssertionError(impossible); }
+ user(UserStorage.SQLITE, JdbcSqlUserStorage.Dialect.SQLITE).write(UserStorage.SQLITE, "Points", new DataValueInt(17));
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBackendReviewRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBackendReviewRegressionTest.java
new file mode 100644
index 000000000..3a3d8e28a
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBackendReviewRegressionTest.java
@@ -0,0 +1,219 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+/** Exercises real backend/SimpleAPI construction with mocked JDBC, not a live database. */
+class MysqlBackendReviewRegressionTest {
+ @Test void malformedUuidDiagnosticsAreBoundedAndValueFree() throws Exception {
+ Fixture fixture = new Fixture(DbType.MYSQL, "Points");
+ fixture.enumerationRows = List.of("1-1-1-1-1", "private-two");
+ List warnings = new ArrayList<>();
+ SqlBackendLogger logger = new SqlBackendLogger() {
+ @Override public void info(String message) { }
+ @Override public void warn(String message, Throwable error) {
+ warnings.add(message + ":" + error.getMessage());
+ }
+ };
+ try (var managers = fixture.managers(); var backend = fixture.open(logger)) {
+ assertTrue(backend.enumerateUsers().isEmpty());
+ }
+ assertEquals(List.of("Skipping malformed UUID entries while enumerating SQL users; further diagnostics suppressed:Malformed SQL UUID value"), warnings);
+ }
+
+ @Test void uppercaseUuidRowsAreNotExposedAsUnaddressableLowercaseUsers() throws Exception {
+ Fixture fixture = new Fixture(DbType.MYSQL, "Points");
+ fixture.enumerationRows = List.of("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE");
+
+ try (var managers = fixture.managers(); var backend = fixture.open()) {
+ assertTrue(backend.enumerateUsers().isEmpty());
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void postgresRenamesCaseOnlyHistoricalColumnInsteadOfCreatingAParallelColumn() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL, "points");
+ try (var managers = fixture.managers()) {
+ try (var backend = fixture.open()) {
+ assertTrue(backend.isOpen());
+ assertTrue(fixture.adds.isEmpty());
+ assertEquals(List.of("ALTER TABLE \"Users\" RENAME COLUMN \"points\" TO \"Points\";"), fixture.renames);
+ }
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void postgresDoesNotRecreateTheExactColumn() throws Exception { existingSpelling(DbType.POSTGRESQL, "Points"); }
+ @Test void mysqlRetainsCaseInsensitiveColumnLookup() throws Exception { existingSpelling(DbType.MYSQL, "points"); }
+ @Test void mariaDbRetainsCaseInsensitiveColumnLookup() throws Exception { existingSpelling(DbType.MARIADB, "points"); }
+
+ private void existingSpelling(DbType type, String name) throws Exception {
+ Fixture fixture = new Fixture(type, name);
+ try (var managers = fixture.managers(); var backend = fixture.open()) {
+ assertTrue(backend.isOpen());
+ assertTrue(fixture.adds.isEmpty());
+ assertTrue(fixture.renames.isEmpty());
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void competingUuidConversionIsAcceptedAfterFreshInspection() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL, "Points");
+ fixture.uuidType = "character varying";
+ fixture.migrationFailure = new SQLException("invalid input syntax for uuid", "22P02");
+ fixture.competitorConverts = true;
+ try (var managers = fixture.managers()) {
+ try (var backend = fixture.open()) {
+ assertTrue(backend.isOpen());
+ assertEquals(2, fixture.uuidInspections);
+ assertEquals(1, fixture.migrations);
+ verify(managers.constructed().get(0), never()).close();
+ }
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void genuineUuidConversionFailureStillRejectsConstruction() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL, "Points");
+ fixture.uuidType = "character varying";
+ fixture.migrationFailure = new SQLException("bad stored UUID", "22P02");
+ try (var managers = fixture.managers()) {
+ assertSame(fixture.migrationFailure, assertThrows(IllegalStateException.class, fixture::open).getCause());
+ assertEquals(2, fixture.uuidInspections);
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void failedUuidReinspectionRetainsTheOriginalDdlFailure() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL, "Points");
+ fixture.uuidType = "character varying";
+ fixture.migrationFailure = new SQLException("conversion failed", "22P02");
+ fixture.reinspectionFailure = new SQLException("connection lost", "08006");
+ try (var managers = fixture.managers()) {
+ assertSame(fixture.migrationFailure, assertThrows(IllegalStateException.class, fixture::open).getCause());
+ assertEquals(List.of(fixture.reinspectionFailure), List.of(fixture.migrationFailure.getSuppressed()));
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ private static final class Fixture {
+ final DbType type;
+ final String storedColumn;
+ final List adds = new ArrayList<>();
+ final List renames = new ArrayList<>();
+ final List connections = new ArrayList<>();
+ final List statements = new ArrayList<>();
+ final List results = new ArrayList<>();
+ String uuidType = "uuid";
+ int uuidInspections, migrations;
+ boolean competitorConverts;
+ SQLException migrationFailure, reinspectionFailure;
+ List enumerationRows = List.of();
+
+ Fixture(DbType type, String storedColumn) {
+ this.type = type;
+ this.storedColumn = storedColumn;
+ if (type != DbType.POSTGRESQL) uuidType = "varchar";
+ }
+
+ MockedConstruction managers() {
+ return mockConstruction(ConnectionManager.class, (manager, context) -> {
+ when(manager.open()).thenReturn(true);
+ when(manager.getDbType()).thenReturn(type);
+ when(manager.getConnection()).thenAnswer(ignored -> connection());
+ });
+ }
+
+ MysqlUserBackend open() {
+ return open(SqlBackendLogger.NO_OP);
+ }
+
+ MysqlUserBackend open(SqlBackendLogger logger) {
+ MysqlConfig config = new MysqlConfig();
+ config.setDbType(type);
+ config.setDatabase("test_database");
+ config.setTablePrefix("");
+ config.setTableName("Users");
+ config.setMaxThreads(1);
+ return new MysqlUserBackend("Users", config, SqlUserSchema.builder()
+ .column("Points", "INT DEFAULT '0'", DataType.INTEGER).build(), logger);
+ }
+
+ Connection connection() throws SQLException {
+ Connection connection = mock(Connection.class);
+ connections.add(connection);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0, String.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ statements.add(statement);
+ when(statement.executeUpdate()).thenAnswer(ignored -> {
+ if (sql.contains(" ADD COLUMN ")) adds.add(sql);
+ if (sql.contains(" RENAME COLUMN ")) renames.add(sql);
+ if (sql.contains(" ALTER COLUMN ")) {
+ migrations++;
+ if (competitorConverts) uuidType = "uuid";
+ if (migrationFailure != null) throw migrationFailure;
+ uuidType = "uuid";
+ }
+ return 0;
+ });
+ when(statement.executeQuery()).thenAnswer(ignored -> {
+ boolean inspectUuid = sql.toLowerCase(Locale.ROOT).startsWith("select data_type,");
+ if (inspectUuid && ++uuidInspections > 1 && reinspectionFailure != null) throw reinspectionFailure;
+ ResultSet result = mock(ResultSet.class);
+ results.add(result);
+ if (inspectUuid) {
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(uuidType);
+ when(result.getObject(2)).thenReturn(37L);
+ when(result.getString("DATA_TYPE")).thenReturn(uuidType);
+ when(result.getObject("CHARACTER_MAXIMUM_LENGTH")).thenReturn(37L);
+ when(result.getString("COLUMN_DEFAULT")).thenReturn(null);
+ } else if (sql.endsWith("WHERE 1=0")) {
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(2);
+ when(metadata.getColumnName(1)).thenReturn("uuid");
+ when(metadata.getColumnName(2)).thenReturn(storedColumn);
+ } else if (sql.startsWith("SELECT `uuid` FROM")) {
+ AtomicInteger row = new AtomicInteger();
+ when(result.next()).thenAnswer(invocation -> row.get() < enumerationRows.size());
+ when(result.getString(1)).thenAnswer(invocation -> enumerationRows.get(row.getAndIncrement()));
+ }
+ return result;
+ });
+ return statement;
+ });
+ return connection;
+ }
+
+ void assertClosed() throws SQLException {
+ for (Connection connection : connections) verify(connection).close();
+ for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close();
+ for (ResultSet result : results) verify(result).close();
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlConcurrentSchemaTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlConcurrentSchemaTest.java
new file mode 100644
index 000000000..26a4cdab0
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlConcurrentSchemaTest.java
@@ -0,0 +1,148 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import java.sql.*;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+/** Two-node ADD race at the JDBC boundary, not a live shared database test. */
+class MysqlConcurrentSchemaTest {
+ @Test void mysqlAcceptsColumnCreatedByAnotherNode() throws Exception { concurrent(DbType.MYSQL); }
+ @Test void mariaDbAcceptsColumnCreatedByAnotherNode() throws Exception { concurrent(DbType.MARIADB); }
+ @Test void postgresAcceptsColumnCreatedByAnotherNode() throws Exception { concurrent(DbType.POSTGRESQL); }
+
+ private void concurrent(DbType type) throws Exception {
+ Fixture fixture = new Fixture(type);
+ fixture.columnAppears = true;
+ try (var managers = fixture.managers(); var backend = fixture.open()) {
+ assertTrue(backend.isOpen());
+ assertEquals(3, fixture.inspections,
+ "the raced column is re-read once more to verify whether its physical type needs migration");
+ assertEquals(1, fixture.adds);
+ verify(managers.constructed().get(0), never()).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void duplicateCodeWithoutColumnStillFailsAndClosesOwner() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL);
+ try (var managers = fixture.managers()) {
+ assertSame(fixture.ddl, assertThrows(IllegalStateException.class, fixture::open).getCause());
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void unrelatedDdlErrorIsNeverHiddenByAnExistingColumn() throws Exception {
+ Fixture fixture = new Fixture(DbType.MYSQL);
+ fixture.columnAppears = true;
+ fixture.ddl = new SQLException("permission denied", "42000", 1142);
+ try (var managers = fixture.managers()) {
+ assertSame(fixture.ddl, assertThrows(IllegalStateException.class, fixture::open).getCause());
+ assertEquals(1, fixture.inspections);
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void failedRecheckRetainsBothDdlAndInspectionEvidence() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL);
+ fixture.recheckFailure = new SQLException("inspection failed");
+ try (var managers = fixture.managers()) {
+ assertSame(fixture.ddl, assertThrows(IllegalStateException.class, fixture::open).getCause());
+ assertEquals(List.of(fixture.recheckFailure), List.of(fixture.ddl.getSuppressed()));
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ private static final class Fixture {
+ final DbType type;
+ final List connections = new ArrayList<>();
+ final List statements = new ArrayList<>();
+ final List legacyStatements = new ArrayList<>();
+ final List results = new ArrayList<>();
+ SQLException ddl;
+ SQLException recheckFailure;
+ boolean columnAppears;
+ int inspections, adds;
+ Fixture(DbType type) {
+ this.type = type;
+ ddl = type == DbType.POSTGRESQL ? new SQLException("duplicate column", "42701")
+ : new SQLException("duplicate column", "42S21", 1060);
+ }
+ MockedConstruction managers() {
+ return mockConstruction(ConnectionManager.class, (manager, context) -> {
+ when(manager.open()).thenReturn(true);
+ when(manager.getDbType()).thenReturn(type);
+ when(manager.getConnection()).thenAnswer(ignored -> connection());
+ });
+ }
+ MysqlUserBackend open() {
+ MysqlConfig config = new MysqlConfig();
+ config.setDbType(type);
+ config.setDatabase("test_database");
+ config.setMaxThreads(1);
+ return new MysqlUserBackend("Users", config, SqlUserSchema.builder()
+ .column("Player Name", "VARCHAR(30)", DataType.STRING).build(), SqlBackendLogger.NO_OP);
+ }
+ Connection connection() throws SQLException {
+ Connection connection = mock(Connection.class);
+ connections.add(connection);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0, String.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ if (sql.contains(" ADD COLUMN ") || sql.endsWith("WHERE 1=0")) statements.add(statement);
+ else legacyStatements.add(statement);
+ when(statement.executeUpdate()).thenAnswer(ignored -> {
+ if (sql.contains(" ADD COLUMN ")) { adds++; throw ddl; }
+ return 0;
+ });
+ when(statement.executeQuery()).thenAnswer(ignored -> {
+ if (sql.endsWith("WHERE 1=0")) {
+ inspections++;
+ if (inspections > 1 && recheckFailure != null) throw recheckFailure;
+ }
+ ResultSet result = mock(ResultSet.class);
+ results.add(result);
+ if (sql.toLowerCase(Locale.ROOT).startsWith("select data_type,")) {
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(type == DbType.POSTGRESQL ? "uuid" : "varchar");
+ when(result.getString("DATA_TYPE")).thenReturn("varchar");
+ when(result.getObject("CHARACTER_MAXIMUM_LENGTH")).thenReturn(37L);
+ when(result.getString("COLUMN_DEFAULT")).thenReturn(null);
+ } else if (sql.endsWith("WHERE 1=0")) {
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(columnAppears && adds > 0 ? 2 : 1);
+ when(metadata.getColumnName(1)).thenReturn("uuid");
+ when(metadata.getColumnName(2)).thenReturn("Player Name");
+ }
+ return result;
+ });
+ return statement;
+ });
+ return connection;
+ }
+ void assertClosed() throws SQLException {
+ for (Connection connection : connections) verify(connection).close();
+ for (PreparedStatement statement : statements) verify(statement).close();
+ // SimpleAPI-owned statements (including connection setup) may close twice.
+ // Keep exactly-once assertions for this backend's inspection and ADD statements.
+ for (PreparedStatement statement : legacyStatements) verify(statement, atLeastOnce()).close();
+ for (ResultSet result : results) verify(result).close();
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendAdmissionRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendAdmissionRaceTest.java
new file mode 100644
index 000000000..ca5ea7eef
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendAdmissionRaceTest.java
@@ -0,0 +1,62 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+class MysqlUserBackendAdmissionRaceTest {
+ @Test void callerQueuedBeforeCloseCannotStartAfterCloseReturns() throws Exception {
+ Connection connection = mock(Connection.class);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(statement.executeUpdate()).thenReturn(0);
+ when(statement.executeQuery()).thenReturn(mock(ResultSet.class));
+ return statement;
+ });
+ MysqlConfig config = new MysqlConfig();
+ config.setDbType(DbType.MYSQL); config.setDatabase("test_database"); config.setTablePrefix(""); config.setTableName("Users"); config.setMaxThreads(1);
+ try (MockedConstruction managers = mockConstruction(ConnectionManager.class, (manager, context) -> {
+ when(manager.open()).thenReturn(true); when(manager.getDbType()).thenReturn(DbType.MYSQL); when(manager.getConnection()).thenReturn(connection);
+ })) {
+ MysqlUserBackend backend = new MysqlUserBackend("Users", config, SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP);
+ SqlUserStorage retained = backend.user(UUID.randomUUID());
+ Field operationsField = MysqlUserBackend.class.getDeclaredField("operations"); operationsField.setAccessible(true);
+ ReentrantReadWriteLock operations = (ReentrantReadWriteLock) operationsField.get(backend);
+ AtomicReference outcome = new AtomicReference<>();
+ operations.writeLock().lock();
+ Thread caller = new Thread(() -> {
+ try { retained.contains(UserStorage.MYSQL); }
+ catch (Throwable failure) { outcome.set(failure); }
+ }, "mysql-admission-race");
+ try {
+ caller.start();
+ for (int i = 0; i < 200 && !operations.hasQueuedThreads(); i++) Thread.sleep(5);
+ assertTrue(operations.hasQueuedThreads(), "storage caller did not reach the read-lock admission boundary");
+ backend.close();
+ } finally { operations.writeLock().unlock(); }
+ caller.join(5000);
+ assertTrue(!caller.isAlive(), "delayed storage caller did not finish");
+ assertInstanceOf(IllegalStateException.class, outcome.get());
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendLifecycleReviewTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendLifecycleReviewTest.java
new file mode 100644
index 000000000..5b26986f1
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendLifecycleReviewTest.java
@@ -0,0 +1,97 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+class MysqlUserBackendLifecycleReviewTest {
+ @Test
+ void closeRejectsNewWorkAndWaitsForActiveEnumerationCallbacks() throws Exception {
+ UUID enumerated = UUID.randomUUID();
+ Connection connection = mock(Connection.class);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String sql = call.getArgument(0, String.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(statement.executeUpdate()).thenReturn(0);
+ ResultSet result = mock(ResultSet.class);
+ if (sql.endsWith("WHERE 1=0")) {
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(2);
+ when(metadata.getColumnName(1)).thenReturn("uuid");
+ when(metadata.getColumnName(2)).thenReturn("Points");
+ } else if (sql.startsWith("SELECT `uuid` FROM")) {
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(enumerated.toString());
+ }
+ when(statement.executeQuery()).thenReturn(result);
+ return statement;
+ });
+
+ MysqlConfig config = new MysqlConfig();
+ config.setDbType(DbType.MYSQL);
+ config.setDatabase("test_database");
+ config.setTablePrefix("");
+ config.setTableName("Users");
+ config.setMaxThreads(1);
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INT DEFAULT '0'", DataType.INTEGER).build();
+
+ try (MockedConstruction managers = mockConstruction(ConnectionManager.class,
+ (manager, context) -> {
+ when(manager.open()).thenReturn(true);
+ when(manager.getDbType()).thenReturn(DbType.MYSQL);
+ when(manager.getConnection()).thenReturn(connection);
+ })) {
+ MysqlUserBackend backend = new MysqlUserBackend("Users", config, schema, SqlBackendLogger.NO_OP);
+ CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
+ var pool = Executors.newFixedThreadPool(2);
+ try {
+ var enumeration = pool.submit(() -> backend.forEachUser(uuid -> {
+ entered.countDown();
+ try { assertTrue(release.await(5, TimeUnit.SECONDS)); }
+ catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); }
+ }));
+ assertTrue(entered.await(5, TimeUnit.SECONDS));
+ var closing = pool.submit(backend::close);
+ for (int i = 0; i < 100 && backend.isOpen(); i++) Thread.sleep(5);
+ assertFalse(backend.isOpen());
+ assertThrows(IllegalStateException.class, () -> backend.user(UUID.randomUUID()));
+ assertThrows(TimeoutException.class, () -> closing.get(150, TimeUnit.MILLISECONDS));
+ release.countDown();
+ enumeration.get(5, TimeUnit.SECONDS);
+ closing.get(5, TimeUnit.SECONDS);
+ verify(connection).prepareStatement(org.mockito.ArgumentMatchers.contains(
+ "WHERE `uuid` IS NOT NULL"));
+ verify(managers.constructed().get(0)).close();
+ } finally {
+ release.countDown();
+ pool.shutdownNow();
+ assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
+ backend.close();
+ }
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java
new file mode 100644
index 000000000..94bcedc28
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java
@@ -0,0 +1,251 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+/** Mocked JDBC contract tests through the real backend and SimpleAPI construction. */
+class MysqlUserBackendSchemaExpansionTest {
+ @Test void mysqlExpansionMatchesDeclaredCreateTypes() throws Exception { checkTypes(DbType.MYSQL); }
+ @Test void mariaDbExpansionMatchesDeclaredCreateTypes() throws Exception { checkTypes(DbType.MARIADB); }
+ @Test void postgresExpansionUsesTheSameNormalizationAsCreate() throws Exception { checkTypes(DbType.POSTGRESQL); }
+
+ private void checkTypes(DbType type) throws Exception {
+ Fixture fixture = new Fixture(type);
+ try (var managers = fixture.managers(); var backend = fixture.open()) {
+ assertTrue(backend.isOpen());
+ String q = type == DbType.POSTGRESQL ? "\"" : "`";
+ String prefix = "ALTER TABLE " + q + "Users" + q + " ADD COLUMN ";
+ assertTrue(fixture.sql.contains(prefix + q + "Player Name" + q + " VARCHAR(30);"));
+ assertTrue(fixture.sql.contains(prefix + q + "History" + q + " "
+ + (type == DbType.POSTGRESQL ? "TEXT" : "MEDIUMTEXT") + ";"));
+ assertTrue(fixture.sql.contains(prefix + q + "Votes" + q + " INT DEFAULT '0';"));
+ assertEquals(3, fixture.sql.stream().filter(s -> s.startsWith("ALTER TABLE")).count());
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void existingColumnsAreNotAlteredEvenWithDifferentCasing() throws Exception {
+ Fixture fixture = new Fixture(DbType.MYSQL);
+ fixture.existing.addAll(List.of("player name", "HISTORY", "Votes"));
+ try (var managers = fixture.managers(); var backend = fixture.open()) {
+ assertFalse(fixture.sql.stream().anyMatch(s -> s.startsWith("ALTER TABLE")));
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void postgresRejectsAmbiguousCaseFoldedColumns() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL);
+ fixture.existing.addAll(List.of("Points", "points"));
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INT DEFAULT '0'", DataType.INTEGER).build();
+ try (var managers = fixture.managers()) {
+ IllegalStateException failure = assertThrows(IllegalStateException.class, () -> fixture.open(schema));
+ assertInstanceOf(SQLException.class, failure.getCause());
+ assertTrue(failure.getCause().getMessage().contains("Ambiguous case-folded SQL columns"));
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void postgresMigratesRetainedNumericColumnsBeforeStringWrites() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL);
+ fixture.existing.add("Note");
+ fixture.numericColumns.add("Note");
+ SqlUserSchema schema = SqlUserSchema.builder().column("Note", "TEXT", DataType.STRING).build();
+ try (var managers = fixture.managers(); var backend = fixture.open(schema)) {
+ assertTrue(fixture.sql.contains(
+ "ALTER TABLE \"Users\" ALTER COLUMN \"Note\" TYPE TEXT USING \"Note\"::text;"));
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void postgresNumericToStringMigrationPreservesNumericDefault() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL);
+ fixture.existing.add("Note");
+ fixture.numericColumns.add("Note");
+ fixture.columnDefaults.put("Note", "0");
+ SqlUserSchema schema = SqlUserSchema.builder().column("Note", "TEXT", DataType.STRING).build();
+ try (var managers = fixture.managers(); var backend = fixture.open(schema)) {
+ assertTrue(fixture.sql.contains("ALTER TABLE \"Users\" ALTER COLUMN \"Note\" DROP DEFAULT,"
+ + " ALTER COLUMN \"Note\" TYPE TEXT USING \"Note\"::text,"
+ + " ALTER COLUMN \"Note\" SET DEFAULT (0)::text;"));
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void mysqlMigratesRetainedNumericColumnsBeforeStringWrites() throws Exception {
+ assertMysqlFamilyMigratesRetainedNumericColumn(DbType.MYSQL);
+ }
+
+ @Test void mariaDbMigratesRetainedNumericColumnsBeforeStringWrites() throws Exception {
+ assertMysqlFamilyMigratesRetainedNumericColumn(DbType.MARIADB);
+ }
+
+ private void assertMysqlFamilyMigratesRetainedNumericColumn(DbType dbType) throws Exception {
+ Fixture fixture = new Fixture(dbType);
+ fixture.existing.add("Note");
+ fixture.numericColumns.add("Note");
+ fixture.nonNullableColumns.add("Note");
+ fixture.columnDefaults.put("Note", "0");
+ SqlUserSchema schema = SqlUserSchema.builder().column("Note", "TEXT", DataType.STRING).build();
+ try (var managers = fixture.managers(); var backend = fixture.open(schema)) {
+ assertTrue(fixture.sql.contains(
+ "ALTER TABLE `Users` MODIFY COLUMN `Note` TEXT NOT NULL DEFAULT '0';"));
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void postgresMigratesRetainedBooleanColumnsBeforeStringWrites() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL);
+ fixture.existing.add("Note");
+ fixture.booleanColumns.add("Note");
+ SqlUserSchema schema = SqlUserSchema.builder().column("Note", "TEXT", DataType.STRING).build();
+ try (var managers = fixture.managers(); var backend = fixture.open(schema)) {
+ assertTrue(fixture.sql.contains(
+ "ALTER TABLE \"Users\" ALTER COLUMN \"Note\" TYPE TEXT USING \"Note\"::text;"));
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void failedAddRejectsInitializationAndClosesThePool() throws Exception {
+ Fixture fixture = new Fixture(DbType.POSTGRESQL);
+ fixture.addFailure = new SQLException("DDL denied");
+ try (var managers = fixture.managers()) {
+ var error = assertThrows(IllegalStateException.class, fixture::open);
+ assertSame(fixture.addFailure, error.getCause());
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ @Test void failedInspectionIsNotTreatedAsAnEmptySchema() throws Exception {
+ Fixture fixture = new Fixture(DbType.MYSQL);
+ fixture.inspectFailure = new SQLException("metadata denied");
+ try (var managers = fixture.managers()) {
+ var error = assertThrows(IllegalStateException.class, fixture::open);
+ assertSame(fixture.inspectFailure, error.getCause());
+ assertFalse(fixture.sql.stream().anyMatch(s -> s.startsWith("ALTER TABLE")));
+ verify(managers.constructed().get(0)).close();
+ }
+ fixture.assertClosed();
+ }
+
+ private static final class Fixture {
+ final DbType type;
+ final List sql = new ArrayList<>();
+ final List existing = new ArrayList<>(List.of("uuid"));
+ final List numericColumns = new ArrayList<>();
+ final List booleanColumns = new ArrayList<>();
+ final List nonNullableColumns = new ArrayList<>();
+ final java.util.Map columnDefaults = new java.util.HashMap<>();
+ final List connections = new ArrayList<>();
+ final List statements = new ArrayList<>();
+ final List results = new ArrayList<>();
+ SQLException addFailure;
+ SQLException inspectFailure;
+ Fixture(DbType type) { this.type = type; }
+
+ MockedConstruction managers() {
+ return mockConstruction(ConnectionManager.class, (manager, context) -> {
+ when(manager.open()).thenReturn(true);
+ when(manager.getDbType()).thenReturn(type);
+ when(manager.getConnection()).thenAnswer(ignored -> connection());
+ });
+ }
+
+ MysqlUserBackend open() {
+ return open(SqlUserSchema.builder()
+ .column("Player Name", "VARCHAR(30)", DataType.STRING)
+ .column("History", "MEDIUMTEXT", DataType.STRING)
+ .column("Votes", "INT DEFAULT '0'", DataType.INTEGER).build());
+ }
+
+ MysqlUserBackend open(SqlUserSchema schema) {
+ MysqlConfig config = new MysqlConfig();
+ config.setDbType(type);
+ config.setDatabase("test_database");
+ config.setMaxThreads(1);
+ return new MysqlUserBackend("Users", config, schema, SqlBackendLogger.NO_OP);
+ }
+
+ Connection connection() throws SQLException {
+ Connection connection = mock(Connection.class);
+ connections.add(connection);
+ when(connection.prepareStatement(anyString())).thenAnswer(call -> {
+ String query = call.getArgument(0, String.class);
+ sql.add(query);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ statements.add(statement);
+ String[] stringParameters = new String[3];
+ doAnswer(parameter -> {
+ stringParameters[parameter.getArgument(0, Integer.class)] = parameter.getArgument(1, String.class);
+ return null;
+ }).when(statement).setString(anyInt(), anyString());
+ when(statement.executeUpdate()).thenAnswer(ignored -> {
+ if (query.startsWith("ALTER TABLE") && addFailure != null) throw addFailure;
+ return 0;
+ });
+ when(statement.executeQuery()).thenAnswer(ignored -> {
+ if (query.endsWith("WHERE 1=0") && inspectFailure != null) throw inspectFailure;
+ ResultSet result = mock(ResultSet.class);
+ results.add(result);
+ if (query.toLowerCase(Locale.ROOT).startsWith("select data_type,")) {
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(type == DbType.POSTGRESQL ? "uuid" : "varchar");
+ when(result.getString("DATA_TYPE")).thenReturn("varchar");
+ when(result.getObject("CHARACTER_MAXIMUM_LENGTH")).thenReturn(37L);
+ when(result.getString("COLUMN_DEFAULT")).thenReturn(null);
+ } else if (query.startsWith("SELECT IS_NULLABLE, COLUMN_DEFAULT")) {
+ String column = stringParameters[2];
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(nonNullableColumns.contains(column) ? "NO" : "YES");
+ when(result.getString(2)).thenReturn(columnDefaults.get(column));
+ when(result.getString(3)).thenReturn("");
+ when(result.getString(4)).thenReturn("");
+ } else if (query.startsWith("SELECT pg_catalog.pg_get_expr")) {
+ String column = stringParameters[2];
+ String defaultValue = columnDefaults.get(column);
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(defaultValue);
+ } else if (query.endsWith("WHERE 1=0")) {
+ ResultSetMetaData metadata = mock(ResultSetMetaData.class);
+ when(result.getMetaData()).thenReturn(metadata);
+ when(metadata.getColumnCount()).thenReturn(existing.size());
+ for (int i = 0; i < existing.size(); i++) {
+ when(metadata.getColumnName(i + 1)).thenReturn(existing.get(i));
+ when(metadata.getColumnType(i + 1)).thenReturn(booleanColumns.contains(existing.get(i))
+ ? java.sql.Types.BOOLEAN : numericColumns.contains(existing.get(i))
+ ? java.sql.Types.INTEGER : java.sql.Types.VARCHAR);
+ }
+ }
+ return result;
+ });
+ return statement;
+ });
+ return connection;
+ }
+
+ void assertClosed() throws SQLException {
+ for (Connection connection : connections) verify(connection).close();
+ for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close();
+ for (ResultSet result : results) verify(result).close();
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendUuidMigrationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendUuidMigrationTest.java
new file mode 100644
index 000000000..80fd88e59
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendUuidMigrationTest.java
@@ -0,0 +1,232 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedConstruction;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig;
+
+/** Real backend/SimpleAPI construction with mocked JDBC; not a live PostgreSQL test. */
+class MysqlUserBackendUuidMigrationTest {
+ private static final UUID UUID_VALUE = UUID.fromString("542b75a0-5333-4f44-828c-94676443cf5d");
+ private static final String TABLE = "prefix-User \"Data\"";
+ private static final String QUOTED_TABLE = "\"prefix-User \"\"Data\"\"\"";
+ private static final String ALTER_UUID = "ALTER TABLE " + QUOTED_TABLE
+ + " ALTER COLUMN \"uuid\" TYPE UUID USING NULLIF(\"uuid\", '')::uuid;";
+ private static final String ALTER_MYSQL_UUID = "ALTER TABLE `prefix-User \"Data\"` MODIFY `uuid` VARCHAR(37);";
+
+ @Test
+ void migratesNarrowMysqlFamilyUuidSynchronouslyBeforeUserAccess() throws Exception {
+ for (DbType dbType : List.of(DbType.MYSQL, DbType.MARIADB)) {
+ JdbcFixture jdbc = new JdbcFixture(dbType, "varchar", 16L);
+ try (MockedConstruction managers = jdbc.managers()) {
+ try (MysqlUserBackend backend = openBackend(dbType)) {
+ assertTrue(backend.isOpen());
+ assertEquals(37L, jdbc.uuidLength);
+ assertSame(Thread.currentThread(), jdbc.migrationThread);
+ assertEquals(1, jdbc.sql.stream().filter(ALTER_MYSQL_UUID::equals).count());
+ assertTrue(backend.user(UUID_VALUE).contains(UserStorage.MYSQL));
+ verify(jdbc.lookup).setString(1, UUID_VALUE.toString());
+ assertEquals(List.of("migration-complete", "text-uuid-lookup"), jdbc.events);
+ }
+ assertEquals(1, managers.constructed().size());
+ verify(managers.constructed().get(0)).close();
+ jdbc.verifyClosed();
+ }
+ }
+ }
+
+ @Test
+ void migratesLegacyVarcharUuidSynchronouslyBeforeUserAccess() throws Exception {
+ JdbcFixture jdbc = new JdbcFixture("character varying");
+ try (MockedConstruction managers = jdbc.managers()) {
+ try (MysqlUserBackend backend = openBackend()) {
+ assertTrue(backend.isOpen());
+ assertEquals("uuid", jdbc.uuidType);
+ assertSame(Thread.currentThread(), jdbc.migrationThread);
+ assertEquals(1, jdbc.sql.stream().filter(ALTER_UUID::equals).count());
+ assertTrue(backend.user(UUID_VALUE).contains(UserStorage.MYSQL));
+ verify(jdbc.lookup).setObject(1, UUID_VALUE);
+ assertEquals(List.of("migration-complete", "native-uuid-lookup"), jdbc.events);
+ }
+ assertEquals(1, managers.constructed().size());
+ verify(managers.constructed().get(0)).close();
+ jdbc.verifyClosed();
+ }
+ }
+
+ @Test
+ void alreadyNativeUuidDoesNotRunAnotherAlter() throws Exception {
+ JdbcFixture jdbc = new JdbcFixture("uuid");
+ try (MockedConstruction managers = jdbc.managers()) {
+ try (MysqlUserBackend backend = openBackend()) {
+ assertTrue(backend.user(UUID_VALUE).contains(UserStorage.MYSQL));
+ assertEquals(0, jdbc.sql.stream().filter(ALTER_UUID::equals).count());
+ assertEquals(List.of("native-uuid-lookup"), jdbc.events);
+ }
+ verify(managers.constructed().get(0)).close();
+ jdbc.verifyClosed();
+ }
+ }
+
+ @Test
+ void conversionFailureRejectsConstructionAndClosesOwnedPool() throws Exception {
+ JdbcFixture jdbc = new JdbcFixture("character varying");
+ jdbc.migrationFailure = new SQLException("invalid UUID in existing row");
+ try (MockedConstruction managers = jdbc.managers()) {
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ MysqlUserBackendUuidMigrationTest::openBackend);
+ assertSame(jdbc.migrationFailure, failure.getCause());
+ assertEquals("character varying", jdbc.uuidType);
+ assertEquals(List.of(), jdbc.events);
+ verify(managers.constructed().get(0)).close();
+ jdbc.verifyClosed();
+ }
+ }
+
+ @Test
+ void inspectionFailureDoesNotExposeUnknownUuidTypeOrLeakPool() throws Exception {
+ JdbcFixture jdbc = new JdbcFixture("character varying");
+ jdbc.inspectionFailure = new SQLException("metadata unavailable");
+ try (MockedConstruction managers = jdbc.managers()) {
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ MysqlUserBackendUuidMigrationTest::openBackend);
+ assertSame(jdbc.inspectionFailure, failure.getCause());
+ assertEquals(0, jdbc.sql.stream().filter(ALTER_UUID::equals).count());
+ verify(managers.constructed().get(0)).close();
+ jdbc.verifyClosed();
+ }
+ }
+
+ private static MysqlUserBackend openBackend() {
+ return openBackend(DbType.POSTGRESQL);
+ }
+
+ private static MysqlUserBackend openBackend(DbType dbType) {
+ MysqlConfig config = new MysqlConfig();
+ config.setDbType(dbType);
+ config.setDatabase("test_database");
+ config.setMaxThreads(2);
+ config.setTablePrefix("prefix-");
+ config.setTableName("User \"Data\"");
+ return new MysqlUserBackend("ignored", config, SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP);
+ }
+
+ private static final class JdbcFixture {
+ final List connections = new ArrayList<>();
+ final List statements = new ArrayList<>();
+ final List results = new ArrayList<>();
+ final List sql = new ArrayList<>();
+ final List events = new ArrayList<>();
+ String uuidType;
+ Long uuidLength;
+ final DbType dbType;
+ Thread migrationThread;
+ SQLException migrationFailure;
+ SQLException inspectionFailure;
+ PreparedStatement lookup;
+
+ JdbcFixture(String uuidType) {
+ this(DbType.POSTGRESQL, uuidType, 37L);
+ }
+
+ JdbcFixture(DbType dbType, String uuidType, Long uuidLength) {
+ this.dbType = dbType;
+ this.uuidType = uuidType;
+ this.uuidLength = uuidLength;
+ }
+
+ MockedConstruction managers() {
+ return mockConstruction(ConnectionManager.class, (manager, context) -> {
+ when(manager.open()).thenReturn(true);
+ when(manager.getDbType()).thenReturn(dbType);
+ when(manager.getConnection()).thenAnswer(ignored -> connection());
+ });
+ }
+
+ Connection connection() throws SQLException {
+ Connection connection = mock(Connection.class);
+ connections.add(connection);
+ when(connection.prepareStatement(anyString())).thenAnswer(invocation -> {
+ String query = invocation.getArgument(0, String.class);
+ sql.add(query);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ statements.add(statement);
+ when(statement.executeUpdate()).thenAnswer(ignored -> {
+ if (ALTER_UUID.equals(query) || ALTER_MYSQL_UUID.equals(query)) {
+ migrationThread = Thread.currentThread();
+ if (migrationFailure != null) throw migrationFailure;
+ uuidType = dbType == DbType.POSTGRESQL ? "uuid" : "varchar";
+ uuidLength = dbType == DbType.POSTGRESQL ? null : 37L;
+ events.add("migration-complete");
+ }
+ // CREATE IF NOT EXISTS must not replace the legacy column type.
+ return 0;
+ });
+ when(statement.executeQuery()).thenAnswer(ignored -> {
+ if (query.toLowerCase(Locale.ROOT).startsWith("select data_type,")) {
+ if (inspectionFailure != null) throw inspectionFailure;
+ verify(statement).setString(1, TABLE);
+ verify(statement).setString(2, "uuid");
+ ResultSet result = row(uuidType);
+ when(result.getObject(2)).thenReturn(uuidLength);
+ when(result.getString("DATA_TYPE")).thenReturn(uuidType);
+ when(result.getObject("CHARACTER_MAXIMUM_LENGTH")).thenReturn(uuidLength);
+ when(result.getString("COLUMN_DEFAULT")).thenReturn(null);
+ return result;
+ }
+ if (query.startsWith("SELECT column_name FROM information_schema.columns")) {
+ return row("uuid");
+ }
+ if (query.startsWith("SELECT 1 FROM")) {
+ if (dbType == DbType.POSTGRESQL && !"uuid".equals(uuidType)) {
+ throw new SQLException("varchar = uuid is invalid");
+ }
+ lookup = statement;
+ events.add(dbType == DbType.POSTGRESQL ? "native-uuid-lookup" : "text-uuid-lookup");
+ return row("1");
+ }
+ return row(UUID_VALUE.toString());
+ });
+ return statement;
+ });
+ return connection;
+ }
+
+ ResultSet row(String value) throws SQLException {
+ ResultSet result = mock(ResultSet.class);
+ results.add(result);
+ when(result.next()).thenReturn(true, false);
+ when(result.getString(1)).thenReturn(value);
+ return result;
+ }
+
+ void verifyClosed() throws SQLException {
+ for (Connection connection : connections) verify(connection).close();
+ for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close();
+ for (ResultSet result : results) verify(result).close();
+ }
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaDuplicateColumnTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaDuplicateColumnTest.java
new file mode 100644
index 000000000..619a77456
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaDuplicateColumnTest.java
@@ -0,0 +1,31 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt;
+import com.bencodez.simpleapi.sql.DataType;
+
+class SqlUserSchemaDuplicateColumnTest {
+ @Test void builderRejectsCaseFoldedDuplicateNamesInsteadOfSilentlyOverwriting() {
+ var builder = SqlUserSchema.builder().column("Points", "INTEGER", DataType.INTEGER);
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> builder.column("points", "VARCHAR(30)", DataType.STRING));
+ assertTrue(failure.getMessage().contains("Points"));
+ }
+
+ @Test void fromKeysRejectsCaseFoldedDuplicates() {
+ assertThrows(IllegalArgumentException.class, () -> SqlUserSchema.fromKeys(List.of(
+ new UserDataKeyInt("Votes"), new UserDataKeyInt("votes"))));
+ }
+
+ @Test void uniqueColumnsKeepTheirOriginalSpelling() {
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INTEGER", DataType.INTEGER).build();
+ assertEquals("Points", schema.column("points").name());
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaReservedColumnTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaReservedColumnTest.java
new file mode 100644
index 000000000..36e5613da
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaReservedColumnTest.java
@@ -0,0 +1,21 @@
+package com.bencodez.advancedcore.core.user.storage.sql;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.simpleapi.sql.DataType;
+
+class SqlUserSchemaReservedColumnTest {
+ @Test
+ void canonicalUuidCannotBeOverriddenByAnyCaseAlias() {
+ for (String alias : new String[] { "uuid", "UUID", "Uuid", "uUiD" }) {
+ assertThrows(IllegalArgumentException.class,
+ () -> SqlUserSchema.builder().column(alias, "TEXT", DataType.STRING));
+ }
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INT DEFAULT '0'", DataType.INTEGER).build();
+ assertEquals("uuid", schema.columns().get(0).name());
+ assertEquals("VARCHAR(37)", schema.columns().get(0).sqlType());
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteRequiredColumnTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteRequiredColumnTest.java
new file mode 100644
index 000000000..5b685a2ef
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteRequiredColumnTest.java
@@ -0,0 +1,96 @@
+package com.bencodez.advancedcore.tests.storage;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess;
+import com.bencodez.advancedcore.core.user.storage.sql.*;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.*;
+
+/** Real SQLite required-column persistence and rollback regressions. */
+class SqliteRequiredColumnTest {
+ @TempDir Path directory;
+ private final SqlUserSchema schema = SqlUserSchema.builder()
+ .column("PlayerName", "VARCHAR(30) NOT NULL", DataType.STRING)
+ .column("Points", "INTEGER DEFAULT 7", DataType.INTEGER).build();
+
+ @Test void suppliedRequiredValueIsInsertedAndRetainsDefaultsAfterReopen() {
+ UUID uuid = UUID.randomUUID();
+ try (SqliteUserBackend backend = open()) {
+ backend.user(uuid).write(UserStorage.SQLITE, "PlayerName", new DataValueString("Ben"));
+ }
+ try (SqliteUserBackend backend = open()) {
+ var values = new SqlUserDataAccess(backend.user(uuid)).getValues(UserStorage.SQLITE);
+ assertEquals("Ben", values.get("PlayerName").getString());
+ assertEquals(7, values.get("Points").getInt());
+ assertEquals(List.of(uuid), backend.enumerateUsers());
+ }
+ }
+
+ @Test void existingRowCanBeUpdatedWithoutResupplyingRequiredColumns() {
+ UUID uuid = UUID.randomUUID();
+ try (SqliteUserBackend backend = open()) {
+ var user = backend.user(uuid);
+ user.write(UserStorage.SQLITE, "PlayerName", new DataValueString("Ben"));
+ user.write(UserStorage.SQLITE, "Points", new DataValueInt(12));
+ }
+ try (SqliteUserBackend backend = open()) {
+ var values = new SqlUserDataAccess(backend.user(uuid)).getValues(UserStorage.SQLITE);
+ assertEquals("Ben", values.get("PlayerName").getString());
+ assertEquals(12, values.get("Points").getInt());
+ }
+ }
+
+ @Test void missingRequiredValueCannotReportASuccessfulWriteToANonexistentRow() {
+ UUID uuid = UUID.randomUUID();
+ try (SqliteUserBackend backend = open()) {
+ assertThrows(IllegalStateException.class,
+ () -> backend.user(uuid).write(UserStorage.SQLITE, "Points", new DataValueInt(12)));
+ assertFalse(backend.user(uuid).contains(UserStorage.SQLITE));
+ }
+ try (SqliteUserBackend backend = open()) { assertTrue(backend.enumerateUsers().isEmpty()); }
+ }
+
+ @Test void ambiguousCaseAliasesAreRejectedBeforeWriting() {
+ UUID uuid = UUID.randomUUID();
+ HashMap values = new HashMap<>();
+ values.put("PlayerName", new DataValueString("Ben"));
+ values.put("playername", new DataValueString("Other"));
+ values.put("UUID", new DataValueString(UUID.randomUUID().toString()));
+ HashMap original = new HashMap<>(values);
+ try (SqliteUserBackend backend = open()) {
+ IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
+ () -> backend.user(uuid).writeValues(UserStorage.SQLITE, values));
+ assertTrue(failure.getMessage().contains("PlayerName"));
+ assertEquals(original, values);
+ assertFalse(backend.user(uuid).contains(UserStorage.SQLITE));
+ }
+ }
+
+ @Test void copiedUuidMetadataCannotChangeTheBoundIdentity() {
+ UUID uuid = UUID.randomUUID();
+ HashMap values = new HashMap<>();
+ values.put("PlayerName", new DataValueString("Ben"));
+ values.put("UUID", new DataValueString(UUID.randomUUID().toString()));
+ HashMap original = new HashMap<>(values);
+ try (SqliteUserBackend backend = open()) {
+ backend.user(uuid).writeValues(UserStorage.SQLITE, values);
+ assertEquals(original, values);
+ assertEquals(List.of(uuid), backend.enumerateUsers());
+ assertEquals("Ben", new SqlUserDataAccess(backend.user(uuid)).getString(UserStorage.SQLITE, "PlayerName"));
+ }
+ }
+
+ private SqliteUserBackend open() {
+ return new SqliteUserBackend(directory, "Users", "Users", schema, SqlBackendLogger.NO_OP);
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendAdmissionRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendAdmissionRaceTest.java
new file mode 100644
index 000000000..3e1457922
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendAdmissionRaceTest.java
@@ -0,0 +1,55 @@
+package com.bencodez.advancedcore.tests.storage;
+
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.nio.file.Path;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema;
+import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend;
+
+class SqliteUserBackendAdmissionRaceTest {
+ @TempDir Path directory;
+
+ @Test
+ void callerQueuedBeforeCloseCannotStartAfterCloseReturns() throws Exception {
+ SqliteUserBackend backend = new SqliteUserBackend(directory, "Users", "Users",
+ SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP);
+ SqlUserStorage retained = backend.user(UUID.randomUUID());
+
+ Field operationsField = SqliteUserBackend.class.getDeclaredField("operations");
+ operationsField.setAccessible(true);
+ ReentrantReadWriteLock operations = (ReentrantReadWriteLock) operationsField.get(backend);
+ AtomicReference outcome = new AtomicReference<>();
+
+ operations.writeLock().lock();
+ Thread caller = new Thread(() -> {
+ try { retained.contains(UserStorage.SQLITE); }
+ catch (Throwable failure) { outcome.set(failure); }
+ }, "sqlite-admission-race");
+ try {
+ caller.start();
+ for (int i = 0; i < 200 && !operations.hasQueuedThreads(); i++) Thread.sleep(5);
+ assertTrue(operations.hasQueuedThreads(), "storage caller did not reach the read-lock admission boundary");
+ // Reentrant for this test thread: close returns while our outer write hold
+ // keeps the delayed reader queued, exactly modeling the pre-lock race.
+ backend.close();
+ } finally {
+ operations.writeLock().unlock();
+ }
+
+ caller.join(5000);
+ assertTrue(!caller.isAlive(), "delayed storage caller did not finish");
+ assertInstanceOf(IllegalStateException.class, outcome.get());
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendBulkCopyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendBulkCopyTest.java
new file mode 100644
index 000000000..33685b0bc
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendBulkCopyTest.java
@@ -0,0 +1,119 @@
+package com.bencodez.advancedcore.tests.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema;
+import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueBoolean;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+import com.bencodez.simpleapi.sql.data.DataValueString;
+
+/** Real SQLite copies through the shared row access API, without Bukkit. */
+class SqliteUserBackendBulkCopyTest {
+ @TempDir
+ Path directory;
+
+ private static final SqlUserSchema SCHEMA = SqlUserSchema.builder()
+ .column("Points", "INTEGER", DataType.INTEGER)
+ .column("Enabled", "VARCHAR(5)", DataType.BOOLEAN)
+ .column("Custom-note", "TEXT", DataType.STRING).build();
+
+ @Test
+ void copiesReadRowValuesToBoundUserAndPersistsAcrossReopen() {
+ UUID sourceId = UUID.randomUUID();
+ UUID targetId = UUID.randomUUID();
+ try (SqliteUserBackend source = open("Source"); SqliteUserBackend target = open("Target")) {
+ SqlUserDataAccess sourceData = new SqlUserDataAccess(source.user(sourceId));
+ sourceData.setValues(UserStorage.SQLITE, sampleValues(42));
+ HashMap copied = sourceData.getValues(UserStorage.SQLITE);
+ assertEquals(sourceId.toString(), copied.get("uuid").getString());
+ HashMap before = new HashMap<>(copied);
+
+ new SqlUserDataAccess(target.user(targetId)).setValues(UserStorage.SQLITE, copied);
+ assertEquals(before, copied);
+ assertFalse(target.user(sourceId).contains(UserStorage.SQLITE));
+ }
+ try (SqliteUserBackend source = open("Source"); SqliteUserBackend target = open("Target")) {
+ HashMap row = new SqlUserDataAccess(target.user(targetId)).getValues(UserStorage.SQLITE);
+ assertEquals(targetId.toString(), row.get("uuid").getString());
+ assertEquals(42, row.get("Points").getInt());
+ assertTrue(row.get("Enabled").getBoolean());
+ assertEquals("RewardA;;RewardB", row.get("Custom-note").getString());
+ assertEquals(List.of(targetId), target.enumerateUsers());
+ assertEquals(List.of(sourceId), source.enumerateUsers());
+ assertEquals(42, new SqlUserDataAccess(source.user(sourceId)).getInt(UserStorage.SQLITE, "Points", 0));
+ }
+ }
+
+ @Test
+ void uuidOnlyMapDoesNotCreateARowAndMixedCaseMetadataIsIgnored() {
+ UUID targetId = UUID.randomUUID();
+ HashMap values = new HashMap<>();
+ values.put("uuid", new DataValueString(UUID.randomUUID().toString()));
+ values.put("UUID", null);
+ values.put("uUiD", new DataValueString("not a UUID"));
+ try (SqliteUserBackend target = open("Target")) {
+ target.user(targetId).writeValues(UserStorage.SQLITE, values);
+ assertFalse(target.user(targetId).contains(UserStorage.SQLITE));
+ assertTrue(target.enumerateUsers().isEmpty());
+ values.putAll(sampleValues(9));
+ HashMap before = new HashMap<>(values);
+ target.user(targetId).writeValues(UserStorage.SQLITE, values);
+ assertEquals(before, values);
+ }
+ try (SqliteUserBackend target = open("Target")) {
+ SqlUserDataAccess data = new SqlUserDataAccess(target.user(targetId));
+ assertEquals(targetId.toString(), data.getString(UserStorage.SQLITE, "uuid"));
+ assertEquals(9, data.getInt(UserStorage.SQLITE, "Points", 0));
+ assertEquals(List.of(targetId), target.enumerateUsers());
+ }
+ }
+
+ @Test
+ void ignoringCopiedUuidDoesNotHideInvalidColumnsOrPartialWrites() {
+ UUID targetId = UUID.randomUUID();
+ try (SqliteUserBackend target = open("Target")) {
+ SqlUserDataAccess data = new SqlUserDataAccess(target.user(targetId));
+ data.setValues(UserStorage.SQLITE, sampleValues(7));
+ HashMap invalid = new LinkedHashMap<>();
+ invalid.put("uuid", new DataValueString(UUID.randomUUID().toString()));
+ invalid.put("Points", new DataValueInt(99));
+ invalid.put("Unregistered", new DataValueString("reject"));
+ assertThrows(IllegalArgumentException.class, () -> data.setValues(UserStorage.SQLITE, invalid));
+ }
+ try (SqliteUserBackend target = open("Target")) {
+ SqlUserDataAccess data = new SqlUserDataAccess(target.user(targetId));
+ assertEquals(7, data.getInt(UserStorage.SQLITE, "Points", 0));
+ assertEquals(List.of(targetId), target.enumerateUsers());
+ }
+ }
+
+ private SqliteUserBackend open(String database) {
+ return new SqliteUserBackend(directory, database, "Users", SCHEMA, SqlBackendLogger.NO_OP);
+ }
+
+ private HashMap sampleValues(int points) {
+ HashMap values = new HashMap<>();
+ values.put("Points", new DataValueInt(points));
+ values.put("Enabled", new DataValueBoolean(true));
+ values.put("Custom-note", new DataValueString("RewardA;;RewardB"));
+ return values;
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java
new file mode 100644
index 000000000..0b8398c2a
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java
@@ -0,0 +1,241 @@
+package com.bencodez.advancedcore.tests.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyBoolean;
+import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyString;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema;
+import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend;
+import com.bencodez.simpleapi.sql.Column;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueBoolean;
+import com.bencodez.simpleapi.sql.data.DataValueString;
+
+/** Real Xerial SQLite regressions; no Bukkit plugin or mocked database. */
+class SqliteUserBackendCompatibilityTest {
+ @TempDir
+ Path directory;
+
+ @Test
+ void booleanKeysRetainTextEncodingAcrossUpdatesAndReopens() throws Exception {
+ SqlUserSchema schema = SqlUserSchema.fromKeys(List.of(
+ new UserDataKeyBoolean("Enabled"), new UserDataKeyBoolean("Disabled"),
+ new UserDataKeyBoolean("Unset")));
+ UUID uuid = UUID.randomUUID();
+ try (SqliteUserBackend backend = open("Users", schema)) {
+ HashMap values = new HashMap<>();
+ values.put("Enabled", new DataValueBoolean(true));
+ values.put("Disabled", new DataValueBoolean(false));
+ backend.user(uuid).writeValues(UserStorage.SQLITE, values);
+ assertTrue(value(backend.user(uuid).readRow(UserStorage.SQLITE), "Enabled").getBoolean());
+ }
+ try (Connection connection = connect();
+ PreparedStatement statement = connection.prepareStatement(
+ "SELECT Enabled, Disabled FROM Users WHERE uuid=?")) {
+ statement.setString(1, uuid.toString());
+ try (ResultSet result = statement.executeQuery()) {
+ assertTrue(result.next());
+ assertEquals("true", result.getString(1));
+ assertEquals("false", result.getString(2));
+ }
+ }
+ try (SqliteUserBackend backend = open("Users", schema)) {
+ SqlUserStorage user = backend.user(uuid);
+ List row = user.readRow(UserStorage.SQLITE);
+ assertTrue(value(row, "Enabled").getBoolean());
+ assertFalse(value(row, "Disabled").getBoolean());
+ assertFalse(value(row, "Unset").getBoolean());
+ user.write(UserStorage.SQLITE, "Enabled", new DataValueBoolean(false));
+ user.write(UserStorage.SQLITE, "Disabled", new DataValueBoolean(true));
+ }
+ try (SqliteUserBackend backend = open("Users", schema)) {
+ List row = backend.user(uuid).readRow(UserStorage.SQLITE);
+ assertFalse(value(row, "Enabled").getBoolean());
+ assertTrue(value(row, "Disabled").getBoolean());
+ }
+ }
+
+ @Test
+ void readsLegacyNumericTextAndNullBooleansWithoutMigration() throws Exception {
+ SqlUserSchema schema = SqlUserSchema.fromKeys(List.of(new UserDataKeyBoolean("Flag")));
+ List encoded = Arrays.asList("1", "0", "true", "false", "TRUE", null);
+ List expected = List.of(true, false, true, false, true, false);
+ UUID[] users = new UUID[encoded.size()];
+ try (SqliteUserBackend ignored = open("Users", schema);
+ Connection connection = connect();
+ PreparedStatement statement = connection.prepareStatement(
+ "INSERT INTO Users (uuid, Flag) VALUES (?, ?)")) {
+ for (int i = 0; i < users.length; i++) {
+ users[i] = UUID.randomUUID();
+ statement.setString(1, users[i].toString());
+ if (i < 2) {
+ // Reproduce the old binding, which Xerial encodes as 1/0.
+ statement.setBoolean(2, expected.get(i));
+ } else {
+ statement.setString(2, encoded.get(i));
+ }
+ statement.executeUpdate();
+ }
+ }
+ try (SqliteUserBackend backend = open("Users", schema)) {
+ for (int i = 0; i < users.length; i++) {
+ DataValue flag = value(backend.user(users[i]).readRow(UserStorage.SQLITE), "Flag");
+ assertTrue(flag.isBoolean());
+ assertEquals(expected.get(i).booleanValue(), flag.getBoolean(), "encoding " + encoded.get(i));
+ }
+ }
+ }
+
+ @Test
+ void rejectsPreexistingTableWithoutRequiredUuidColumn() throws Exception {
+ try (Connection connection = connect();
+ PreparedStatement statement = connection.prepareStatement("CREATE TABLE Users (Name TEXT)")) {
+ statement.executeUpdate();
+ }
+
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> open("Users", SqlUserSchema.fromKeys(List.of(new UserDataKeyString("Name")))));
+
+ assertTrue(failure.getCause().getMessage().contains("missing required UUID column"));
+ }
+
+ @Test
+ void rejectsPreexistingUuidColumnWithoutUniqueConstraint() throws Exception {
+ try (Connection connection = connect();
+ PreparedStatement statement = connection.prepareStatement("CREATE TABLE Users (uuid TEXT, Name TEXT)")) {
+ statement.executeUpdate();
+ }
+
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> open("Users", SqlUserSchema.fromKeys(List.of(new UserDataKeyString("Name")))));
+
+ assertTrue(failure.getCause().getMessage().contains("must be PRIMARY KEY or UNIQUE"));
+ }
+
+ @Test
+ void acceptsPreexistingUuidUniqueIndex() throws Exception {
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE Users (uuid TEXT, Name TEXT)");
+ statement.executeUpdate("CREATE UNIQUE INDEX users_uuid ON Users(uuid)");
+ }
+
+ try (SqliteUserBackend backend = open("Users", SqlUserSchema.fromKeys(List.of(new UserDataKeyString("Name"))))) {
+ UUID uuid = UUID.randomUUID();
+ backend.user(uuid).write(UserStorage.SQLITE, "Name", new DataValueString("valid"));
+ assertEquals(List.of(uuid), backend.enumerateUsers());
+ }
+ }
+
+ @Test
+ void acceptsAllTextAffinityUuidDeclarations() throws Exception {
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE Users (uuid NVARCHAR(37), Name TEXT)");
+ statement.executeUpdate("CREATE UNIQUE INDEX users_uuid ON Users(uuid)");
+ }
+ try (SqliteUserBackend backend = open("Users", SqlUserSchema.fromKeys(List.of(new UserDataKeyString("Name"))))) {
+ UUID uuid = UUID.randomUUID();
+ backend.user(uuid).write(UserStorage.SQLITE, "Name", new DataValueString("valid"));
+ assertEquals(List.of(uuid), backend.enumerateUsers());
+ }
+ }
+
+ @Test
+ void rejectsUuidDeclarationWithNonTextAffinity() throws Exception {
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE Users (uuid INTEGER, Name TEXT)");
+ statement.executeUpdate("CREATE UNIQUE INDEX users_uuid ON Users(uuid)");
+ }
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> open("Users", SqlUserSchema.fromKeys(List.of(new UserDataKeyString("Name")))));
+ assertTrue(failure.getCause().getMessage().contains("text-compatible type"));
+ }
+
+ @Test
+ void rejectsPartialUuidUniqueIndex() throws Exception {
+ try (Connection connection = connect(); Statement statement = connection.createStatement()) {
+ statement.executeUpdate("CREATE TABLE Users (uuid TEXT, Name TEXT)");
+ statement.executeUpdate("CREATE UNIQUE INDEX users_uuid_active ON Users(uuid) WHERE Name IS NOT NULL");
+ }
+
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> open("Users", SqlUserSchema.fromKeys(List.of(new UserDataKeyString("Name")))));
+
+ assertTrue(failure.getCause().getMessage().contains("must be PRIMARY KEY or UNIQUE"));
+ }
+
+ @Test
+ void quotedCustomNamesWorkForCreateAlterCrudAndRestart() {
+ String table = "Users - `archive` \"copy\"";
+ String originalKey = "Player-Name `label`";
+ List addedKeys = List.of("Vote Note", "quote\"field", "Total-votes", "累计票数",
+ "field`=?; DROP TABLE `Users`; --");
+ SqlUserSchema initial = SqlUserSchema.fromKeys(List.of(new UserDataKeyString(originalKey)));
+ UUID uuid = UUID.randomUUID();
+ String data = "Ben's value `quoted` \"text\"; not SQL";
+ try (SqliteUserBackend backend = open(table, initial)) {
+ SqlUserStorage user = backend.user(uuid);
+ assertFalse(user.contains(UserStorage.SQLITE));
+ user.write(UserStorage.SQLITE, originalKey, new DataValueString(data));
+ assertEquals(data, value(user.readRow(UserStorage.SQLITE), originalKey).getString());
+ }
+
+ var keys = new java.util.ArrayList();
+ keys.add(new UserDataKeyString(originalKey));
+ addedKeys.forEach(key -> keys.add(new UserDataKeyString(key)));
+ SqlUserSchema expanded = SqlUserSchema.fromKeys(keys);
+ try (SqliteUserBackend backend = open(table, expanded)) {
+ // Existing table forces ALTER TABLE and PRAGMA through the same quoting rules.
+ SqlUserStorage user = backend.user(uuid);
+ HashMap values = new HashMap<>();
+ addedKeys.forEach(key -> values.put(key, new DataValueString(data + key)));
+ user.writeValues(UserStorage.SQLITE, values);
+ assertTrue(user.contains(UserStorage.SQLITE));
+ assertEquals(List.of(uuid), backend.enumerateUsers());
+ assertThrows(IllegalArgumentException.class, () -> user.write(UserStorage.SQLITE,
+ "unregistered`=?; DELETE FROM Users; --", new DataValueString("no")));
+ }
+ try (SqliteUserBackend backend = open(table, expanded)) {
+ SqlUserStorage user = backend.user(uuid);
+ List row = user.readRow(UserStorage.SQLITE);
+ assertEquals(data, value(row, originalKey).getString());
+ for (String key : addedKeys) {
+ assertEquals(data + key, value(row, key).getString());
+ }
+ user.delete(UserStorage.SQLITE);
+ assertFalse(user.contains(UserStorage.SQLITE));
+ assertTrue(backend.enumerateUsers().isEmpty());
+ }
+ }
+
+ private SqliteUserBackend open(String table, SqlUserSchema schema) {
+ return new SqliteUserBackend(directory, "Users", table, schema, SqlBackendLogger.NO_OP);
+ }
+
+ private Connection connect() throws Exception {
+ return DriverManager.getConnection("jdbc:sqlite:" + directory.resolve("Users.db").toAbsolutePath());
+ }
+
+ private static DataValue value(List row, String key) {
+ return row.stream().filter(column -> column.getName().equals(key)).findFirst().orElseThrow().getValue();
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendLifecycleTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendLifecycleTest.java
new file mode 100644
index 000000000..c5af0e1a2
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendLifecycleTest.java
@@ -0,0 +1,198 @@
+package com.bencodez.advancedcore.tests.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema;
+import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueString;
+
+/** Real Xerial SQLite transactions, with latches at deterministic operation boundaries. */
+@Timeout(15)
+class SqliteUserBackendLifecycleTest {
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void concurrentCloseCallsDrainCommitAndRejectStaleUsers() throws Exception {
+ closeDrainsWrite(false);
+ }
+
+ @Test
+ void failedWriteRollsBackBeforeCloseReturns() throws Exception {
+ closeDrainsWrite(true);
+ }
+
+ private void closeDrainsWrite(boolean failWrite) throws Exception {
+ SqlUserSchema schema = SqlUserSchema.builder()
+ .column("PlayerName", "VARCHAR(30)", DataType.STRING).build();
+ SqliteUserBackend backend = new SqliteUserBackend(tempDir, "Users", "Users", schema, SqlBackendLogger.NO_OP);
+ UUID uuid = UUID.randomUUID();
+ SqlUserStorage user = backend.user(uuid);
+ user.write(UserStorage.SQLITE, "PlayerName", new DataValueString("before"));
+ CountDownLatch transactionOpen = new CountDownLatch(1);
+ CountDownLatch releaseWrite = new CountDownLatch(1);
+ IllegalStateException writeFailure = new IllegalStateException("injected value failure");
+ DataValue value = mock(DataValue.class);
+ when(value.isString()).thenReturn(true);
+ when(value.getString()).thenAnswer(ignored -> {
+ // JdbcSqlUserStorage has opened the connection, begun the transaction,
+ // and ensured the row before it asks for this value.
+ transactionOpen.countDown();
+ await(releaseWrite);
+ if (failWrite) throw writeFailure;
+ return "after";
+ });
+ HashMap values = new HashMap<>();
+ values.put("PlayerName", value);
+ ExecutorService workers = Executors.newFixedThreadPool(3);
+ try {
+ Future> write = workers.submit(() -> {
+ // Cover both public write entry points, not just the delegate.
+ if (failWrite) user.writeValues(UserStorage.SQLITE, values);
+ else user.write(UserStorage.SQLITE, "PlayerName", value);
+ });
+ await(transactionOpen);
+ CountDownLatch closersStarted = new CountDownLatch(2);
+ Future> close = workers.submit(() -> {
+ closersStarted.countDown();
+ backend.close();
+ });
+ Future interruptedClose = workers.submit(() -> {
+ Thread.currentThread().interrupt();
+ closersStarted.countDown();
+ backend.close();
+ return Thread.currentThread().isInterrupted();
+ });
+ await(closersStarted);
+ awaitStopping(backend);
+ assertThrows(TimeoutException.class, () -> close.get(100, TimeUnit.MILLISECONDS));
+ assertThrows(TimeoutException.class, () -> interruptedClose.get(100, TimeUnit.MILLISECONDS));
+
+ assertThrows(IllegalStateException.class, () -> backend.user(uuid));
+ assertThrows(IllegalStateException.class, backend::enumerateUsers);
+ assertThrows(IllegalStateException.class, () -> user.readRow(UserStorage.SQLITE));
+ assertThrows(IllegalStateException.class, () -> user.contains(UserStorage.SQLITE));
+ assertThrows(IllegalStateException.class, () -> user.delete(UserStorage.SQLITE));
+ assertThrows(IllegalStateException.class,
+ () -> user.write(UserStorage.SQLITE, "PlayerName", new DataValueString("stale")));
+ assertThrows(IllegalStateException.class, () -> user.writeValues(UserStorage.SQLITE, values));
+
+ releaseWrite.countDown();
+ if (failWrite) {
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> write.get(5, TimeUnit.SECONDS));
+ assertSame(writeFailure, failure.getCause());
+ } else {
+ write.get(5, TimeUnit.SECONDS);
+ }
+ close.get(5, TimeUnit.SECONDS);
+ assertTrue(interruptedClose.get(5, TimeUnit.SECONDS));
+ assertFalse(backend.isOpen());
+ backend.close();
+
+ try (SqliteUserBackend reopened = new SqliteUserBackend(tempDir, "Users", "Users", schema,
+ SqlBackendLogger.NO_OP)) {
+ SqlUserStorage replacement = reopened.user(uuid);
+ assertEquals(failWrite ? "before" : "after", playerName(replacement));
+ replacement.write(UserStorage.SQLITE, "PlayerName", new DataValueString("replacement"));
+ assertThrows(IllegalStateException.class,
+ () -> user.write(UserStorage.SQLITE, "PlayerName", new DataValueString("late")));
+ assertEquals("replacement", playerName(replacement));
+ assertEquals(List.of(uuid), reopened.enumerateUsers());
+ }
+ } finally {
+ releaseWrite.countDown();
+ workers.shutdownNow();
+ assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
+ backend.close();
+ }
+ }
+
+ @Test
+ void closeAlsoWaitsForActiveEnumeration() throws Exception {
+ CountDownLatch reading = new CountDownLatch(1);
+ CountDownLatch releaseRead = new CountDownLatch(1);
+ SqlBackendLogger logger = mock(SqlBackendLogger.class);
+ doAnswer(ignored -> {
+ // The invalid-row diagnostic runs while the result set/connection is open.
+ reading.countDown();
+ await(releaseRead);
+ return null;
+ }).when(logger).warn(anyString(), any(Throwable.class));
+ SqliteUserBackend backend = new SqliteUserBackend(tempDir, "Users", "Users",
+ SqlUserSchema.builder().build(), logger);
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + backend.databaseFile().toAbsolutePath());
+ PreparedStatement statement = connection.prepareStatement("INSERT INTO `Users` (`uuid`) VALUES (?)")) {
+ statement.setString(1, "not-a-uuid");
+ statement.executeUpdate();
+ }
+ ExecutorService workers = Executors.newFixedThreadPool(2);
+ try {
+ Future> enumeration = workers.submit(backend::enumerateUsers);
+ await(reading);
+ Future> close = workers.submit(backend::close);
+ awaitStopping(backend);
+ assertThrows(TimeoutException.class, () -> close.get(100, TimeUnit.MILLISECONDS));
+ assertThrows(IllegalStateException.class, backend::enumerateUsers);
+ releaseRead.countDown();
+ assertEquals(List.of(), enumeration.get(5, TimeUnit.SECONDS));
+ close.get(5, TimeUnit.SECONDS);
+ } finally {
+ releaseRead.countDown();
+ workers.shutdownNow();
+ assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
+ backend.close();
+ }
+ }
+
+ private static String playerName(SqlUserStorage user) {
+ return user.readRow(UserStorage.SQLITE).stream()
+ .filter(column -> "PlayerName".equals(column.getName()))
+ .findFirst().orElseThrow().getValue().getString();
+ }
+
+ private static void await(CountDownLatch latch) throws InterruptedException {
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "operation did not reach the expected boundary");
+ }
+
+ private static void awaitStopping(SqliteUserBackend backend) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (backend.isOpen() && System.nanoTime() < deadline) {
+ Thread.sleep(1);
+ }
+ assertFalse(backend.isOpen(), "close did not stop admission");
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendPersistenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendPersistenceTest.java
new file mode 100644
index 000000000..3fdf69e59
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendPersistenceTest.java
@@ -0,0 +1,92 @@
+package com.bencodez.advancedcore.tests.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.SqlUserStorage;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema;
+import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend;
+import com.bencodez.simpleapi.sql.Column;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+import com.bencodez.simpleapi.sql.data.DataValueString;
+
+class SqliteUserBackendPersistenceTest {
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void persistsAcrossCloseAndReopenWithoutBukkit() {
+ SqlUserSchema schema = SqlUserSchema.builder()
+ .column("PlayerName", "VARCHAR(30)", DataType.STRING)
+ .column("Points", "INTEGER", DataType.INTEGER)
+ .column("OfflineRewards", "MEDIUMTEXT", DataType.STRING)
+ .build();
+ UUID uuid = UUID.randomUUID();
+
+ try (SqliteUserBackend backend = new SqliteUserBackend(tempDir, "Users", "Users", schema,
+ SqlBackendLogger.NO_OP)) {
+ SqlUserStorage user = backend.user(uuid);
+ HashMap values = new HashMap<>();
+ values.put("PlayerName", new DataValueString("Ben"));
+ values.put("Points", new DataValueInt(42));
+ values.put("OfflineRewards", new DataValueString("RewardA;;RewardB"));
+ user.writeValues(UserStorage.SQLITE, values);
+
+ assertTrue(user.contains(UserStorage.SQLITE));
+ assertEquals(List.of(uuid), backend.enumerateUsers());
+ assertTrue(backend.databaseFile().toFile().isFile());
+ }
+
+ try (SqliteUserBackend reopened = new SqliteUserBackend(tempDir, "Users", "Users", schema,
+ SqlBackendLogger.NO_OP)) {
+ SqlUserStorage user = reopened.user(uuid);
+ List row = user.readRow(UserStorage.SQLITE);
+ assertEquals("Ben", value(row, "PlayerName").getString());
+ assertEquals(42, value(row, "Points").getInt());
+ assertEquals("RewardA;;RewardB", value(row, "OfflineRewards").getString());
+
+ user.delete(UserStorage.SQLITE);
+ assertFalse(user.contains(UserStorage.SQLITE));
+ }
+ }
+
+ @Test
+ void failedInitializationDoesNotRemainOpen() {
+ Path regularFile = tempDir.resolve("not-a-directory");
+ try {
+ java.nio.file.Files.writeString(regularFile, "x");
+ } catch (java.io.IOException e) {
+ throw new AssertionError(e);
+ }
+
+ boolean failed = false;
+ try {
+ new SqliteUserBackend(regularFile, "Users", "Users",
+ SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP);
+ } catch (IllegalStateException expected) {
+ failed = true;
+ }
+ assertTrue(failed);
+ }
+
+ private static DataValue value(List row, String name) {
+ return row.stream()
+ .filter(column -> name.equalsIgnoreCase(column.getName()))
+ .findFirst()
+ .orElseThrow()
+ .getValue();
+ }
+}
diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java
new file mode 100644
index 000000000..42b2c620a
--- /dev/null
+++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java
@@ -0,0 +1,116 @@
+package com.bencodez.advancedcore.tests.storage;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger;
+import com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema;
+import com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+
+class SqliteUserEnumerationCallbackTest {
+ @TempDir Path directory;
+
+ @Test
+ void callbacksCanWriteThroughTheSameBackendWithoutHoldingTheEnumerationReadTransaction() {
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INT DEFAULT '0'", DataType.INTEGER).build();
+ try (SqliteUserBackend backend = new SqliteUserBackend(directory, "Users", "Users", schema, SqlBackendLogger.NO_OP)) {
+ List users = List.of(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID());
+ for (UUID uuid : users) {
+ HashMap values = new HashMap<>();
+ values.put("Points", new DataValueInt(1));
+ backend.user(uuid).writeValues(UserStorage.SQLITE, values);
+ }
+ backend.forEachUser(uuid -> backend.user(uuid).write(UserStorage.SQLITE, "Points", new DataValueInt(2)));
+ for (UUID uuid : users) {
+ assertEquals(2, backend.user(uuid).readRow(UserStorage.SQLITE).stream()
+ .filter(column -> "Points".equals(column.getName())).findFirst().orElseThrow().getValue().getInt());
+ }
+ }
+ }
+
+ @Test
+ void nullUuidRowsDoNotShortenTheFirstPaginationPage() throws Exception {
+ SqlUserSchema schema = SqlUserSchema.builder().column("Points", "INT DEFAULT '0'", DataType.INTEGER).build();
+ try (SqliteUserBackend backend = new SqliteUserBackend(directory, "PagedUsers", "Users", schema, SqlBackendLogger.NO_OP)) {
+ List expected = new ArrayList<>();
+ try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + backend.databaseFile().toAbsolutePath())) {
+ connection.setAutoCommit(false);
+ try (PreparedStatement statement = connection.prepareStatement("INSERT INTO `Users` (`uuid`, `Points`) VALUES (?, ?)")) {
+ statement.setObject(1, null);
+ statement.setInt(2, 0);
+ statement.addBatch();
+ for (int i = 0; i < 513; i++) {
+ UUID uuid = new UUID(0L, i + 1L);
+ expected.add(uuid);
+ statement.setString(1, uuid.toString());
+ statement.setInt(2, i);
+ statement.addBatch();
+ }
+ statement.executeBatch();
+ }
+ connection.commit();
+ }
+ List actual = new ArrayList<>();
+ backend.forEachUser(actual::add);
+ assertEquals(513, actual.size());
+ assertEquals(expected.stream().sorted().toList(), actual);
+ }
+ }
+
+ @Test
+ void malformedUuidWarningIsBoundedAndOmitsStoredValue() throws Exception {
+ String malformed = "private-value-".repeat(1_000);
+ List warnings = new ArrayList<>();
+ SqlBackendLogger logger = new SqlBackendLogger() {
+ @Override public void info(String message) {}
+ @Override public void warn(String message, Throwable error) {
+ assertEquals("Malformed SQLite UUID value", error.getMessage());
+ warnings.add(message);
+ }
+ };
+ try (SqliteUserBackend backend = new SqliteUserBackend(directory, "Users", "Users",
+ SqlUserSchema.builder().build(), logger);
+ Connection connection = DriverManager.getConnection("jdbc:sqlite:" + backend.databaseFile().toAbsolutePath());
+ PreparedStatement statement = connection.prepareStatement("INSERT INTO `Users` (`uuid`) VALUES (?)")) {
+ statement.setString(1, malformed);
+ statement.executeUpdate();
+ statement.setString(1, malformed + "another");
+ statement.executeUpdate();
+ statement.setString(1, "1-1-1-1-1");
+ statement.executeUpdate();
+
+ assertEquals(List.of(), backend.enumerateUsers());
+ }
+ assertEquals(List.of("Skipping malformed UUID entries while enumerating SQLite users; further diagnostics suppressed"), warnings);
+ }
+
+ @Test
+ void uppercaseUuidRowsAreNotExposedAsUnaddressableLowercaseUsers() throws Exception {
+ UUID uuid = UUID.fromString("abcdefab-cdef-abcd-efab-cdefabcdefab");
+ try (SqliteUserBackend backend = new SqliteUserBackend(directory, "UppercaseUsers", "Users",
+ SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP);
+ Connection connection = DriverManager.getConnection(
+ "jdbc:sqlite:" + backend.databaseFile().toAbsolutePath());
+ PreparedStatement statement = connection.prepareStatement(
+ "INSERT INTO `Users` (`uuid`) VALUES (?)")) {
+ statement.setString(1, uuid.toString().toUpperCase(java.util.Locale.ROOT));
+ statement.executeUpdate();
+
+ assertEquals(List.of(), backend.enumerateUsers());
+ }
+ }
+}
diff --git a/docs/shared-sql-user-access.md b/docs/shared-sql-user-access.md
index fe37dba9b..1d7bcaab7 100644
--- a/docs/shared-sql-user-access.md
+++ b/docs/shared-sql-user-access.md
@@ -11,6 +11,13 @@ constructs connections nor owns a cache, executor, identity resolver, schema, or
shutdown lifecycle. Shared access performs no game-thread dispatch. SQL calls
may block and must remain on the caller's appropriate storage execution context.
+The headless `SqliteUserBackend` deliberately does not shade another SQLite driver
+into AdvancedCore. Bukkit/Paper/Folia deployments use the server runtime's
+`org.sqlite.JDBC`; a future Fabric/Forge/NeoForge host must provide `sqlite-jdbc`
+as a loader/runtime dependency (or download it in that loader's dependency phase)
+before constructing the backend. The test-scoped Maven dependency exists only so
+headless persistence tests can run and must not be interpreted as runtime bundling.
+
The existing `UserData` facade delegates its SQL operations to this access layer.
Its temporary-cache field and accessors, six fetch modes, user-cache precedence,
cache updates, notifications, list encoding, and synchronous/asynchronous write