From 640b301091bbc006d39a02fcf9bb2d45aaa438af Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:33:31 -0600 Subject: [PATCH 01/48] Add shared SQL backend logger --- .../core/user/storage/sql/SqlBackendLogger.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlBackendLogger.java 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); +} From 0840e973c3534a599c9283148489384d63a1ae4b Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:33:40 -0600 Subject: [PATCH 02/48] Add shared SQL user schema --- .../core/user/storage/sql/SqlUserSchema.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchema.java 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..2c8918acd --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchema.java @@ -0,0 +1,90 @@ +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 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() { + column(UUID_COLUMN, "VARCHAR(37)", DataType.STRING); + } + + public Builder column(String name, String sqlType, DataType dataType) { + ColumnDefinition definition = new ColumnDefinition(name, sqlType, dataType); + columns.put(name.toLowerCase(Locale.ROOT), definition); + return this; + } + + public SqlUserSchema build() { + return new SqlUserSchema(columns); + } + } +} From af8c3bc81929306c85ad2d1837ddd0d3366efd09 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:33:44 -0600 Subject: [PATCH 03/48] Add shared SQL backend lifecycle --- .../core/user/storage/sql/SqlUserBackend.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java 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..ab9dbc9aa --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java @@ -0,0 +1,20 @@ +package com.bencodez.advancedcore.core.user.storage.sql; + +import java.util.List; +import java.util.UUID; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; + +public interface SqlUserBackend extends AutoCloseable { + UserStorage storageType(); + + SqlUserStorage user(UUID uuid); + + List enumerateUsers(); + + boolean isOpen(); + + @Override + void close(); +} From 1e18b18626cf60a42eb8084242216f1d9c654011 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:34:05 -0600 Subject: [PATCH 04/48] Implement shared JDBC user storage --- .../user/storage/sql/JdbcSqlUserStorage.java | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java 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..83365cc7c --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorage.java @@ -0,0 +1,226 @@ +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 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; + +final class JdbcSqlUserStorage implements SqlUserStorage { + enum Dialect { + SQLITE, + MYSQL + } + + @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)) { + statement.setString(1, uuid.toString()); + 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(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)) { + statement.setString(1, uuid.toString()); + 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)) { + statement.setString(1, uuid.toString()); + statement.executeUpdate(); + } catch (SQLException e) { + throw failure("delete user row", e); + } + } + + @Override + public void write(UserStorage requestedStorage, String key, DataValue value) { + 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"); + if (values.isEmpty()) { + return; + } + try (Connection connection = connections.open()) { + boolean autoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + ensureRow(connection); + for (Map.Entry entry : values.entrySet()) { + updateValue(connection, entry.getKey(), entry.getValue()); + } + connection.commit(); + } catch (SQLException | RuntimeException e) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + e.addSuppressed(rollbackFailure); + } + throw e; + } finally { + connection.setAutoCommit(autoCommit); + } + } catch (SQLException e) { + throw failure("write user values", e); + } + } + + private void ensureRow(Connection connection) throws SQLException { + String sql; + if (dialect == Dialect.SQLITE) { + sql = "INSERT OR IGNORE INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) + + ") VALUES (?)"; + } else { + sql = "INSERT IGNORE INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) + + ") VALUES (?)"; + } + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + statement.executeUpdate(); + } + } + + private void updateValue(Connection connection, String key, DataValue value) throws SQLException { + Objects.requireNonNull(key, "key"); + if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(key)) { + throw new IllegalArgumentException("uuid is immutable through SqlUserStorage"); + } + if (!schema.contains(key)) { + throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); + } + String sql = "UPDATE " + quote(tableName) + " SET " + quote(key) + "=? WHERE " + + quote(SqlUserSchema.UUID_COLUMN) + "=?"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, 1, value); + statement.setString(2, uuid.toString()); + 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) { + int value = result.getInt(index); + return new DataValueInt(result.wasNull() ? 0 : value); + } + if (type == DataType.BOOLEAN) { + return new DataValueBoolean(Boolean.valueOf(result.getString(index))); + } + return new DataValueString(result.getString(index)); + } + + private void bind(PreparedStatement statement, int index, DataValue value) throws SQLException { + if (value == null) { + statement.setObject(index, null); + } else if (value.isString()) { + statement.setString(index, value.getString()); + } else if (value.isInt()) { + statement.setInt(index, value.getInt()); + } else if (value.isBoolean()) { + statement.setBoolean(index, value.getBoolean()); + } else { + statement.setObject(index, value.toString()); + } + } + + private String quote(String identifier) { + if (identifier == null || identifier.isBlank()) { + throw new IllegalArgumentException("SQL identifier cannot be blank"); + } + if (!identifier.matches("[A-Za-z0-9_]+")) { + throw new IllegalArgumentException("Unsafe SQL identifier: " + identifier); + } + return "`" + identifier + "`"; + } + + private IllegalStateException failure(String operation, SQLException error) { + logger.warn("Failed to " + operation + " for " + uuid, error); + return new IllegalStateException("Failed to " + operation + " for " + uuid, error); + } +} From 6de19008339894154fb540d9d46c217475e2d50c Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:34:18 -0600 Subject: [PATCH 05/48] Add headless SQLite user backend --- .../user/storage/sql/SqliteUserBackend.java | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java 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..64c8f24ab --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java @@ -0,0 +1,169 @@ +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.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; + +public final class SqliteUserBackend implements SqlUserBackend { + private final Path databaseFile; + private final String tableName; + private final SqlUserSchema schema; + private final SqlBackendLogger logger; + private final AtomicBoolean open = new AtomicBoolean(); + + public SqliteUserBackend(Path dataDirectory, String databaseName, String tableName, + SqlUserSchema schema, SqlBackendLogger logger) { + Objects.requireNonNull(dataDirectory, "dataDirectory"); + Objects.requireNonNull(databaseName, "databaseName"); + this.tableName = requireIdentifier(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) { + requireOpen(); + return new JdbcSqlUserStorage(UserStorage.SQLITE, uuid, tableName, schema, this::openConnection, + JdbcSqlUserStorage.Dialect.SQLITE, logger); + } + + @Override + public List enumerateUsers() { + requireOpen(); + String sql = "SELECT `uuid` FROM `" + tableName + "`"; + ArrayList users = new ArrayList<>(); + try (Connection connection = openConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet result = statement.executeQuery()) { + while (result.next()) { + String value = result.getString(1); + if (value == null || value.isBlank()) { + continue; + } + try { + users.add(UUID.fromString(value)); + } catch (IllegalArgumentException invalid) { + logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); + } + } + return users; + } catch (SQLException e) { + throw new IllegalStateException("Failed to enumerate SQLite users", e); + } + } + + @Override + public boolean isOpen() { + return open.get(); + } + + @Override + public void close() { + open.set(false); + } + + 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 { + for (SqlUserSchema.ColumnDefinition column : schema.columns()) { + if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) { + continue; + } + if (!hasColumn(column.name())) { + String sql = "ALTER TABLE `" + tableName + "` ADD COLUMN `" + column.name() + "` " + column.sqlType(); + try (Connection connection = openConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + } + } + } + } + + private boolean hasColumn(String name) throws SQLException { + String sql = "PRAGMA table_info(`" + 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 String createTableSql() { + StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS `").append(tableName).append("` ("); + boolean first = true; + for (SqlUserSchema.ColumnDefinition column : schema.columns()) { + if (!first) { + sql.append(", "); + } + first = false; + sql.append('`').append(column.name()).append("` ").append(column.sqlType()); + } + sql.append(", PRIMARY KEY (`uuid`))"); + return sql.toString(); + } + + private Connection openConnection() throws SQLException { + requireOpen(); + return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath()); + } + + private void requireOpen() { + if (!open.get()) { + throw new IllegalStateException("SQLite user backend is closed"); + } + } + + private static String requireIdentifier(String value, String label) { + Objects.requireNonNull(value, label); + if (!value.matches("[A-Za-z0-9_]+")) { + throw new IllegalArgumentException(label + " is not a safe SQL identifier: " + value); + } + return value; + } +} From 52917ab2d3b017a04c3b056e73c71e17c987dd0c Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:34:30 -0600 Subject: [PATCH 06/48] Add headless MySQL user backend --- .../user/storage/sql/MysqlUserBackend.java | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java 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..e5df5e3e0 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java @@ -0,0 +1,154 @@ +package com.bencodez.advancedcore.core.user.storage.sql; + +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.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +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 final SqlUserSchema schema; + private final SqlBackendLogger logger; + private final HeadlessUserTable table; + private final AtomicBoolean open = new AtomicBoolean(true); + + 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); + ensureRegisteredColumns(); + } + + @Override + public UserStorage storageType() { + return UserStorage.MYSQL; + } + + @Override + public SqlUserStorage user(UUID uuid) { + requireOpen(); + return new JdbcSqlUserStorage(UserStorage.MYSQL, uuid, table.getTableName(), schema, + () -> table.getMysql().getConnectionManager().getConnection(), JdbcSqlUserStorage.Dialect.MYSQL, logger); + } + + @Override + public List enumerateUsers() { + requireOpen(); + String sql = "SELECT " + table.quote("uuid") + " FROM " + table.quote(table.getTableName()); + ArrayList users = new ArrayList<>(); + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet result = statement.executeQuery()) { + while (result.next()) { + String value = result.getString(1); + if (value == null || value.isBlank()) { + continue; + } + try { + users.add(UUID.fromString(value)); + } catch (IllegalArgumentException invalid) { + logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); + } + } + return users; + } catch (SQLException e) { + throw new IllegalStateException("Failed to enumerate MySQL users", e); + } + } + + @Override + public boolean isOpen() { + return open.get(); + } + + @Override + public void close() { + if (open.compareAndSet(true, false)) { + table.close(); + } + } + + private void ensureRegisteredColumns() { + for (SqlUserSchema.ColumnDefinition column : schema.columns()) { + if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) { + table.ensureColumn(column.name(), column.dataType()); + } + } + } + + private void requireOpen() { + 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; + init(); + } + + @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; + sql.append(quote(column.name())).append(' ').append(normaliseTypeForDb(column.sqlType())); + } + 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 ensureColumn(String name, com.bencodez.simpleapi.sql.DataType type) { + checkColumn(name, type); + } + + String quote(String identifier) { + return qi(identifier); + } + } +} From afdaba6190962564c384e4d0966e499bfebe1971 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:34:39 -0600 Subject: [PATCH 07/48] Test headless SQLite persistence lifecycle --- .../SqliteUserBackendPersistenceTest.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendPersistenceTest.java 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(); + } +} From fe71042d8c128b4b0231019a8a2ba065ea7e4f03 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:35:10 -0600 Subject: [PATCH 08/48] Add explicit SQL backend construction factory --- .../storage/sql/SqlUserBackendFactory.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackendFactory.java 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 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 keys, SqlBackendLogger logger) { + Objects.requireNonNull(keys, "keys"); + return new MysqlUserBackend(baseTableName, config, SqlUserSchema.fromKeys(keys), logger); + } +} From 01ecf072a54a2e3cacb5ca9f5e7437a9330f2a4a Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:39:34 -0600 Subject: [PATCH 09/48] Add SQLite JDBC for headless persistence tests --- AdvancedCore/pom.xml | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 7267c96b87f314743cff1131765b262e27f99b5f Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:20:21 -0600 Subject: [PATCH 10/48] Fix SQL dialect, boolean encoding, and quoted user column access --- .../user/storage/sql/JdbcSqlUserStorage.java | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) 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 index 83365cc7c..db2f2419f 100644 --- 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 @@ -20,11 +20,37 @@ 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 + 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) { + // Match the native UUID column used by the existing PostgreSQL provider. + statement.setObject(index, uuid); + } else { + statement.setString(index, uuid.toString()); + } + } } @FunctionalInterface @@ -57,7 +83,7 @@ public List readRow(UserStorage requestedStorage) { String sql = "SELECT * FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=?"; try (Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, uuid.toString()); + dialect.bindUuid(statement, 1, uuid); try (ResultSet result = statement.executeQuery()) { if (!result.next()) { return new ArrayList<>(); @@ -86,7 +112,7 @@ public boolean contains(UserStorage requestedStorage) { + "=? LIMIT 1"; try (Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, uuid.toString()); + dialect.bindUuid(statement, 1, uuid); try (ResultSet result = statement.executeQuery()) { return result.next(); } @@ -101,7 +127,7 @@ public void delete(UserStorage requestedStorage) { String sql = "DELETE FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=?"; try (Connection connection = connections.open(); PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, uuid.toString()); + dialect.bindUuid(statement, 1, uuid); statement.executeUpdate(); } catch (SQLException e) { throw failure("delete user row", e); @@ -151,12 +177,15 @@ private void ensureRow(Connection connection) throws SQLException { if (dialect == Dialect.SQLITE) { sql = "INSERT OR IGNORE INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) + ") VALUES (?)"; + } else if (dialect == Dialect.POSTGRESQL) { + sql = "INSERT INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) + + ") VALUES (?) ON CONFLICT (" + quote(SqlUserSchema.UUID_COLUMN) + ") DO NOTHING"; } else { sql = "INSERT IGNORE INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) + ") VALUES (?)"; } try (PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, uuid.toString()); + dialect.bindUuid(statement, 1, uuid); statement.executeUpdate(); } } @@ -169,11 +198,11 @@ private void updateValue(Connection connection, String key, DataValue value) thr if (!schema.contains(key)) { throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); } - String sql = "UPDATE " + quote(tableName) + " SET " + quote(key) + "=? WHERE " + String sql = "UPDATE " + quote(tableName) + " SET " + quote(schema.column(key).name()) + "=? WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=?"; try (PreparedStatement statement = connection.prepareStatement(sql)) { bind(statement, 1, value); - statement.setString(2, uuid.toString()); + dialect.bindUuid(statement, 2, uuid); statement.executeUpdate(); } } @@ -190,7 +219,9 @@ private DataValue readValue(ResultSet result, int index, DataType type) throws S return new DataValueInt(result.wasNull() ? 0 : value); } if (type == DataType.BOOLEAN) { - return new DataValueBoolean(Boolean.valueOf(result.getString(index))); + String value = result.getString(index); + // Also read numeric values written by the old SQLite setBoolean path. + return new DataValueBoolean("1".equals(value) || Boolean.parseBoolean(value)); } return new DataValueString(result.getString(index)); } @@ -203,20 +234,15 @@ private void bind(PreparedStatement statement, int index, DataValue value) throw } else if (value.isInt()) { statement.setInt(index, value.getInt()); } else if (value.isBoolean()) { - statement.setBoolean(index, value.getBoolean()); + // UserDataKeyBoolean uses VARCHAR(5), not a native SQL boolean column. + statement.setString(index, Boolean.toString(value.getBoolean())); } else { statement.setObject(index, value.toString()); } } private String quote(String identifier) { - if (identifier == null || identifier.isBlank()) { - throw new IllegalArgumentException("SQL identifier cannot be blank"); - } - if (!identifier.matches("[A-Za-z0-9_]+")) { - throw new IllegalArgumentException("Unsafe SQL identifier: " + identifier); - } - return "`" + identifier + "`"; + return dialect.quote(identifier); } private IllegalStateException failure(String operation, SQLException error) { From ff16ad3df6a8294bb4db6b291c8cb4484e4f6223 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:20:40 -0600 Subject: [PATCH 11/48] Derive user SQL dialect from the provider and retain PostgreSQL UUID schema --- .../core/user/storage/sql/MysqlUserBackend.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index e5df5e3e0..ac627c0cd 100644 --- 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 @@ -37,8 +37,10 @@ public UserStorage storageType() { @Override public SqlUserStorage user(UUID uuid) { requireOpen(); + JdbcSqlUserStorage.Dialect dialect = JdbcSqlUserStorage.Dialect.fromDbType( + table.getMysql().getConnectionManager().getDbType()); return new JdbcSqlUserStorage(UserStorage.MYSQL, uuid, table.getTableName(), schema, - () -> table.getMysql().getConnectionManager().getConnection(), JdbcSqlUserStorage.Dialect.MYSQL, logger); + () -> table.getMysql().getConnectionManager().getConnection(), dialect, logger); } @Override @@ -117,7 +119,9 @@ public String buildCreateTableSql(DbType dbType) { sql.append(", "); } first = false; - sql.append(quote(column.name())).append(' ').append(normaliseTypeForDb(column.sqlType())); + 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(); From 71d6880c62cee3aedb980cc45436cdbd6607ed7a Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:21:02 -0600 Subject: [PATCH 12/48] Escape SQLite identifiers consistently during schema setup and enumeration --- .../user/storage/sql/SqliteUserBackend.java | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) 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 index 64c8f24ab..ef1d42e15 100644 --- 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 @@ -28,7 +28,8 @@ public SqliteUserBackend(Path dataDirectory, String databaseName, String tableNa SqlUserSchema schema, SqlBackendLogger logger) { Objects.requireNonNull(dataDirectory, "dataDirectory"); Objects.requireNonNull(databaseName, "databaseName"); - this.tableName = requireIdentifier(tableName, "tableName"); + quote(tableName); // Validate before opening the database; retain the original name. + this.tableName = tableName; this.schema = Objects.requireNonNull(schema, "schema"); this.logger = logger == null ? SqlBackendLogger.NO_OP : logger; if (databaseName.isBlank() || databaseName.contains("/") || databaseName.contains("\\")) { @@ -57,7 +58,7 @@ public SqlUserStorage user(UUID uuid) { @Override public List enumerateUsers() { requireOpen(); - String sql = "SELECT `uuid` FROM `" + tableName + "`"; + String sql = "SELECT " + quote(SqlUserSchema.UUID_COLUMN) + " FROM " + quote(tableName); ArrayList users = new ArrayList<>(); try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); @@ -111,7 +112,8 @@ private void ensureRegisteredColumns() throws SQLException { continue; } if (!hasColumn(column.name())) { - String sql = "ALTER TABLE `" + tableName + "` ADD COLUMN `" + column.name() + "` " + column.sqlType(); + String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + + " " + column.sqlType(); try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); @@ -121,7 +123,7 @@ private void ensureRegisteredColumns() throws SQLException { } private boolean hasColumn(String name) throws SQLException { - String sql = "PRAGMA table_info(`" + tableName + "`)"; + String sql = "PRAGMA table_info(" + quote(tableName) + ")"; try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery()) { @@ -135,16 +137,16 @@ private boolean hasColumn(String name) throws SQLException { } private String createTableSql() { - StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS `").append(tableName).append("` ("); + 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('`').append(column.name()).append("` ").append(column.sqlType()); + sql.append(quote(column.name())).append(' ').append(column.sqlType()); } - sql.append(", PRIMARY KEY (`uuid`))"); + sql.append(", PRIMARY KEY (").append(quote(SqlUserSchema.UUID_COLUMN)).append("))"); return sql.toString(); } @@ -159,11 +161,7 @@ private void requireOpen() { } } - private static String requireIdentifier(String value, String label) { - Objects.requireNonNull(value, label); - if (!value.matches("[A-Za-z0-9_]+")) { - throw new IllegalArgumentException(label + " is not a safe SQL identifier: " + value); - } - return value; + private static String quote(String identifier) { + return JdbcSqlUserStorage.Dialect.SQLITE.quote(identifier); } } From 4fccbe0244f77ba1719b91346fabc6c5e761e1a3 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:21:35 -0600 Subject: [PATCH 13/48] Test real SQLite boolean compatibility and quoted schema CRUD across restarts --- .../SqliteUserBackendCompatibilityTest.java | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java 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..7f0f8f630 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java @@ -0,0 +1,163 @@ +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.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 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(); + } +} From 3de792647319f07df4d05d827ea98409724f858f Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:22:02 -0600 Subject: [PATCH 14/48] Cover PostgreSQL CRUD and provider dialect selection without a live database --- .../sql/JdbcSqlUserStorageDialectTest.java | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java 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..353f5d132 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java @@ -0,0 +1,148 @@ +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.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.PreparedStatement; +import java.sql.ResultSet; +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; + +/** JDBC contract tests, not a live PostgreSQL/MySQL integration database. */ +class JdbcSqlUserStorageDialectTest { + private static final UUID UUID_VALUE = UUID.fromString("542b75a0-5333-4f44-828c-94676443cf5d"); + + @Test + void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() 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\"=?", + "INSERT INTO \"User \"\"Data\"\"\" (\"uuid\") VALUES (?) ON CONFLICT (\"uuid\") DO NOTHING", + "UPDATE \"User \"\"Data\"\"\" SET \"Vote \"\"Flag\"\"\"=? WHERE \"uuid\"=?"), jdbc.sql); + for (int i = 0; i < 4; 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(1, "true"); + verify(jdbc.statements.get(4)).setObject(2, UUID_VALUE); + verify(jdbc.connection).commit(); + verify(jdbc.connection, times(4)).close(); + for (PreparedStatement statement : jdbc.statements) verify(statement).close(); + } + + @Test + void mysqlAndMariaDbRetainInsertIgnoreAndTextUuidBindings() 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( + "INSERT IGNORE INTO `User ``Data``` (`uuid`) VALUES (?)", + "UPDATE `User ``Data``` SET `Vote ``Flag```=? WHERE `uuid`=?"), jdbc.sql); + verify(jdbc.statements.get(0)).setString(1, UUID_VALUE.toString()); + verify(jdbc.statements.get(1)).setString(1, "false"); + verify(jdbc.statements.get(1)).setString(2, UUID_VALUE.toString()); + verify(jdbc.connection).commit(); + verify(jdbc.connection).close(); + } + } + + @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)); + return statement; + }); + } + } +} From 4d619a7334fad6802436ee21d63f161df5c6ecef Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:39:30 -0600 Subject: [PATCH 15/48] Fix SQL backend UUID migration and SQLite shutdown lifecycle Preserve the established PostgreSQL VARCHAR-to-UUID conversion and await its completion before exposing user operations. Reuse SimpleAPI's schema inspection, quoting, and connection manager; close the owned pool when inspection or conversion fails. Guard SQLite user operations and enumeration through JDBC cleanup so all close callers drain active commits/rollbacks before returning, reject new work, and preserve interruption without permitting stale writes. Add four mocked PostgreSQL migration contract regressions and three real SQLite lifecycle regressions. Address Codex comments 3998391833 and 3998391834 together in this single follow-up commit. Local validation: Java 21 syntax parsing and incremental diff whitespace checks passed. Full local Maven builds are blocked by absent Maven and unavailable GitHub/Maven DNS; repository tests require GitHub Actions. No live PostgreSQL/MySQL or game-server validation is claimed. --- .../user/storage/sql/MysqlUserBackend.java | 39 +++- .../user/storage/sql/SqliteUserBackend.java | 81 ++++++- .../MysqlUserBackendUuidMigrationTest.java | 192 +++++++++++++++++ .../SqliteUserBackendLifecycleTest.java | 198 ++++++++++++++++++ 4 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendUuidMigrationTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendLifecycleTest.java 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 index ac627c0cd..2b5f9838f 100644 --- 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 @@ -26,7 +26,19 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema 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); - ensureRegisteredColumns(); + try { + ensureRegisteredColumns(); + } catch (RuntimeException | Error failure) { + // A failed UUID conversion must neither expose an incompatible backend + // nor leave the newly owned connection manager running. + open.set(false); + try { + table.close(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } } @Override @@ -81,6 +93,7 @@ public void close() { } private void ensureRegisteredColumns() { + table.ensureUuidType(); for (SqlUserSchema.ColumnDefinition column : schema.columns()) { if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) { table.ensureColumn(column.name(), column.dataType()); @@ -147,6 +160,30 @@ public void debug(String message) { logger.info(message); } + void ensureUuidType() { + if (getDbType() != DbType.POSTGRESQL) { + return; + } + try { + String uuidType = bestUuidType(); + if (!columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) { + return; + } + // Match AbstractSqlTable's established VARCHAR -> UUID conversion, + // but await it here: alterColumnType() only submits background DDL. + // Use the same connection manager; do not create another JDBC pool. + String uuidColumn = quote(SqlUserSchema.UUID_COLUMN); + String sql = "ALTER TABLE " + quote(tableName) + " ALTER COLUMN " + uuidColumn + + " TYPE " + uuidType + " USING NULLIF(" + uuidColumn + ", '')::uuid;"; + try (Connection connection = getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + } + } catch (SQLException failure) { + throw new IllegalStateException("Failed to initialize PostgreSQL UUID column", failure); + } + } + void ensureColumn(String name, com.bencodez.simpleapi.sql.DataType type) { checkColumn(name, type); } 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 index ef1d42e15..2e9dc810e 100644 --- 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 @@ -9,13 +9,18 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantReadWriteLock; +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 final Path databaseFile; @@ -23,6 +28,7 @@ public final class SqliteUserBackend implements SqlUserBackend { private final SqlUserSchema schema; private final SqlBackendLogger logger; private final AtomicBoolean open = new AtomicBoolean(); + private final ReentrantReadWriteLock operations = new ReentrantReadWriteLock(true); public SqliteUserBackend(Path dataDirectory, String databaseName, String tableName, SqlUserSchema schema, SqlBackendLogger logger) { @@ -51,13 +57,51 @@ public UserStorage storageType() { @Override public SqlUserStorage user(UUID uuid) { requireOpen(); - return new JdbcSqlUserStorage(UserStorage.SQLITE, uuid, tableName, schema, this::openConnection, - JdbcSqlUserStorage.Dialect.SQLITE, logger); + 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() { - requireOpen(); + return withOperation(this::readUserIds); + } + + private List readUserIds() { String sql = "SELECT " + quote(SqlUserSchema.UUID_COLUMN) + " FROM " + quote(tableName); ArrayList users = new ArrayList<>(); try (Connection connection = openConnection(); @@ -85,9 +129,40 @@ public boolean isOpen() { return open.get(); } + /** + * Stops accepting operations, then waits for every active operation (including + * commit/rollback and JDBC resource cleanup). Call from the storage lifecycle, + * not a game/region thread. Concurrent close calls all wait for the same drain. + * + * @throws IllegalStateException if called from inside an active operation + */ @Override public void close() { + if (operations.getReadHoldCount() != 0) { + throw new IllegalStateException("Cannot close SQLite from inside an active storage operation"); + } open.set(false); + // Do not return early when another closer has already set open=false. + // Uninterruptible acquisition preserves the caller's interrupt status + // without allowing it to start a replacement while a transaction is alive. + operations.writeLock().lock(); + try { + // Taking the exclusive lock is the drain barrier. Connections are + // operation-owned and have been closed before their read lock releases. + } finally { + operations.writeLock().unlock(); + } + } + + private T withOperation(Supplier operation) { + requireOpen(); + operations.readLock().lock(); + try { + requireOpen(); + return operation.get(); + } finally { + operations.readLock().unlock(); + } } private void initialize() { 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..a5c594ec8 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendUuidMigrationTest.java @@ -0,0 +1,192 @@ +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;"; + + @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() { + MysqlConfig config = new MysqlConfig(); + config.setDbType(DbType.POSTGRESQL); + 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; + Thread migrationThread; + SQLException migrationFailure; + SQLException inspectionFailure; + PreparedStatement lookup; + + JdbcFixture(String uuidType) { + this.uuidType = uuidType; + } + + MockedConstruction managers() { + return mockConstruction(ConnectionManager.class, (manager, context) -> { + when(manager.open()).thenReturn(true); + when(manager.getDbType()).thenReturn(DbType.POSTGRESQL); + 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)) { + migrationThread = Thread.currentThread(); + if (migrationFailure != null) throw migrationFailure; + uuidType = "uuid"; + 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(37L); + return result; + } + if (query.startsWith("SELECT column_name FROM information_schema.columns")) { + return row("uuid"); + } + if (query.startsWith("SELECT 1 FROM")) { + if (!"uuid".equals(uuidType)) throw new SQLException("varchar = uuid is invalid"); + lookup = statement; + events.add("native-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/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"); + } +} From cc6e47caf4a8724cc97b965bc380e606bd207771 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:54:10 -0600 Subject: [PATCH 16/48] Preserve bulk-copy identity and committed SQL write outcomes Ignore UUID metadata in copied row maps without changing the bound user identity or mutating caller input. UUID-only bulk writes are no-ops and explicit single-field identity changes remain rejected. Keep post-commit auto-commit restoration and connection-close failures from making persisted batches retryable. Preserve primary write failures with cleanup errors suppressed, and do not enable auto-commit after a failed rollback. Add 11 mocked JDBC outcome/dialect regressions and 3 real SQLite bulk copy, reopen, and rollback regressions. Address Codex comments 3998443943 and 3998443945 together in one follow-up commit. Local Java 21 syntax and incremental whitespace checks passed. Full local Maven tests/build are blocked by missing Maven and unavailable GitHub/ Maven DNS; GitHub Actions must execute the repository test/package gate. No live MySQL/PostgreSQL or game-server validation is claimed. --- .../user/storage/sql/JdbcSqlUserStorage.java | 66 ++++- .../JdbcSqlUserStorageWriteOutcomeTest.java | 244 ++++++++++++++++++ .../SqliteUserBackendBulkCopyTest.java | 119 +++++++++ 3 files changed, 421 insertions(+), 8 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageWriteOutcomeTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendBulkCopyTest.java 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 index db2f2419f..6099db1a2 100644 --- 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 @@ -7,6 +7,7 @@ import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -136,6 +137,10 @@ public void delete(UserStorage requestedStorage) { @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); @@ -145,30 +150,75 @@ public void write(UserStorage requestedStorage, String key, DataValue value) { public void writeValues(UserStorage requestedStorage, HashMap values) { requireStorage(requestedStorage); Objects.requireNonNull(values, "values"); - if (values.isEmpty()) { + // SELECT * copies include identity metadata. Do not mutate the caller's map + // or let its UUID replace this storage object's bound identity. + Map updates = new LinkedHashMap<>(values); + updates.keySet().removeIf(SqlUserSchema.UUID_COLUMN::equalsIgnoreCase); + 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 { ensureRow(connection); - for (Map.Entry entry : values.entrySet()) { + for (Map.Entry entry : updates.entrySet()) { updateValue(connection, entry.getKey(), entry.getValue()); } connection.commit(); - } catch (SQLException | RuntimeException e) { + committed = true; + transactionEnded = true; + } catch (SQLException | RuntimeException | Error e) { + transactionFailure = e; try { connection.rollback(); - } catch (SQLException rollbackFailure) { - e.addSuppressed(rollbackFailure); + transactionEnded = true; + } catch (SQLException | RuntimeException rollbackFailure) { + suppress(e, rollbackFailure); } throw e; } finally { - connection.setAutoCommit(autoCommit); + // Switching to auto-commit after a failed rollback can commit a + // partial batch. Close that connection without restoring its mode. + 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) { - throw failure("write user values", e); + } catch (SQLException | RuntimeException e) { + if (committed) { + // The resource-close path must not make a durable batch retryable. + committedCleanupFailure("close SQL connection", e); + } else if (e instanceof SQLException sqlFailure) { + throw failure("write user values", sqlFailure); + } else { + throw e; + } + } + } + + private void committedCleanupFailure(String operation, Exception error) { + try { + logger.warn("User values committed, but failed to " + operation + " for " + uuid, error); + } catch (RuntimeException loggingFailure) { + // An injected logger must not turn post-commit cleanup into a retry. + suppress(error, loggingFailure); + } + } + + private static void suppress(Throwable primary, Throwable secondary) { + if (primary != secondary) { + primary.addSuppressed(secondary); } } 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..d415132c6 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageWriteOutcomeTest.java @@ -0,0 +1,244 @@ +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.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.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.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +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; + +/** Failure-injection JDBC contracts; these mocks are not live database tests. */ +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(); + verify(jdbc.connection).close(); + } + + @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(2, jdbc.sql.size()); + assertFalse(jdbc.sql.get(1).contains("SET " + dialect.quote("uuid"))); + if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) { + verify(jdbc.insert).setObject(1, USER); + verify(jdbc.update).setObject(2, USER); + } else { + verify(jdbc.insert).setString(1, USER.toString()); + verify(jdbc.update).setString(2, USER.toString()); + } + verify(jdbc.update).setInt(1, 17); + verify(jdbc.connection).commit(); + } + } + + @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(); + } + + 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(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() { + user(UserStorage.SQLITE, JdbcSqlUserStorage.Dialect.SQLITE) + .write(UserStorage.SQLITE, "Points", new DataValueInt(17)); + } + } +} 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; + } +} From ef89541c6fe1ee97dca8860c668ebc2e094a93cf Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 20:59:03 -0600 Subject: [PATCH 17/48] Fix checked-exception rethrow in SQL write cleanup Split SQL and runtime catches so the runtime rethrow retains its unchecked type. This corrects the compilation error found by Actions in cc6e47c; transaction outcomes and the existing regression tests are unchanged. An extracted JDK 21 control-flow compilation reproduces the prior error and passes with this correction. Full repository Maven validation remains unavailable locally and must run through Actions. Published history is preserved rather than force-pushed. --- .../core/user/storage/sql/JdbcSqlUserStorage.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 index 6099db1a2..b2cbba5dd 100644 --- 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 @@ -195,12 +195,16 @@ public void writeValues(UserStorage requestedStorage, HashMap } } } - } catch (SQLException | RuntimeException e) { + } catch (SQLException e) { if (committed) { // The resource-close path must not make a durable batch retryable. committedCleanupFailure("close SQL connection", e); - } else if (e instanceof SQLException sqlFailure) { - throw failure("write user values", sqlFailure); + } else { + throw failure("write user values", e); + } + } catch (RuntimeException e) { + if (committed) { + committedCleanupFailure("close SQL connection", e); } else { throw e; } From 801fb0a50524996ef7a1d060e0cdc9f33245c190 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 22:57:09 -0600 Subject: [PATCH 18/48] Preserve declared column definitions during SQL schema expansion Address Codex 3998505294: add missing columns synchronously using the same normalized SQL definitions and quoting as fresh table creation. Treat metadata and DDL failures as initialization failures and close the owned pool. Add six mocked JDBC/backend construction regressions covering MySQL, MariaDB, PostgreSQL, existing columns, and failure cleanup. Local JDK 21 isolated control-flow compilation passed; full local Maven and live MySQL/PostgreSQL are unavailable (Maven absent, network DNS unavailable). Repository tests require GitHub Actions. No dependency, packaging, loader, or Bukkit-startup changes. --- .../user/storage/sql/MysqlUserBackend.java | 37 ++++- .../MysqlUserBackendSchemaExpansionTest.java | 148 ++++++++++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.java 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 index 2b5f9838f..a3b5855fc 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -12,6 +13,7 @@ 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.mysql.AbstractSqlTable; import com.bencodez.simpleapi.sql.mysql.DbType; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; @@ -96,7 +98,7 @@ private void ensureRegisteredColumns() { table.ensureUuidType(); for (SqlUserSchema.ColumnDefinition column : schema.columns()) { if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) { - table.ensureColumn(column.name(), column.dataType()); + table.ensureColumn(column); } } } @@ -184,8 +186,37 @@ void ensureUuidType() { } } - void ensureColumn(String name, com.bencodez.simpleapi.sql.DataType type) { - checkColumn(name, type); + void ensureColumn(SqlUserSchema.ColumnDefinition column) { + synchronized (checkColumnLock) { + // Inspect the actual table without reading user data. Unlike the legacy + // cache helper, inspection errors must abort initialization, not look + // like missing columns. Keep the same connection manager and quoting. + try (Connection connection = getMysql().getConnectionManager().getConnection()) { + try (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 (column.name().equalsIgnoreCase(metadata.getColumnName(i))) { + return; + } + } + } + // Preserve length, nullability and defaults exactly as CREATE does. + // checkColumn(DataType) would replace these with TEXT or BIGINT. + String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + + " " + normaliseTypeForDb(column.sqlType()) + ";"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + } + columns.add(column.name()); + if (column.dataType() == DataType.INTEGER && !intColumns.contains(column.name())) { + intColumns.add(column.name()); + } + } catch (SQLException failure) { + throw new IllegalStateException("Failed to initialize registered SQL column: " + column.name(), failure); + } + } } String quote(String identifier) { 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..27e3e3301 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendSchemaExpansionTest.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.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 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 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() { + MysqlConfig config = new MysqlConfig(); + config.setDbType(type); + config.setDatabase("test_database"); + config.setMaxThreads(1); + SqlUserSchema schema = SqlUserSchema.builder() + .column("Player Name", "VARCHAR(30)", DataType.STRING) + .column("History", "MEDIUMTEXT", DataType.STRING) + .column("Votes", "INT DEFAULT '0'", DataType.INTEGER).build(); + 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); + 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("uuid"); + } 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)); + } + } + 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(); + } + } +} From 158c8a124cf391b0d4045ceb72a06e492440bbf7 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 23:50:35 -0600 Subject: [PATCH 19/48] Fix required-column user creation and concurrent SQL schema expansion Address Codex 3998791570 and 3998791572. Include supplied registered values in the initial row insert, collapse case aliases without changing UUID identity, and reject ignored inserts when no user row actually exists. Skip PostgreSQL insertion for existing rows so partial updates need not resupply required fields. Preserve dialect quoting, encodings, transaction outcome and cleanup behavior. After a narrowly identified duplicate-column DDL error, recheck once on a fresh borrowed connection. Accept only a column that now exists; retain other DDL and inspection failures and close the owned backend on failure. Add four real SQLite required-column regressions, six mocked concurrent schema regressions and a PostgreSQL partial-update contract test. Update existing mocked SQL expectations to verify full insert values and lookup. New row-initialization methods compile in an isolated local JDK21 harness; full Maven/JUnit/packaged validation requires GitHub Actions. Live MySQL, MariaDB, PostgreSQL and server integration remain unavailable. No dependency, packaging, native loader, or unrelated PR changes. --- .../user/storage/sql/JdbcSqlUserStorage.java | 57 +++++-- .../user/storage/sql/MysqlUserBackend.java | 50 ++++--- .../sql/JdbcSqlUserStorageDialectTest.java | 14 +- .../JdbcSqlUserStorageWriteOutcomeTest.java | 23 ++- .../sql/MysqlConcurrentSchemaTest.java | 139 ++++++++++++++++++ .../storage/SqliteRequiredColumnTest.java | 81 ++++++++++ 6 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlConcurrentSchemaTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteRequiredColumnTest.java 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 index b2cbba5dd..617b35724 100644 --- 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 @@ -164,7 +164,7 @@ public void writeValues(UserStorage requestedStorage, HashMap boolean transactionEnded = false; Throwable transactionFailure = null; try { - ensureRow(connection); + ensureRow(connection, updates); for (Map.Entry entry : updates.entrySet()) { updateValue(connection, entry.getKey(), entry.getValue()); } @@ -226,21 +226,52 @@ private static void suppress(Throwable primary, Throwable secondary) { } } - private void ensureRow(Connection connection) throws SQLException { - String sql; - if (dialect == Dialect.SQLITE) { - sql = "INSERT OR IGNORE INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) - + ") VALUES (?)"; - } else if (dialect == Dialect.POSTGRESQL) { - sql = "INSERT INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) - + ") VALUES (?) ON CONFLICT (" + quote(SqlUserSchema.UUID_COLUMN) + ") DO NOTHING"; - } else { - sql = "INSERT IGNORE INTO " + quote(tableName) + " (" + quote(SqlUserSchema.UUID_COLUMN) - + ") VALUES (?)"; + private void ensureRow(Connection connection, Map updates) throws SQLException { + Map initial = new LinkedHashMap<>(); + for (Map.Entry entry : updates.entrySet()) { + String key = Objects.requireNonNull(entry.getKey(), "key"); + SqlUserSchema.ColumnDefinition definition = schema.column(key); + if (definition == null) { + throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); + } + // Case aliases refer to one physical column, with the same last-wins + // order used by the following updates. Never add UUID metadata here. + initial.put(definition.name(), entry.getValue()); + } + // PostgreSQL checks NOT NULL before ON CONFLICT. An existing row can be + // updated without re-supplying all of its required columns. + if (dialect == Dialect.POSTGRESQL && rowExists(connection)) return; + StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN)); + StringBuilder parameters = new StringBuilder("?"); + for (String key : initial.keySet()) { + names.append(", ").append(quote(key)); + parameters.append(", ?"); } + String prefix = dialect == Dialect.SQLITE ? "INSERT OR IGNORE INTO " + : dialect == Dialect.POSTGRESQL ? "INSERT INTO " : "INSERT IGNORE 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); - statement.executeUpdate(); + int index = 2; + for (DataValue value : initial.values()) bind(statement, index++, value); + inserted = statement.executeUpdate(); + } + // OR IGNORE may reject a missing required field, not just a duplicate UUID. + // Do not acknowledge a batch when no row exists for its bound identity. + if (inserted == 0 && !rowExists(connection)) { + throw new SQLException("SQL user row was not created"); + } + } + + private boolean rowExists(Connection connection) throws SQLException { + String sql = "SELECT 1 FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=? LIMIT 1"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + dialect.bindUuid(statement, 1, uuid); + try (ResultSet result = statement.executeQuery()) { return result.next(); } } } 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 index a3b5855fc..2e89bfaeb 100644 --- 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 @@ -188,26 +188,23 @@ void ensureUuidType() { void ensureColumn(SqlUserSchema.ColumnDefinition column) { synchronized (checkColumnLock) { - // Inspect the actual table without reading user data. Unlike the legacy - // cache helper, inspection errors must abort initialization, not look - // like missing columns. Keep the same connection manager and quoting. - try (Connection connection = getMysql().getConnectionManager().getConnection()) { - try (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 (column.name().equalsIgnoreCase(metadata.getColumnName(i))) { - return; - } - } - } - // Preserve length, nullability and defaults exactly as CREATE does. - // checkColumn(DataType) would replace these with TEXT or BIGINT. + try { + if (hasRegisteredColumn(column.name())) return; String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + " " + normaliseTypeForDb(column.sqlType()) + ";"; - try (PreparedStatement statement = connection.prepareStatement(sql)) { + try (Connection connection = getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); + } catch (SQLException ddlFailure) { + if (!isDuplicateColumn(ddlFailure)) throw ddlFailure; + // Another server may have won the ADD race. Recheck once on + // a new borrowed connection, after the failed DDL is cleaned up. + try { + if (!hasRegisteredColumn(column.name())) throw ddlFailure; + } catch (SQLException inspectionFailure) { + if (inspectionFailure != ddlFailure) ddlFailure.addSuppressed(inspectionFailure); + throw ddlFailure; + } } columns.add(column.name()); if (column.dataType() == DataType.INTEGER && !intColumns.contains(column.name())) { @@ -219,6 +216,25 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { } } + private boolean hasRegisteredColumn(String name) throws SQLException { + // Never turn an unavailable schema inspection into a missing-column result. + 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.equalsIgnoreCase(metadata.getColumnName(i))) return true; + } + return false; + } + } + + 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/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 index 353f5d132..56acdf01f 100644 --- 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 @@ -50,14 +50,16 @@ void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() throws "SELECT * FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=?", "SELECT 1 FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=? LIMIT 1", "DELETE FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=?", - "INSERT INTO \"User \"\"Data\"\"\" (\"uuid\") VALUES (?) ON CONFLICT (\"uuid\") DO NOTHING", + "SELECT 1 FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=? LIMIT 1", + "INSERT INTO \"User \"\"Data\"\"\" (\"uuid\", \"Vote \"\"Flag\"\"\") VALUES (?, ?) ON CONFLICT (\"uuid\") DO NOTHING", "UPDATE \"User \"\"Data\"\"\" SET \"Vote \"\"Flag\"\"\"=? WHERE \"uuid\"=?"), jdbc.sql); - for (int i = 0; i < 4; i++) { + 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(1, "true"); - verify(jdbc.statements.get(4)).setObject(2, UUID_VALUE); + verify(jdbc.statements.get(4)).setString(2, "true"); + verify(jdbc.statements.get(5)).setString(1, "true"); + verify(jdbc.statements.get(5)).setObject(2, UUID_VALUE); verify(jdbc.connection).commit(); verify(jdbc.connection, times(4)).close(); for (PreparedStatement statement : jdbc.statements) verify(statement).close(); @@ -72,9 +74,10 @@ void mysqlAndMariaDbRetainInsertIgnoreAndTextUuidBindings() throws Exception { () -> jdbc.connection, JdbcSqlUserStorage.Dialect.fromDbType(type), SqlBackendLogger.NO_OP); user.write(UserStorage.MYSQL, "Vote `Flag`", new DataValueBoolean(false)); assertEquals(List.of( - "INSERT IGNORE INTO `User ``Data``` (`uuid`) VALUES (?)", + "INSERT IGNORE INTO `User ``Data``` (`uuid`, `Vote ``Flag```) VALUES (?, ?)", "UPDATE `User ``Data``` SET `Vote ``Flag```=? WHERE `uuid`=?"), jdbc.sql); verify(jdbc.statements.get(0)).setString(1, UUID_VALUE.toString()); + verify(jdbc.statements.get(0)).setString(2, "false"); verify(jdbc.statements.get(1)).setString(1, "false"); verify(jdbc.statements.get(1)).setString(2, UUID_VALUE.toString()); verify(jdbc.connection).commit(); @@ -141,6 +144,7 @@ private static final class RecordingJdbc { 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 index d415132c6..da0e39422 100644 --- 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 @@ -16,6 +16,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; @@ -167,7 +168,6 @@ void throwingLoggerCannotMakeCommittedCleanupRetryable() throws Exception { assertDoesNotThrow(() -> jdbc.write()); assertEquals(List.of(logging), Arrays.asList(restore.getSuppressed())); verify(jdbc.connection).commit(); - verify(jdbc.connection).close(); } @Test @@ -184,8 +184,8 @@ void uuidMetadataCannotChangeBoundIdentityInAnyDialect() throws Exception { HashMap before = new HashMap<>(values); user.writeValues(type, values); assertEquals(before, values); - assertEquals(2, jdbc.sql.size()); - assertFalse(jdbc.sql.get(1).contains("SET " + dialect.quote("uuid"))); + assertEquals(dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL ? 3 : 2, jdbc.sql.size()); + assertTrue(jdbc.sql.stream().noneMatch(sql -> sql.contains("SET " + dialect.quote("uuid")))); if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) { verify(jdbc.insert).setObject(1, USER); verify(jdbc.update).setObject(2, USER); @@ -193,6 +193,7 @@ void uuidMetadataCannotChangeBoundIdentityInAnyDialect() throws Exception { verify(jdbc.insert).setString(1, USER.toString()); verify(jdbc.update).setString(2, USER.toString()); } + verify(jdbc.insert).setInt(2, 17); verify(jdbc.update).setInt(1, 17); verify(jdbc.connection).commit(); } @@ -214,6 +215,20 @@ void uuidOnlyBulkIsNoOpButExplicitIdentityMutationIsRejected() throws Exception 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).setInt(1, 23); + verify(jdbc.connection).commit(); + verify(row).close(); + } + private static final class Jdbc { final Connection connection = mock(Connection.class); final PreparedStatement insert = mock(PreparedStatement.class); @@ -223,6 +238,8 @@ private static final class Jdbc { 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); 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..b9076cdb9 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlConcurrentSchemaTest.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.*; +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(2, fixture.inspections); + 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 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); + statements.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("uuid"); + } 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(); + for (ResultSet result : results) verify(result).close(); + } + } +} 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..78e0461d0 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteRequiredColumnTest.java @@ -0,0 +1,81 @@ +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 caseAliasesAndCopiedUuidMetadataKeepOneColumnAndTheBoundIdentity() { + UUID uuid = UUID.randomUUID(); + HashMap values = new HashMap<>(); + values.put("PlayerName", new DataValueString("Ben")); + 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); + } +} From 7e57c1101d2c5d084f7f485040c8e07bcd24915b Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 00:07:56 -0600 Subject: [PATCH 20/48] Account for existing SimpleAPI CREATE cleanup in schema race tests The real SimpleAPI Query closes its CREATE statement twice; JDBC close is idempotent. Track that statement separately while retaining exactly-once cleanup assertions for the new inspection and ALTER statements. All six schema race behavior assertions and connection/result cleanup remain. The preceding Actions run compiled all code and ran 554 tests; only this fixture assumption failed. No production code or dependencies changed. --- .../core/user/storage/sql/MysqlConcurrentSchemaTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index b9076cdb9..9465e4a4d 100644 --- 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 @@ -71,6 +71,7 @@ private static final class Fixture { final DbType type; final List connections = new ArrayList<>(); final List statements = new ArrayList<>(); + final List legacyCreateStatements = new ArrayList<>(); final List results = new ArrayList<>(); SQLException ddl; SQLException recheckFailure; @@ -102,7 +103,8 @@ Connection connection() throws SQLException { when(connection.prepareStatement(anyString())).thenAnswer(call -> { String sql = call.getArgument(0, String.class); PreparedStatement statement = mock(PreparedStatement.class); - statements.add(statement); + if (sql.startsWith("CREATE TABLE")) legacyCreateStatements.add(statement); + else statements.add(statement); when(statement.executeUpdate()).thenAnswer(ignored -> { if (sql.contains(" ADD COLUMN ")) { adds++; throw ddl; } return 0; @@ -133,6 +135,8 @@ Connection connection() throws SQLException { void assertClosed() throws SQLException { for (Connection connection : connections) verify(connection).close(); for (PreparedStatement statement : statements) verify(statement).close(); + // Existing SimpleAPI Query closes CREATE explicitly and in its resource scope. + for (PreparedStatement statement : legacyCreateStatements) verify(statement, atLeastOnce()).close(); for (ResultSet result : results) verify(result).close(); } } From 79d689f1449406071598f6158362d07cea7f3891 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 09:42:10 -0600 Subject: [PATCH 21/48] Fix remaining SQL schema and PostgreSQL write review findings Respect quoted PostgreSQL column case, recheck UUID types after a competing migration, lock rows used by write transactions through commit, and retain the legacy zero fallback for JDBC integer-conversion errors without hiding connection failures. Preserve current providers, snapshots and packaging. Add 14 mocked-JDBC regressions for schema case, migration outcomes, row locking and integer conversion. Local Java 21 syntax and incremental whitespace checks passed; full local Maven and live MySQL/PostgreSQL tests are unavailable. GitHub Actions must validate the full repository build. --- .../user/storage/sql/JdbcSqlUserStorage.java | 21 +- .../user/storage/sql/MysqlUserBackend.java | 16 +- .../storage/sql/JdbcReviewRegressionTest.java | 139 +++++++++++++ .../sql/MysqlBackendReviewRegressionTest.java | 188 ++++++++++++++++++ 4 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcReviewRegressionTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBackendReviewRegressionTest.java 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 index 617b35724..e49c29665 100644 --- 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 @@ -5,6 +5,7 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; +import java.sql.SQLDataException; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -268,7 +269,11 @@ private void ensureRow(Connection connection, Map updates) th } private boolean rowExists(Connection connection) throws SQLException { - String sql = "SELECT 1 FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=? LIMIT 1"; + // Only write transactions call this helper. Keep PostgreSQL's existing + // row locked through UPDATE/commit so a concurrent delete cannot make a + // successful batch silently update zero rows. contains() stays read-only. + String sql = "SELECT 1 FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=? LIMIT 1" + + (dialect == Dialect.POSTGRESQL ? " FOR UPDATE" : ""); try (PreparedStatement statement = connection.prepareStatement(sql)) { dialect.bindUuid(statement, 1, uuid); try (ResultSet result = statement.executeQuery()) { return result.next(); } @@ -300,8 +305,18 @@ private void requireStorage(UserStorage requestedStorage) { private DataValue readValue(ResultSet result, int index, DataType type) throws SQLException { if (type == DataType.INTEGER) { - int value = result.getInt(index); - return new DataValueInt(result.wasNull() ? 0 : value); + try { + int value = result.getInt(index); + return new DataValueInt(result.wasNull() ? 0 : value); + } catch (SQLException invalidInteger) { + // Legacy string columns can contain invalid/out-of-range integer + // text. Preserve the zero fallback, but never hide an I/O failure. + 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); 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 index 2e89bfaeb..5fc2b0eea 100644 --- 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 @@ -180,6 +180,16 @@ void ensureUuidType() { try (Connection connection = getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); + } catch (SQLException ddlFailure) { + // A competing node may have converted VARCHAR to UUID after + // our inspection. Only accept the failure if a fresh inspection + // confirms the required type; retain every genuine failure. + 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 PostgreSQL UUID column", failure); @@ -224,7 +234,11 @@ private boolean hasRegisteredColumn(String name) throws SQLException { ResultSet result = statement.executeQuery()) { ResultSetMetaData metadata = result.getMetaData(); for (int i = 1; i <= metadata.getColumnCount(); i++) { - if (name.equalsIgnoreCase(metadata.getColumnName(i))) return true; + String storedName = metadata.getColumnName(i); + // PostgreSQL's quoted names are case-sensitive. Other supported + // backends keep their established case-insensitive lookup. + if (getDbType() == DbType.POSTGRESQL ? name.equals(storedName) + : name.equalsIgnoreCase(storedName)) return true; } return false; } 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/MysqlBackendReviewRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBackendReviewRegressionTest.java new file mode 100644 index 000000000..29f5f7e77 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBackendReviewRegressionTest.java @@ -0,0 +1,188 @@ +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; + +/** Exercises real backend/SimpleAPI construction with mocked JDBC, not a live database. */ +class MysqlBackendReviewRegressionTest { + @Test void postgresAddsTheExactQuotedSpelling() throws Exception { + Fixture fixture = new Fixture(DbType.POSTGRESQL, "points"); + try (var managers = fixture.managers()) { + try (var backend = fixture.open()) { + assertTrue(backend.isOpen()); + assertEquals(1, fixture.adds.size()); + assertTrue(fixture.adds.get(0).startsWith("ALTER TABLE \"Users\" ADD COLUMN \"Points\" ")); + assertTrue(fixture.adds.get(0).contains("DEFAULT '0'")); + } + 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()); + } + 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 connections = new ArrayList<>(); + final List statements = new ArrayList<>(); + final List results = new ArrayList<>(); + String uuidType = "uuid"; + int uuidInspections, migrations; + boolean competitorConverts; + SQLException migrationFailure, reinspectionFailure; + + Fixture(DbType type, String storedColumn) { + this.type = type; + this.storedColumn = storedColumn; + } + + 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.setTablePrefix(""); + config.setTableName("Users"); + config.setMaxThreads(1); + return new MysqlUserBackend("Users", config, SqlUserSchema.builder() + .column("Points", "INT DEFAULT '0'", DataType.INTEGER).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); + statements.add(statement); + when(statement.executeUpdate()).thenAnswer(ignored -> { + if (sql.contains(" ADD COLUMN ")) adds.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); + } 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); + } + return result; + }); + return statement; + }); + return connection; + } + + void assertClosed() throws SQLException { + for (Connection connection : connections) verify(connection).close(); + // Existing SimpleAPI CREATE owns an idempotent, repeated statement close. + for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close(); + for (ResultSet result : results) verify(result).close(); + } + } +} From 1de42f08e481a5c69ad33d6577953b4fe29f4e02 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 09:50:06 -0600 Subject: [PATCH 22/48] Align SQL regression fixtures with locking and SimpleAPI cleanup Expect FOR UPDATE only in the PostgreSQL write transaction, retaining the unlocked contains assertion. Keep exact once cleanup assertions on new schema inspection/ADD operations while allowing existing SimpleAPI-owned connection setup statements to close idempotently. Actions compiled all sources and ran 568 tests; all 14 new review regression tests passed. Four fixture assertions failed and are corrected here. No production code, test skips, or dependency/packaging changes. --- .../storage/sql/JdbcSqlUserStorageDialectTest.java | 2 +- .../user/storage/sql/MysqlConcurrentSchemaTest.java | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) 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 index 56acdf01f..a8da2e4c7 100644 --- 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 @@ -50,7 +50,7 @@ void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() throws "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", + "SELECT 1 FROM \"User \"\"Data\"\"\" WHERE \"uuid\"=? LIMIT 1 FOR UPDATE", "INSERT INTO \"User \"\"Data\"\"\" (\"uuid\", \"Vote \"\"Flag\"\"\") VALUES (?, ?) ON CONFLICT (\"uuid\") DO NOTHING", "UPDATE \"User \"\"Data\"\"\" SET \"Vote \"\"Flag\"\"\"=? WHERE \"uuid\"=?"), jdbc.sql); for (int i = 0; i < 5; i++) { 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 index 9465e4a4d..9cb9a9d67 100644 --- 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 @@ -71,7 +71,7 @@ private static final class Fixture { final DbType type; final List connections = new ArrayList<>(); final List statements = new ArrayList<>(); - final List legacyCreateStatements = new ArrayList<>(); + final List legacyStatements = new ArrayList<>(); final List results = new ArrayList<>(); SQLException ddl; SQLException recheckFailure; @@ -103,8 +103,8 @@ Connection connection() throws SQLException { when(connection.prepareStatement(anyString())).thenAnswer(call -> { String sql = call.getArgument(0, String.class); PreparedStatement statement = mock(PreparedStatement.class); - if (sql.startsWith("CREATE TABLE")) legacyCreateStatements.add(statement); - else statements.add(statement); + 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; @@ -135,8 +135,9 @@ Connection connection() throws SQLException { void assertClosed() throws SQLException { for (Connection connection : connections) verify(connection).close(); for (PreparedStatement statement : statements) verify(statement).close(); - // Existing SimpleAPI Query closes CREATE explicitly and in its resource scope. - for (PreparedStatement statement : legacyCreateStatements) verify(statement, atLeastOnce()).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(); } } From d308470be0e665c8f6f1f91bf807a55d646e1529 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:12:23 -0600 Subject: [PATCH 23/48] Preserve SQL schema identity and bound enumeration --- .../user/storage/sql/MysqlUserBackend.java | 156 ++++++++++-------- .../core/user/storage/sql/SqlUserBackend.java | 14 ++ .../core/user/storage/sql/SqlUserSchema.java | 8 +- .../user/storage/sql/SqliteUserBackend.java | 115 ++++--------- .../sql/SqlUserSchemaReservedColumnTest.java | 21 +++ docs/shared-sql-user-access.md | 7 + 6 files changed, 172 insertions(+), 149 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaReservedColumnTest.java 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 index 5fc2b0eea..0b1539b2d 100644 --- 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 @@ -10,6 +10,7 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; @@ -31,8 +32,6 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema try { ensureRegisteredColumns(); } catch (RuntimeException | Error failure) { - // A failed UUID conversion must neither expose an incompatible backend - // nor leave the newly owned connection manager running. open.set(false); try { table.close(); @@ -59,24 +58,36 @@ public SqlUserStorage user(UUID uuid) { @Override public List enumerateUsers() { - requireOpen(); - String sql = "SELECT " + table.quote("uuid") + " FROM " + table.quote(table.getTableName()); 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) { + requireOpen(); + Objects.requireNonNull(consumer, "consumer"); + String sql = "SELECT " + table.quote(SqlUserSchema.UUID_COLUMN) + " FROM " + table.quote(table.getTableName()); try (Connection connection = table.getMysql().getConnectionManager().getConnection(); - PreparedStatement statement = connection.prepareStatement(sql); - ResultSet result = statement.executeQuery()) { - while (result.next()) { - String value = result.getString(1); - if (value == null || value.isBlank()) { - continue; - } - try { - users.add(UUID.fromString(value)); - } catch (IllegalArgumentException invalid) { - logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setFetchSize(512); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + String value = result.getString(1); + if (value == null || value.isBlank()) continue; + try { + consumer.accept(UUID.fromString(value)); + } catch (IllegalArgumentException invalid) { + logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); + } } } - return users; } catch (SQLException e) { throw new IllegalStateException("Failed to enumerate MySQL users", e); } @@ -117,7 +128,19 @@ private static final class HeadlessUserTable extends AbstractSqlTable { super(baseTableName, config, config.isDebug(), true); this.schema = schema; this.logger = logger; - init(); + try { + init(); + } catch (RuntimeException | Error failure) { + // AbstractSqlTable owns the connection manager before init() begins. + // Clean it here because the outer backend cannot receive this table + // instance when construction itself fails. + try { + if (getMysql() != null) getMysql().disconnect(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } } @Override @@ -130,9 +153,7 @@ 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(", "); - } + if (!first) sql.append(", "); first = false; String type = SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name()) ? bestUuidType() : normaliseTypeForDb(column.sqlType()); @@ -142,38 +163,16 @@ public String buildCreateTableSql(DbType dbType) { 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); - } + @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() { - if (getDbType() != DbType.POSTGRESQL) { - return; - } + if (getDbType() != DbType.POSTGRESQL) return; try { String uuidType = bestUuidType(); - if (!columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) { - return; - } - // Match AbstractSqlTable's established VARCHAR -> UUID conversion, - // but await it here: alterColumnType() only submits background DDL. - // Use the same connection manager; do not create another JDBC pool. + if (!columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) return; String uuidColumn = quote(SqlUserSchema.UUID_COLUMN); String sql = "ALTER TABLE " + quote(tableName) + " ALTER COLUMN " + uuidColumn + " TYPE " + uuidType + " USING NULLIF(" + uuidColumn + ", '')::uuid;"; @@ -181,9 +180,6 @@ void ensureUuidType() { PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); } catch (SQLException ddlFailure) { - // A competing node may have converted VARCHAR to UUID after - // our inspection. Only accept the failure if a fresh inspection - // confirms the required type; retain every genuine failure. try { if (columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) throw ddlFailure; } catch (SQLException inspectionFailure) { @@ -199,7 +195,14 @@ void ensureUuidType() { void ensureColumn(SqlUserSchema.ColumnDefinition column) { synchronized (checkColumnLock) { try { - if (hasRegisteredColumn(column.name())) return; + String storedName = findRegisteredColumn(column.name()); + if (storedName != null) { + 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(); @@ -207,40 +210,57 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { statement.executeUpdate(); } catch (SQLException ddlFailure) { if (!isDuplicateColumn(ddlFailure)) throw ddlFailure; - // Another server may have won the ADD race. Recheck once on - // a new borrowed connection, after the failed DDL is cleaned up. try { - if (!hasRegisteredColumn(column.name())) throw ddlFailure; + String raced = findRegisteredColumn(column.name()); + if (raced == null) throw ddlFailure; + if (getDbType() == DbType.POSTGRESQL && !raced.equals(column.name())) { + renamePostgresColumn(raced, column.name()); + } } catch (SQLException inspectionFailure) { if (inspectionFailure != ddlFailure) ddlFailure.addSuppressed(inspectionFailure); throw ddlFailure; } } - columns.add(column.name()); - if (column.dataType() == DataType.INTEGER && !intColumns.contains(column.name())) { - intColumns.add(column.name()); - } + rememberColumn(column); } catch (SQLException failure) { throw new IllegalStateException("Failed to initialize registered SQL column: " + column.name(), failure); } } } - private boolean hasRegisteredColumn(String name) throws SQLException { - // Never turn an unavailable schema inspection into a missing-column result. + 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) { + // A peer may have completed the same rename after our inspection. + String current = findRegisteredColumn(requestedName); + if (!requestedName.equals(current)) throw renameFailure; + } + } + + 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 foldedMatch = null; for (int i = 1; i <= metadata.getColumnCount(); i++) { String storedName = metadata.getColumnName(i); - // PostgreSQL's quoted names are case-sensitive. Other supported - // backends keep their established case-insensitive lookup. - if (getDbType() == DbType.POSTGRESQL ? name.equals(storedName) - : name.equalsIgnoreCase(storedName)) return true; + if (name.equals(storedName)) return storedName; + if (foldedMatch == null && name.equalsIgnoreCase(storedName)) foldedMatch = storedName; } - return false; + return foldedMatch; } } @@ -249,8 +269,6 @@ private boolean isDuplicateColumn(SQLException failure) { : failure.getErrorCode() == 1060 && "42S21".equals(failure.getSQLState()); } - String quote(String identifier) { - return qi(identifier); - } + String quote(String identifier) { return qi(identifier); } } } 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 index ab9dbc9aa..20062bbe5 100644 --- 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 @@ -1,18 +1,32 @@ 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 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 index 2c8918acd..b241aea1f 100644 --- 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 @@ -74,10 +74,16 @@ public static final class Builder { private final Map columns = new LinkedHashMap<>(); private Builder() { - column(UUID_COLUMN, "VARCHAR(37)", DataType.STRING); + // Identity spelling/type is invariant. PostgreSQL quotes identifiers and + // therefore cannot tolerate a custom "UUID" replacing canonical "uuid". + 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"); + } ColumnDefinition definition = new ColumnDefinition(name, sqlType, dataType); columns.put(name.toLowerCase(Locale.ROOT), definition); return this; 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 index 2e9dc810e..caf2586d9 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -34,7 +35,7 @@ public SqliteUserBackend(Path dataDirectory, String databaseName, String tableNa SqlUserSchema schema, SqlBackendLogger logger) { Objects.requireNonNull(dataDirectory, "dataDirectory"); Objects.requireNonNull(databaseName, "databaseName"); - quote(tableName); // Validate before opening the database; retain the original name. + quote(tableName); this.tableName = tableName; this.schema = Objects.requireNonNull(schema, "schema"); this.logger = logger == null ? SqlBackendLogger.NO_OP : logger; @@ -45,14 +46,8 @@ public SqliteUserBackend(Path dataDirectory, String databaseName, String tableNa initialize(); } - public Path databaseFile() { - return databaseFile; - } - - @Override - public UserStorage storageType() { - return UserStorage.SQLITE; - } + public Path databaseFile() { return databaseFile; } + @Override public UserStorage storageType() { return UserStorage.SQLITE; } @Override public SqlUserStorage user(UUID uuid) { @@ -60,95 +55,67 @@ public SqlUserStorage user(UUID uuid) { 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 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 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 void writeValues(UserStorage storage, HashMap values) { + withOperation(() -> { delegate.writeValues(storage, values); return null; }); } }; } @Override public List enumerateUsers() { - return withOperation(this::readUserIds); + 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; } - private List readUserIds() { + @Override + public void forEachUser(Consumer consumer) { + Objects.requireNonNull(consumer, "consumer"); + withOperation(() -> { readUserIds(consumer); return null; }); + } + + private void readUserIds(Consumer consumer) { String sql = "SELECT " + quote(SqlUserSchema.UUID_COLUMN) + " FROM " + quote(tableName); - ArrayList users = new ArrayList<>(); try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery()) { while (result.next()) { String value = result.getString(1); - if (value == null || value.isBlank()) { - continue; - } + if (value == null || value.isBlank()) continue; try { - users.add(UUID.fromString(value)); + consumer.accept(UUID.fromString(value)); } catch (IllegalArgumentException invalid) { logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); } } - return users; } catch (SQLException e) { throw new IllegalStateException("Failed to enumerate SQLite users", e); } } - @Override - public boolean isOpen() { - return open.get(); - } + @Override public boolean isOpen() { return open.get(); } - /** - * Stops accepting operations, then waits for every active operation (including - * commit/rollback and JDBC resource cleanup). Call from the storage lifecycle, - * not a game/region thread. Concurrent close calls all wait for the same drain. - * - * @throws IllegalStateException if called from inside an active operation - */ @Override public void close() { if (operations.getReadHoldCount() != 0) { throw new IllegalStateException("Cannot close SQLite from inside an active storage operation"); } open.set(false); - // Do not return early when another closer has already set open=false. - // Uninterruptible acquisition preserves the caller's interrupt status - // without allowing it to start a replacement while a transaction is alive. operations.writeLock().lock(); try { - // Taking the exclusive lock is the drain barrier. Connections are - // operation-owned and have been closed before their read lock releases. + // Exclusive acquisition is the drain barrier. } finally { operations.writeLock().unlock(); } @@ -183,9 +150,7 @@ private void initialize() { private void ensureRegisteredColumns() throws SQLException { for (SqlUserSchema.ColumnDefinition column : schema.columns()) { - if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) { - continue; - } + if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) continue; if (!hasColumn(column.name())) { String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + " " + column.sqlType(); @@ -203,9 +168,7 @@ private boolean hasColumn(String name) throws SQLException { PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery()) { while (result.next()) { - if (name.equalsIgnoreCase(result.getString("name"))) { - return true; - } + if (name.equalsIgnoreCase(result.getString("name"))) return true; } return false; } @@ -215,9 +178,7 @@ 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(", "); - } + if (!first) sql.append(", "); first = false; sql.append(quote(column.name())).append(' ').append(column.sqlType()); } @@ -231,12 +192,8 @@ private Connection openConnection() throws SQLException { } private void requireOpen() { - if (!open.get()) { - throw new IllegalStateException("SQLite user backend is closed"); - } + if (!open.get()) throw new IllegalStateException("SQLite user backend is closed"); } - private static String quote(String identifier) { - return JdbcSqlUserStorage.Dialect.SQLITE.quote(identifier); - } + 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/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/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 From 2f9deddc7fb3408dcbdddfb7c303067b775965f5 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:21:03 -0600 Subject: [PATCH 24/48] Update PostgreSQL case-rename regression --- .../sql/MysqlBackendReviewRegressionTest.java | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) 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 index 29f5f7e77..56048cb06 100644 --- 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 @@ -23,37 +23,29 @@ /** Exercises real backend/SimpleAPI construction with mocked JDBC, not a live database. */ class MysqlBackendReviewRegressionTest { - @Test void postgresAddsTheExactQuotedSpelling() throws Exception { + @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()); - assertEquals(1, fixture.adds.size()); - assertTrue(fixture.adds.get(0).startsWith("ALTER TABLE \"Users\" ADD COLUMN \"Points\" ")); - assertTrue(fixture.adds.get(0).contains("DEFAULT '0'")); + 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"); - } + @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(); } @@ -104,6 +96,7 @@ 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<>(); @@ -112,10 +105,7 @@ private static final class Fixture { boolean competitorConverts; SQLException migrationFailure, reinspectionFailure; - Fixture(DbType type, String storedColumn) { - this.type = type; - this.storedColumn = storedColumn; - } + Fixture(DbType type, String storedColumn) { this.type = type; this.storedColumn = storedColumn; } MockedConstruction managers() { return mockConstruction(ConnectionManager.class, (manager, context) -> { @@ -145,6 +135,7 @@ Connection connection() throws SQLException { 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"; @@ -155,9 +146,7 @@ Connection connection() throws SQLException { }); when(statement.executeQuery()).thenAnswer(ignored -> { boolean inspectUuid = sql.toLowerCase(Locale.ROOT).startsWith("select data_type,"); - if (inspectUuid && ++uuidInspections > 1 && reinspectionFailure != null) { - throw reinspectionFailure; - } + if (inspectUuid && ++uuidInspections > 1 && reinspectionFailure != null) throw reinspectionFailure; ResultSet result = mock(ResultSet.class); results.add(result); if (inspectUuid) { @@ -180,7 +169,6 @@ Connection connection() throws SQLException { void assertClosed() throws SQLException { for (Connection connection : connections) verify(connection).close(); - // Existing SimpleAPI CREATE owns an idempotent, repeated statement close. for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close(); for (ResultSet result : results) verify(result).close(); } From 05b3d262bf72d64d1e709c206da2e366e951fb25 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:26:42 -0600 Subject: [PATCH 25/48] Fix native booleans and streamed user enumeration --- .../user/storage/sql/JdbcSqlUserStorage.java | 194 ++++++------------ .../user/storage/sql/MysqlUserBackend.java | 150 +++++--------- .../user/storage/sql/SqliteUserBackend.java | 86 +++----- 3 files changed, 142 insertions(+), 288 deletions(-) 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 index e49c29665..8b5d3952d 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -46,19 +47,13 @@ String quote(String identifier) { } void bindUuid(PreparedStatement statement, int index, UUID uuid) throws SQLException { - if (this == POSTGRESQL) { - // Match the native UUID column used by the existing PostgreSQL provider. - statement.setObject(index, uuid); - } else { - statement.setString(index, uuid.toString()); - } + if (this == POSTGRESQL) statement.setObject(index, uuid); + else statement.setString(index, uuid.toString()); } } @FunctionalInterface - interface ConnectionProvider { - Connection open() throws SQLException; - } + interface ConnectionProvider { Connection open() throws SQLException; } private final UserStorage storage; private final UUID uuid; @@ -83,13 +78,10 @@ interface ConnectionProvider { 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)) { + 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<>(); - } + if (!result.next()) return new ArrayList<>(); ResultSetMetaData metadata = result.getMetaData(); ArrayList columns = new ArrayList<>(metadata.getColumnCount()); for (int i = 1; i <= metadata.getColumnCount(); i++) { @@ -97,51 +89,38 @@ public List readRow(UserStorage requestedStorage) { SqlUserSchema.ColumnDefinition definition = schema.column(name); DataType type = definition == null ? DataType.STRING : definition.dataType(); Column column = new Column(name, type); - column.setValue(readValue(result, i, type)); + column.setValue(readValue(result, i, definition, type)); columns.add(column); } return columns; } - } catch (SQLException e) { - throw failure("read user row", e); - } + } 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)) { + 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); - } + 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)) { + 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); - } + } 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"); - } + 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); @@ -151,13 +130,9 @@ public void write(UserStorage requestedStorage, String key, DataValue value) { public void writeValues(UserStorage requestedStorage, HashMap values) { requireStorage(requestedStorage); Objects.requireNonNull(values, "values"); - // SELECT * copies include identity metadata. Do not mutate the caller's map - // or let its UUID replace this storage object's bound identity. Map updates = new LinkedHashMap<>(values); updates.keySet().removeIf(SqlUserSchema.UUID_COLUMN::equalsIgnoreCase); - if (updates.isEmpty()) { - return; - } + if (updates.isEmpty()) return; boolean committed = false; try (Connection connection = connections.open()) { boolean autoCommit = connection.getAutoCommit(); @@ -166,9 +141,7 @@ public void writeValues(UserStorage requestedStorage, HashMap Throwable transactionFailure = null; try { ensureRow(connection, updates); - for (Map.Entry entry : updates.entrySet()) { - updateValue(connection, entry.getKey(), entry.getValue()); - } + for (Map.Entry entry : updates.entrySet()) updateValue(connection, entry.getKey(), entry.getValue()); connection.commit(); committed = true; transactionEnded = true; @@ -177,54 +150,33 @@ public void writeValues(UserStorage requestedStorage, HashMap try { connection.rollback(); transactionEnded = true; - } catch (SQLException | RuntimeException rollbackFailure) { - suppress(e, rollbackFailure); - } + } catch (SQLException | RuntimeException rollbackFailure) { suppress(e, rollbackFailure); } throw e; } finally { - // Switching to auto-commit after a failed rollback can commit a - // partial batch. Close that connection without restoring its mode. if (transactionEnded) { - try { - connection.setAutoCommit(autoCommit); - } catch (SQLException | RuntimeException restoreFailure) { - if (transactionFailure != null) { - suppress(transactionFailure, restoreFailure); - } else { - committedCleanupFailure("restore auto-commit", restoreFailure); - } + 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) { - // The resource-close path must not make a durable batch retryable. - committedCleanupFailure("close SQL connection", e); - } else { - throw failure("write user values", 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; - } + if (committed) committedCleanupFailure("close SQL connection", e); + else throw e; } } private void committedCleanupFailure(String operation, Exception error) { - try { - logger.warn("User values committed, but failed to " + operation + " for " + uuid, error); - } catch (RuntimeException loggingFailure) { - // An injected logger must not turn post-commit cleanup into a retry. - suppress(error, loggingFailure); - } + 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); - } + if (primary != secondary) primary.addSuppressed(secondary); } private void ensureRow(Connection connection, Map updates) throws SQLException { @@ -232,15 +184,9 @@ private void ensureRow(Connection connection, Map updates) th for (Map.Entry entry : updates.entrySet()) { String key = Objects.requireNonNull(entry.getKey(), "key"); SqlUserSchema.ColumnDefinition definition = schema.column(key); - if (definition == null) { - throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); - } - // Case aliases refer to one physical column, with the same last-wins - // order used by the following updates. Never add UUID metadata here. + if (definition == null) throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); initial.put(definition.name(), entry.getValue()); } - // PostgreSQL checks NOT NULL before ON CONFLICT. An existing row can be - // updated without re-supplying all of its required columns. if (dialect == Dialect.POSTGRESQL && rowExists(connection)) return; StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN)); StringBuilder parameters = new StringBuilder("?"); @@ -251,27 +197,20 @@ private void ensureRow(Connection connection, Map updates) th String prefix = dialect == Dialect.SQLITE ? "INSERT OR IGNORE INTO " : dialect == Dialect.POSTGRESQL ? "INSERT INTO " : "INSERT IGNORE INTO "; String sql = prefix + quote(tableName) + " (" + names + ") VALUES (" + parameters + ")"; - if (dialect == Dialect.POSTGRESQL) { - sql += " ON CONFLICT (" + quote(SqlUserSchema.UUID_COLUMN) + ") DO NOTHING"; - } + 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 (DataValue value : initial.values()) bind(statement, index++, value); + for (Map.Entry entry : initial.entrySet()) { + bind(statement, index++, entry.getValue(), schema.column(entry.getKey())); + } inserted = statement.executeUpdate(); } - // OR IGNORE may reject a missing required field, not just a duplicate UUID. - // Do not acknowledge a batch when no row exists for its bound identity. - if (inserted == 0 && !rowExists(connection)) { - throw new SQLException("SQL user row was not created"); - } + if (inserted == 0 && !rowExists(connection)) throw new SQLException("SQL user row was not created"); } private boolean rowExists(Connection connection) throws SQLException { - // Only write transactions call this helper. Keep PostgreSQL's existing - // row locked through UPDATE/commit so a concurrent delete cannot make a - // successful batch silently update zero rows. contains() stays read-only. String sql = "SELECT 1 FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=? LIMIT 1" + (dialect == Dialect.POSTGRESQL ? " FOR UPDATE" : ""); try (PreparedStatement statement = connection.prepareStatement(sql)) { @@ -282,69 +221,64 @@ private boolean rowExists(Connection connection) throws SQLException { private void updateValue(Connection connection, String key, DataValue value) throws SQLException { Objects.requireNonNull(key, "key"); - if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(key)) { - throw new IllegalArgumentException("uuid is immutable through SqlUserStorage"); - } - if (!schema.contains(key)) { - throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); - } - String sql = "UPDATE " + quote(tableName) + " SET " + quote(schema.column(key).name()) + "=? WHERE " + if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(key)) throw new IllegalArgumentException("uuid is immutable through SqlUserStorage"); + SqlUserSchema.ColumnDefinition definition = schema.column(key); + if (definition == null) throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); + String sql = "UPDATE " + quote(tableName) + " SET " + quote(definition.name()) + "=? WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=?"; try (PreparedStatement statement = connection.prepareStatement(sql)) { - bind(statement, 1, value); + bind(statement, 1, value, definition); dialect.bindUuid(statement, 2, uuid); statement.executeUpdate(); } } private void requireStorage(UserStorage requestedStorage) { - if (requestedStorage != storage) { - throw new IllegalArgumentException("Storage mismatch: backend=" + storage + ", requested=" + requestedStorage); - } + if (requestedStorage != storage) throw new IllegalArgumentException("Storage mismatch: backend=" + storage + ", requested=" + requestedStorage); } - private DataValue readValue(ResultSet result, int index, DataType type) throws SQLException { + private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDefinition definition, DataType type) throws SQLException { if (type == DataType.INTEGER) { try { int value = result.getInt(index); return new DataValueInt(result.wasNull() ? 0 : value); } catch (SQLException invalidInteger) { - // Legacy string columns can contain invalid/out-of-range integer - // text. Preserve the zero fallback, but never hide an I/O failure. String state = invalidInteger.getSQLState(); - if (invalidInteger instanceof SQLDataException || (state != null && state.startsWith("22"))) { - return new DataValueInt(0); - } + if (invalidInteger instanceof SQLDataException || (state != null && state.startsWith("22"))) return new DataValueInt(0); throw invalidInteger; } } if (type == DataType.BOOLEAN) { + if (isNativeBoolean(definition)) { + boolean value = result.getBoolean(index); + return new DataValueBoolean(!result.wasNull() && value); + } String value = result.getString(index); - // Also read numeric values written by the old SQLite setBoolean path. - return new DataValueBoolean("1".equals(value) || Boolean.parseBoolean(value)); + return new DataValueBoolean("1".equals(value) || "t".equalsIgnoreCase(value) + || "true".equalsIgnoreCase(value)); } return new DataValueString(result.getString(index)); } - private void bind(PreparedStatement statement, int index, DataValue value) throws SQLException { - if (value == null) { - statement.setObject(index, null); - } else if (value.isString()) { - statement.setString(index, value.getString()); - } else if (value.isInt()) { - statement.setInt(index, value.getInt()); - } else if (value.isBoolean()) { - // UserDataKeyBoolean uses VARCHAR(5), not a native SQL boolean column. - statement.setString(index, Boolean.toString(value.getBoolean())); - } else { - statement.setObject(index, value.toString()); - } + 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()) statement.setInt(index, value.getInt()); + else if (value.isBoolean()) { + if (isNativeBoolean(definition)) statement.setBoolean(index, value.getBoolean()); + else statement.setString(index, Boolean.toString(value.getBoolean())); + } else statement.setObject(index, value.toString()); } - private String quote(String identifier) { - return dialect.quote(identifier); + private boolean isNativeBoolean(SqlUserSchema.ColumnDefinition definition) { + if (definition == null || definition.dataType() != DataType.BOOLEAN) return false; + String sqlType = definition.sqlType().strip().toUpperCase(Locale.ROOT); + return sqlType.startsWith("BOOLEAN") || sqlType.startsWith("BOOL"); } + private String quote(String identifier) { return dialect.quote(identifier); } + private IllegalStateException failure(String operation, SQLException error) { logger.warn("Failed to " + operation + " for " + uuid, error); 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 index 0b1539b2d..ec706d156 100644 --- 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 @@ -29,29 +29,20 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema 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) { + try { ensureRegisteredColumns(); } + catch (RuntimeException | Error failure) { open.set(false); - try { - table.close(); - } catch (RuntimeException | Error cleanupFailure) { - failure.addSuppressed(cleanupFailure); - } + try { table.close(); } catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } throw failure; } } - @Override - public UserStorage storageType() { - return UserStorage.MYSQL; - } + @Override public UserStorage storageType() { return UserStorage.MYSQL; } @Override public SqlUserStorage user(UUID uuid) { requireOpen(); - JdbcSqlUserStorage.Dialect dialect = JdbcSqlUserStorage.Dialect.fromDbType( - table.getMysql().getConnectionManager().getDbType()); + JdbcSqlUserStorage.Dialect dialect = JdbcSqlUserStorage.Dialect.fromDbType(table.getMysql().getConnectionManager().getDbType()); return new JdbcSqlUserStorage(UserStorage.MYSQL, uuid, table.getTableName(), schema, () -> table.getMysql().getConnectionManager().getConnection(), dialect, logger); } @@ -60,10 +51,8 @@ public SqlUserStorage user(UUID uuid) { 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"); - } + 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; @@ -74,51 +63,46 @@ public void forEachUser(Consumer consumer) { requireOpen(); Objects.requireNonNull(consumer, "consumer"); String sql = "SELECT " + table.quote(SqlUserSchema.UUID_COLUMN) + " FROM " + table.quote(table.getTableName()); - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); - PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setFetchSize(512); - try (ResultSet result = statement.executeQuery()) { - while (result.next()) { - String value = result.getString(1); - if (value == null || value.isBlank()) continue; - try { - consumer.accept(UUID.fromString(value)); - } catch (IllegalArgumentException invalid) { - logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); + try (Connection connection = table.getMysql().getConnectionManager().getConnection()) { + boolean postgresCursor = table.getDbType() == DbType.POSTGRESQL && connection.getAutoCommit(); + if (postgresCursor) connection.setAutoCommit(false); + try { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setFetchSize(512); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + String value = result.getString(1); + if (value == null || value.isBlank()) continue; + UUID parsed; + try { parsed = UUID.fromString(value); } + catch (IllegalArgumentException invalid) { + logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); + continue; + } + consumer.accept(parsed); + } } } + } finally { + if (postgresCursor) { + try { connection.rollback(); } + finally { connection.setAutoCommit(true); } + } } - } catch (SQLException e) { - throw new IllegalStateException("Failed to enumerate MySQL users", e); - } + } catch (SQLException e) { throw new IllegalStateException("Failed to enumerate MySQL users", e); } } - @Override - public boolean isOpen() { - return open.get(); - } - - @Override - public void close() { - if (open.compareAndSet(true, false)) { - table.close(); - } - } + @Override public boolean isOpen() { return open.get(); } + @Override public void close() { if (open.compareAndSet(true, false)) table.close(); } private void ensureRegisteredColumns() { table.ensureUuidType(); for (SqlUserSchema.ColumnDefinition column : schema.columns()) { - if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) { - table.ensureColumn(column); - } + if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column); } } - private void requireOpen() { - if (!open.get()) { - throw new IllegalStateException("MySQL user backend is closed"); - } - } + private void requireOpen() { if (!open.get()) throw new IllegalStateException("MySQL user backend is closed"); } private static final class HeadlessUserTable extends AbstractSqlTable { private final SqlUserSchema schema; @@ -128,25 +112,15 @@ private static final class HeadlessUserTable extends AbstractSqlTable { super(baseTableName, config, config.isDebug(), true); this.schema = schema; this.logger = logger; - try { - init(); - } catch (RuntimeException | Error failure) { - // AbstractSqlTable owns the connection manager before init() begins. - // Clean it here because the outer backend cannot receive this table - // instance when construction itself fails. - try { - if (getMysql() != null) getMysql().disconnect(); - } catch (RuntimeException | Error cleanupFailure) { - failure.addSuppressed(cleanupFailure); - } + 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 getPrimaryKeyColumn() { return SqlUserSchema.UUID_COLUMN; } @Override public String buildCreateTableSql(DbType dbType) { @@ -155,8 +129,7 @@ public String buildCreateTableSql(DbType dbType) { for (SqlUserSchema.ColumnDefinition column : schema.columns()) { if (!first) sql.append(", "); first = false; - String type = SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name()) - ? bestUuidType() : normaliseTypeForDb(column.sqlType()); + 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("));"); @@ -177,19 +150,15 @@ void ensureUuidType() { String sql = "ALTER TABLE " + quote(tableName) + " ALTER COLUMN " + uuidColumn + " TYPE " + uuidType + " USING NULLIF(" + uuidColumn + ", '')::uuid;"; 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) { + 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 PostgreSQL UUID column", failure); - } + } catch (SQLException failure) { throw new IllegalStateException("Failed to initialize PostgreSQL UUID column", failure); } } void ensureColumn(SqlUserSchema.ColumnDefinition column) { @@ -197,45 +166,35 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { try { String storedName = findRegisteredColumn(column.name()); if (storedName != null) { - if (getDbType() == DbType.POSTGRESQL && !storedName.equals(column.name())) { - renamePostgresColumn(storedName, column.name()); - } + 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) { + 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 (getDbType() == DbType.POSTGRESQL && !raced.equals(column.name())) { - renamePostgresColumn(raced, column.name()); - } + 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); - } + } 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)) { + 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) { - // A peer may have completed the same rename after our inspection. String current = findRegisteredColumn(requestedName); if (!requestedName.equals(current)) throw renameFailure; } @@ -250,8 +209,7 @@ private void rememberColumn(SqlUserSchema.ColumnDefinition column) { private String findRegisteredColumn(String name) throws SQLException { try (Connection connection = getMysql().getConnectionManager().getConnection(); - PreparedStatement statement = connection.prepareStatement( - "SELECT * FROM " + quote(tableName) + " WHERE 1=0"); + PreparedStatement statement = connection.prepareStatement("SELECT * FROM " + quote(tableName) + " WHERE 1=0"); ResultSet result = statement.executeQuery()) { ResultSetMetaData metadata = result.getMetaData(); String foldedMatch = null; 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 index caf2586d9..e2c42b0e0 100644 --- 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 @@ -31,17 +31,14 @@ public final class SqliteUserBackend implements SqlUserBackend { private final AtomicBoolean open = new AtomicBoolean(); private final ReentrantReadWriteLock operations = new ReentrantReadWriteLock(true); - public SqliteUserBackend(Path dataDirectory, String databaseName, String tableName, - SqlUserSchema schema, SqlBackendLogger logger) { + 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"); - } + if (databaseName.isBlank() || databaseName.contains("/") || databaseName.contains("\\")) throw new IllegalArgumentException("databaseName must be a simple file name"); this.databaseFile = dataDirectory.resolve(databaseName + ".db"); initialize(); } @@ -58,12 +55,8 @@ public SqlUserStorage user(UUID uuid) { @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 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; }); } }; } @@ -71,10 +64,8 @@ public SqlUserStorage user(UUID uuid) { 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"); - } + 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; @@ -88,48 +79,37 @@ public void forEachUser(Consumer consumer) { private void readUserIds(Consumer consumer) { String sql = "SELECT " + quote(SqlUserSchema.UUID_COLUMN) + " FROM " + quote(tableName); - try (Connection connection = openConnection(); - PreparedStatement statement = connection.prepareStatement(sql); + try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet result = statement.executeQuery()) { while (result.next()) { String value = result.getString(1); if (value == null || value.isBlank()) continue; - try { - consumer.accept(UUID.fromString(value)); - } catch (IllegalArgumentException invalid) { + UUID parsed; + try { parsed = UUID.fromString(value); } + catch (IllegalArgumentException invalid) { logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); + continue; } + consumer.accept(parsed); } - } catch (SQLException e) { - throw new IllegalStateException("Failed to enumerate SQLite users", e); - } + } catch (SQLException e) { throw new IllegalStateException("Failed to enumerate SQLite users", e); } } @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"); - } + if (operations.getReadHoldCount() != 0) throw new IllegalStateException("Cannot close SQLite from inside an active storage operation"); open.set(false); operations.writeLock().lock(); - try { - // Exclusive acquisition is the drain barrier. - } finally { - operations.writeLock().unlock(); - } + try { } finally { operations.writeLock().unlock(); } } private T withOperation(Supplier operation) { requireOpen(); operations.readLock().lock(); - try { - requireOpen(); - return operation.get(); - } finally { - operations.readLock().unlock(); - } + try { requireOpen(); return operation.get(); } + finally { operations.readLock().unlock(); } } private void initialize() { @@ -137,10 +117,7 @@ private void initialize() { Files.createDirectories(databaseFile.getParent()); Class.forName("org.sqlite.JDBC"); open.set(true); - try (Connection connection = openConnection(); - PreparedStatement statement = connection.prepareStatement(createTableSql())) { - statement.executeUpdate(); - } + try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(createTableSql())) { statement.executeUpdate(); } ensureRegisteredColumns(); } catch (IOException | ClassNotFoundException | SQLException | RuntimeException e) { open.set(false); @@ -152,24 +129,16 @@ private void ensureRegisteredColumns() throws SQLException { for (SqlUserSchema.ColumnDefinition column : schema.columns()) { if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) continue; if (!hasColumn(column.name())) { - String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) - + " " + column.sqlType(); - try (Connection connection = openConnection(); - PreparedStatement statement = connection.prepareStatement(sql)) { - statement.executeUpdate(); - } + String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + " " + column.sqlType(); + try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); } } } } 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; - } + 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; } } @@ -186,14 +155,7 @@ private String createTableSql() { return sql.toString(); } - private Connection openConnection() throws SQLException { - requireOpen(); - return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath()); - } - - private void requireOpen() { - if (!open.get()) throw new IllegalStateException("SQLite user backend is closed"); - } - + private Connection openConnection() throws SQLException { requireOpen(); return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath()); } + private void requireOpen() { if (!open.get()) throw new IllegalStateException("SQLite user backend is closed"); } private static String quote(String identifier) { return JdbcSqlUserStorage.Dialect.SQLITE.quote(identifier); } } From bd8c35087f48d10aab1f9bb1063cd5eededa86bc Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:37:25 -0600 Subject: [PATCH 26/48] Page SQL user enumeration outside callback resources --- .../user/storage/sql/MysqlUserBackend.java | 69 ++++++++++------- .../user/storage/sql/SqliteUserBackend.java | 75 ++++++++++++++----- .../SqliteUserEnumerationCallbackTest.java | 40 ++++++++++ 3 files changed, 139 insertions(+), 45 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java 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 index ec706d156..6810737b0 100644 --- 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 @@ -20,6 +20,7 @@ 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; @@ -62,36 +63,54 @@ public List enumerateUsers() { public void forEachUser(Consumer consumer) { requireOpen(); Objects.requireNonNull(consumer, "consumer"); - String sql = "SELECT " + table.quote(SqlUserSchema.UUID_COLUMN) + " FROM " + table.quote(table.getTableName()); - try (Connection connection = table.getMysql().getConnectionManager().getConnection()) { - boolean postgresCursor = table.getDbType() == DbType.POSTGRESQL && connection.getAutoCommit(); - if (postgresCursor) connection.setAutoCommit(false); - try { - try (PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setFetchSize(512); - try (ResultSet result = statement.executeQuery()) { - while (result.next()) { - String value = result.getString(1); - if (value == null || value.isBlank()) continue; - UUID parsed; - try { parsed = UUID.fromString(value); } - catch (IllegalArgumentException invalid) { - logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); - continue; - } - consumer.accept(parsed); - } + String cursor = null; + while (true) { + List page = readUserPage(cursor); + if (page.isEmpty()) return; + cursor = page.get(page.size() - 1).cursor(); + // The page query and pooled connection are already closed here. Callback + // code may safely perform nested reads/writes even with a one-slot pool. + for (UserPageEntry entry : page) { + if (entry.uuid() != null) consumer.accept(entry.uuid()); + } + if (page.size() < USER_PAGE_SIZE) return; + } + } + + private List readUserPage(String cursor) { + String uuidColumn = table.quote(SqlUserSchema.UUID_COLUMN); + String sql = "SELECT " + uuidColumn + " FROM " + table.quote(table.getTableName()) + + (cursor == null ? "" : " WHERE " + 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) dialect.bindUuid(statement, index++, UUID.fromString(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); } + catch (IllegalArgumentException invalid) { + logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); } - } - } finally { - if (postgresCursor) { - try { connection.rollback(); } - finally { connection.setAutoCommit(true); } + page.add(new UserPageEntry(value, parsed)); } } - } catch (SQLException e) { throw new IllegalStateException("Failed to enumerate MySQL users", e); } + 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 (open.compareAndSet(true, false)) table.close(); } 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 index e2c42b0e0..88f5e59ae 100644 --- 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 @@ -11,6 +11,7 @@ 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; @@ -24,6 +25,7 @@ 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; @@ -74,27 +76,51 @@ public List enumerateUsers() { @Override public void forEachUser(Consumer consumer) { Objects.requireNonNull(consumer, "consumer"); - withOperation(() -> { readUserIds(consumer); return null; }); + withOperation(() -> { + String cursor = null; + while (true) { + List page = readUserPage(cursor); + if (page.isEmpty()) return null; + cursor = page.get(page.size() - 1).cursor(); + // No SQLite result set/read transaction is open while callbacks run. + for (UserPageEntry entry : page) { + if (entry.uuid() != null) consumer.accept(entry.uuid()); + } + if (page.size() < USER_PAGE_SIZE) return null; + } + }); } - private void readUserIds(Consumer consumer) { - String sql = "SELECT " + quote(SqlUserSchema.UUID_COLUMN) + " FROM " + quote(tableName); - try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql); - ResultSet result = statement.executeQuery()) { - while (result.next()) { - String value = result.getString(1); - if (value == null || value.isBlank()) continue; - UUID parsed; - try { parsed = UUID.fromString(value); } - catch (IllegalArgumentException invalid) { - logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); - continue; + private List readUserPage(String cursor) { + String uuidColumn = quote(SqlUserSchema.UUID_COLUMN); + String sql = "SELECT " + uuidColumn + " FROM " + quote(tableName) + + (cursor == null ? "" : " WHERE " + 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); } + catch (IllegalArgumentException invalid) { + logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); + } + page.add(new UserPageEntry(value, parsed)); } - consumer.accept(parsed); } - } catch (SQLException e) { throw new IllegalStateException("Failed to enumerate SQLite users", e); } + 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 @@ -120,21 +146,30 @@ private void initialize() { try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(createTableSql())) { statement.executeUpdate(); } ensureRegisteredColumns(); } catch (IOException | ClassNotFoundException | SQLException | RuntimeException e) { - open.set(false); + open.set(false; throw new IllegalStateException("Failed to initialize SQLite user backend at " + databaseFile, e); } } private void ensureRegisteredColumns() throws SQLException { for (SqlUserSchema.ColumnDefinition column : schema.columns()) { - if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) continue; - if (!hasColumn(column.name())) { - String sql = "ALTER TABLE " + quote(tableName) + " ADD COLUMN " + quote(column.name()) + " " + column.sqlType(); - try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); } + 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) { + // A second backend can win the same schema expansion race. Accept + // only the duplicate-column case after a fresh schema inspection. + 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()) { 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..0da20114f --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java @@ -0,0 +1,40 @@ +package com.bencodez.advancedcore.tests.storage; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +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.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()); + } + } + } +} From b9a4c35389d23f0c499d281204d00976efb9e995 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:44:00 -0600 Subject: [PATCH 27/48] Fix SQLite paging compile typo --- .../advancedcore/core/user/storage/sql/SqliteUserBackend.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 88f5e59ae..299ec7023 100644 --- 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 @@ -146,7 +146,7 @@ private void initialize() { try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(createTableSql())) { statement.executeUpdate(); } ensureRegisteredColumns(); } catch (IOException | ClassNotFoundException | SQLException | RuntimeException e) { - open.set(false; + open.set(false); throw new IllegalStateException("Failed to initialize SQLite user backend at " + databaseFile, e); } } From c4a1a1e782fe28bc2533e541946957fb9b0a9672 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:44:28 -0600 Subject: [PATCH 28/48] Preserve raw MySQL enumeration cursor values --- .../core/user/storage/sql/MysqlUserBackend.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 6810737b0..2f930078e 100644 --- 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 @@ -86,7 +86,13 @@ private List readUserPage(String cursor) { try (Connection connection = table.getMysql().getConnectionManager().getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { int index = 1; - if (cursor != null) dialect.bindUuid(statement, index++, UUID.fromString(cursor)); + 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()) { From 8be88c3cacec8e7335c9f80b4a4630cc0e6f9a67 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:24:24 -0600 Subject: [PATCH 29/48] Fix SQL batch and boolean review findings --- .../user/storage/sql/JdbcSqlUserStorage.java | 99 +++++++++++++------ 1 file changed, 69 insertions(+), 30 deletions(-) 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 index 8b5d3952d..9b8121939 100644 --- 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 @@ -52,6 +52,12 @@ void bindUuid(PreparedStatement statement, int index, UUID uuid) throws SQLExcep } } + private enum BooleanStorage { + TEXT, + NATIVE, + NUMERIC + } + @FunctionalInterface interface ConnectionProvider { Connection open() throws SQLException; } @@ -130,8 +136,7 @@ public void write(UserStorage requestedStorage, String key, DataValue value) { public void writeValues(UserStorage requestedStorage, HashMap values) { requireStorage(requestedStorage); Objects.requireNonNull(values, "values"); - Map updates = new LinkedHashMap<>(values); - updates.keySet().removeIf(SqlUserSchema.UUID_COLUMN::equalsIgnoreCase); + Map updates = canonicalize(values); if (updates.isEmpty()) return; boolean committed = false; try (Connection connection = connections.open()) { @@ -141,7 +146,7 @@ public void writeValues(UserStorage requestedStorage, HashMap Throwable transactionFailure = null; try { ensureRow(connection, updates); - for (Map.Entry entry : updates.entrySet()) updateValue(connection, entry.getKey(), entry.getValue()); + updateValues(connection, updates); connection.commit(); committed = true; transactionEnded = true; @@ -170,6 +175,18 @@ public void writeValues(UserStorage requestedStorage, HashMap } } + 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); + 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); } @@ -180,36 +197,35 @@ private static void suppress(Throwable primary, Throwable secondary) { } private void ensureRow(Connection connection, Map updates) throws SQLException { - Map initial = new LinkedHashMap<>(); - for (Map.Entry entry : updates.entrySet()) { - String key = Objects.requireNonNull(entry.getKey(), "key"); - SqlUserSchema.ColumnDefinition definition = schema.column(key); - if (definition == null) throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); - initial.put(definition.name(), entry.getValue()); - } - if (dialect == Dialect.POSTGRESQL && rowExists(connection)) return; + if (dialect != Dialect.SQLITE && rowExists(connection)) return; StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN)); StringBuilder parameters = new StringBuilder("?"); - for (String key : initial.keySet()) { + for (String key : updates.keySet()) { names.append(", ").append(quote(key)); parameters.append(", ?"); } - String prefix = dialect == Dialect.SQLITE ? "INSERT OR IGNORE INTO " - : dialect == Dialect.POSTGRESQL ? "INSERT INTO " : "INSERT IGNORE INTO "; + 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 : initial.entrySet()) { + for (Map.Entry entry : updates.entrySet()) { bind(statement, index++, entry.getValue(), schema.column(entry.getKey())); } inserted = statement.executeUpdate(); + } catch (SQLException insertFailure) { + if (dialect == Dialect.MYSQL && isDuplicateKey(insertFailure) && rowExists(connection)) return; + throw insertFailure; } if (inserted == 0 && !rowExists(connection)) throw new SQLException("SQL user row was not created"); } + 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.POSTGRESQL ? " FOR UPDATE" : ""); @@ -219,16 +235,21 @@ private boolean rowExists(Connection connection) throws SQLException { } } - private void updateValue(Connection connection, String key, DataValue value) throws SQLException { - Objects.requireNonNull(key, "key"); - if (SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(key)) throw new IllegalArgumentException("uuid is immutable through SqlUserStorage"); - SqlUserSchema.ColumnDefinition definition = schema.column(key); - if (definition == null) throw new IllegalArgumentException("Column is not registered in the SQL schema: " + key); - String sql = "UPDATE " + quote(tableName) + " SET " + quote(definition.name()) + "=? WHERE " - + quote(SqlUserSchema.UUID_COLUMN) + "=?"; - try (PreparedStatement statement = connection.prepareStatement(sql)) { - bind(statement, 1, value, definition); - dialect.bindUuid(statement, 2, uuid); + private void updateValues(Connection connection, Map updates) throws SQLException { + 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("=?"); + } + 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(), schema.column(entry.getKey())); + } + dialect.bindUuid(statement, index, uuid); statement.executeUpdate(); } } @@ -249,10 +270,15 @@ private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDef } } if (type == DataType.BOOLEAN) { - if (isNativeBoolean(definition)) { + BooleanStorage booleanStorage = booleanStorage(definition); + if (booleanStorage == BooleanStorage.NATIVE) { boolean value = result.getBoolean(index); return new DataValueBoolean(!result.wasNull() && value); } + if (booleanStorage == BooleanStorage.NUMERIC) { + int value = result.getInt(index); + return new DataValueBoolean(!result.wasNull() && value != 0); + } String value = result.getString(index); return new DataValueBoolean("1".equals(value) || "t".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value)); @@ -266,15 +292,28 @@ private void bind(PreparedStatement statement, int index, DataValue value, else if (value.isString()) statement.setString(index, value.getString()); else if (value.isInt()) statement.setInt(index, value.getInt()); else if (value.isBoolean()) { - if (isNativeBoolean(definition)) statement.setBoolean(index, value.getBoolean()); + BooleanStorage booleanStorage = booleanStorage(definition); + if (booleanStorage == BooleanStorage.NATIVE) statement.setBoolean(index, value.getBoolean()); + else if (booleanStorage == BooleanStorage.NUMERIC) statement.setInt(index, value.getBoolean() ? 1 : 0); else statement.setString(index, Boolean.toString(value.getBoolean())); } else statement.setObject(index, value.toString()); } - private boolean isNativeBoolean(SqlUserSchema.ColumnDefinition definition) { - if (definition == null || definition.dataType() != DataType.BOOLEAN) return false; + private BooleanStorage booleanStorage(SqlUserSchema.ColumnDefinition definition) { + if (definition == null || definition.dataType() != DataType.BOOLEAN) return BooleanStorage.TEXT; String sqlType = definition.sqlType().strip().toUpperCase(Locale.ROOT); - return sqlType.startsWith("BOOLEAN") || sqlType.startsWith("BOOL"); + if (startsType(sqlType, "BOOLEAN") || startsType(sqlType, "BOOL")) return BooleanStorage.NATIVE; + if (startsType(sqlType, "TINYINT") || startsType(sqlType, "SMALLINT") || startsType(sqlType, "MEDIUMINT") + || startsType(sqlType, "INT") || startsType(sqlType, "INTEGER") || startsType(sqlType, "BIGINT") + || startsType(sqlType, "BIT")) 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); } From ea68d09af5fca2ffccdfe3e8563d4287eac0478a Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:24:44 -0600 Subject: [PATCH 30/48] Drain SQLite operations before closing --- .../core/user/storage/sql/SqliteUserBackend.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 299ec7023..5539529c0 100644 --- 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 @@ -82,7 +82,6 @@ public void forEachUser(Consumer consumer) { List page = readUserPage(cursor); if (page.isEmpty()) return null; cursor = page.get(page.size() - 1).cursor(); - // No SQLite result set/read transaction is open while callbacks run. for (UserPageEntry entry : page) { if (entry.uuid() != null) consumer.accept(entry.uuid()); } @@ -126,9 +125,12 @@ private record UserPageEntry(String cursor, UUID uuid) {} @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(); } + try { + open.set(false); + } finally { + operations.writeLock().unlock(); + } } private T withOperation(Supplier operation) { @@ -158,8 +160,6 @@ private void ensureRegisteredColumns() throws SQLException { try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); } catch (SQLException addFailure) { - // A second backend can win the same schema expansion race. Accept - // only the duplicate-column case after a fresh schema inspection. if (!isDuplicateColumn(addFailure) || !hasColumn(column.name())) throw addFailure; } } From faf4c553e240e50febe8d1859866e547cbaca318 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:33:31 -0600 Subject: [PATCH 31/48] Reject new SQLite work while draining active callbacks --- .../user/storage/sql/SqliteUserBackend.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 index 5539529c0..eaf3a678b 100644 --- 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 @@ -125,12 +125,9 @@ private record UserPageEntry(String cursor, UUID uuid) {} @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 { - open.set(false); - } finally { - operations.writeLock().unlock(); - } + try { } finally { operations.writeLock().unlock(); } } private T withOperation(Supplier operation) { @@ -191,6 +188,14 @@ private String createTableSql() { } private Connection openConnection() throws SQLException { requireOpen(); return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath()); } - private void requireOpen() { if (!open.get()) throw new IllegalStateException("SQLite user backend is closed"); } + + private void requireOpen() { + // A close marks the backend unavailable immediately to new callers, but an + // operation that already owns the read lock may finish nested JDBC work. + if (!open.get() && operations.getReadHoldCount() == 0) { + throw new IllegalStateException("SQLite user backend is closed"); + } + } + private static String quote(String identifier) { return JdbcSqlUserStorage.Dialect.SQLITE.quote(identifier); } } From 888eb4245866da30df468aeae382e1776ed8f730 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:35:15 -0600 Subject: [PATCH 32/48] Update SQL regressions for review fixes --- .../sql/JdbcSqlUserStorageDialectTest.java | 53 +++++++++++++++++-- .../JdbcSqlUserStorageWriteOutcomeTest.java | 41 ++++++++++---- 2 files changed, 79 insertions(+), 15 deletions(-) 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 index a8da2e4c7..8f94bf599 100644 --- 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 @@ -15,6 +15,7 @@ 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; @@ -66,7 +67,7 @@ void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() throws } @Test - void mysqlAndMariaDbRetainInsertIgnoreAndTextUuidBindings() throws Exception { + void mysqlAndMariaDbUseConstraintSafeInsertAndTextUuidBindings() 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(); @@ -74,17 +75,59 @@ void mysqlAndMariaDbRetainInsertIgnoreAndTextUuidBindings() throws Exception { () -> jdbc.connection, JdbcSqlUserStorage.Dialect.fromDbType(type), SqlBackendLogger.NO_OP); user.write(UserStorage.MYSQL, "Vote `Flag`", new DataValueBoolean(false)); assertEquals(List.of( - "INSERT IGNORE INTO `User ``Data``` (`uuid`, `Vote ``Flag```) VALUES (?, ?)", + "SELECT 1 FROM `User ``Data``` WHERE `uuid`=? LIMIT 1", + "INSERT INTO `User ``Data``` (`uuid`, `Vote ``Flag```) VALUES (?, ?)", "UPDATE `User ``Data``` SET `Vote ``Flag```=? WHERE `uuid`=?"), jdbc.sql); verify(jdbc.statements.get(0)).setString(1, UUID_VALUE.toString()); - verify(jdbc.statements.get(0)).setString(2, "false"); - verify(jdbc.statements.get(1)).setString(1, "false"); - verify(jdbc.statements.get(1)).setString(2, UUID_VALUE.toString()); + verify(jdbc.statements.get(1)).setString(1, UUID_VALUE.toString()); + verify(jdbc.statements.get(1)).setString(2, "false"); + verify(jdbc.statements.get(2)).setString(1, "false"); + verify(jdbc.statements.get(2)).setString(2, UUID_VALUE.toString()); verify(jdbc.connection).commit(); verify(jdbc.connection).close(); } } + @Test + void numericBooleanDefinitionsUseNumericJdbcBindingsAndReads() throws Exception { + Connection writeConnection = mock(Connection.class); + when(writeConnection.getAutoCommit()).thenReturn(true); + PreparedStatement exists = mock(PreparedStatement.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement update = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(exists.executeQuery()).thenReturn(missing); + when(insert.executeUpdate()).thenReturn(1); + when(update.executeUpdate()).thenReturn(1); + when(writeConnection.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0, String.class); + if (sql.startsWith("SELECT 1")) return exists; + if (sql.startsWith("INSERT")) return insert; + return update; + }); + 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); + verify(update).setInt(1, 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"); + when(result.getInt(1)).thenReturn(1); + 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()); + } + @Test void backendUsesConnectionManagerDialectAndExistingUuidSchemaType() throws Exception { for (DbType type : List.of(DbType.POSTGRESQL, DbType.MYSQL, DbType.MARIADB)) { 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 index da0e39422..7967e2a83 100644 --- 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 @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.UUID; @@ -41,7 +42,6 @@ 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(); @@ -54,7 +54,6 @@ 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(); @@ -69,7 +68,6 @@ void bothPostCommitCleanupFailuresAreReportedWithoutRetry() throws Exception { 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(); @@ -86,7 +84,6 @@ void failedWriteRetainsSqlCauseAndSuppressedCleanupFailures() throws Exception { 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())); @@ -102,7 +99,6 @@ void runtimeFailureIsNotReplacedByRestorationFailure() throws Exception { 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(); @@ -117,7 +113,6 @@ void failedRollbackNeverEnablesAutoCommitOnPartialBatch() throws Exception { 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())); @@ -133,7 +128,6 @@ void commitFailureRemainsAFailureAndAttemptsRollback() throws Exception { 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())); @@ -148,7 +142,6 @@ void setupFailureStillClosesConnectionAndKeepsOriginalCause() throws Exception { 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())); @@ -164,7 +157,6 @@ void throwingLoggerCannotMakeCommittedCleanupRetryable() throws Exception { 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(); @@ -184,7 +176,7 @@ void uuidMetadataCannotChangeBoundIdentityInAnyDialect() throws Exception { HashMap before = new HashMap<>(values); user.writeValues(type, values); assertEquals(before, values); - assertEquals(dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL ? 3 : 2, jdbc.sql.size()); + assertEquals(dialect == JdbcSqlUserStorage.Dialect.SQLITE ? 2 : 3, jdbc.sql.size()); assertTrue(jdbc.sql.stream().noneMatch(sql -> sql.contains("SET " + dialect.quote("uuid")))); if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) { verify(jdbc.insert).setObject(1, USER); @@ -199,6 +191,35 @@ void uuidMetadataCannotChangeBoundIdentityInAnyDialect() throws Exception { } } + @Test + void bulkValuesUseOneAtomicUpdateStatement() throws Exception { + Connection connection = mock(Connection.class); + when(connection.getAutoCommit()).thenReturn(true); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement update = mock(PreparedStatement.class); + when(insert.executeUpdate()).thenReturn(1); + List sql = new ArrayList<>(); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + String query = call.getArgument(0, String.class); + sql.add(query); + return query.startsWith("INSERT") ? insert : update; + }); + SqlUserSchema schema = SqlUserSchema.builder() + .column("A", "INTEGER", DataType.INTEGER) + .column("B", "INTEGER", DataType.INTEGER).build(); + JdbcSqlUserStorage user = new JdbcSqlUserStorage(UserStorage.SQLITE, USER, "Users", schema, + () -> connection, JdbcSqlUserStorage.Dialect.SQLITE, SqlBackendLogger.NO_OP); + HashMap values = new LinkedHashMap<>(); + values.put("A", new DataValueInt(2)); + values.put("B", new DataValueInt(2)); + user.writeValues(UserStorage.SQLITE, values); + assertEquals(2, sql.size()); + assertEquals("UPDATE `Users` SET `A`=?, `B`=? WHERE `uuid`=?", sql.get(1)); + verify(update).setInt(1, 2); + verify(update).setInt(2, 2); + verify(update).setString(3, USER.toString()); + } + @Test void uuidOnlyBulkIsNoOpButExplicitIdentityMutationIsRejected() throws Exception { Jdbc jdbc = new Jdbc(); From 558d6b6a4d86056cc6303580c9a32bf465339b83 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:43:46 -0600 Subject: [PATCH 33/48] Fix SQL lifecycle review follow-ups --- .../user/storage/sql/JdbcSqlUserStorage.java | 2 +- .../user/storage/sql/MysqlUserBackend.java | 80 +++++++++++----- .../user/storage/sql/SqliteUserBackend.java | 20 ++-- .../sql/JdbcSqlUserStorageDialectTest.java | 12 +-- .../MysqlUserBackendLifecycleReviewTest.java | 95 +++++++++++++++++++ .../SqliteUserEnumerationCallbackTest.java | 33 +++++++ 6 files changed, 195 insertions(+), 47 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendLifecycleReviewTest.java 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 index 9b8121939..1e05ef95e 100644 --- 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 @@ -228,7 +228,7 @@ private boolean isDuplicateKey(SQLException failure) { private boolean rowExists(Connection connection) throws SQLException { String sql = "SELECT 1 FROM " + quote(tableName) + " WHERE " + quote(SqlUserSchema.UUID_COLUMN) + "=? LIMIT 1" - + (dialect == Dialect.POSTGRESQL ? " FOR UPDATE" : ""); + + (dialect == Dialect.SQLITE ? "" : " FOR UPDATE"); try (PreparedStatement statement = connection.prepareStatement(sql)) { dialect.bindUuid(statement, 1, uuid); try (ResultSet result = statement.executeQuery()) { return result.next(); } 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 index 2f930078e..b18299f23 100644 --- 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 @@ -6,15 +6,20 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; 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; @@ -25,6 +30,8 @@ public final class MysqlUserBackend implements SqlUserBackend { private final SqlBackendLogger logger; private final HeadlessUserTable table; private final AtomicBoolean open = new AtomicBoolean(true); + 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"); @@ -33,7 +40,8 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema try { ensureRegisteredColumns(); } catch (RuntimeException | Error failure) { open.set(false); - try { table.close(); } catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + try { table.close(); tableClosed = true; } + catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } throw failure; } } @@ -44,8 +52,15 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema public SqlUserStorage user(UUID uuid) { requireOpen(); JdbcSqlUserStorage.Dialect dialect = JdbcSqlUserStorage.Dialect.fromDbType(table.getMysql().getConnectionManager().getDbType()); - return new JdbcSqlUserStorage(UserStorage.MYSQL, uuid, table.getTableName(), schema, + 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 @@ -61,20 +76,19 @@ public List enumerateUsers() { @Override public void forEachUser(Consumer consumer) { - requireOpen(); Objects.requireNonNull(consumer, "consumer"); - String cursor = null; - while (true) { - List page = readUserPage(cursor); - if (page.isEmpty()) return; - cursor = page.get(page.size() - 1).cursor(); - // The page query and pooled connection are already closed here. Callback - // code may safely perform nested reads/writes even with a one-slot pool. - for (UserPageEntry entry : page) { - if (entry.uuid() != null) consumer.accept(entry.uuid()); + 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; } - if (page.size() < USER_PAGE_SIZE) return; - } + }); } private List readUserPage(String cursor) { @@ -87,11 +101,8 @@ private List readUserPage(String cursor) { 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); - } + 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); @@ -101,9 +112,7 @@ private List readUserPage(String cursor) { if (value == null) continue; UUID parsed = null; try { parsed = UUID.fromString(value); } - catch (IllegalArgumentException invalid) { - logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); - } + catch (IllegalArgumentException invalid) { logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); } page.add(new UserPageEntry(value, parsed)); } } @@ -118,7 +127,28 @@ private List readUserPage(String cursor) { private record UserPageEntry(String cursor, UUID uuid) {} @Override public boolean isOpen() { return open.get(); } - @Override public void close() { if (open.compareAndSet(true, false)) table.close(); } + + @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) { + requireOpen(); + operations.readLock().lock(); + try { requireOpen(); return operation.get(); } + finally { operations.readLock().unlock(); } + } private void ensureRegisteredColumns() { table.ensureUuidType(); @@ -127,7 +157,9 @@ private void ensureRegisteredColumns() { } } - private void requireOpen() { if (!open.get()) throw new IllegalStateException("MySQL user backend is closed"); } + private void requireOpen() { + if (!open.get() && operations.getReadHoldCount() == 0) throw new IllegalStateException("MySQL user backend is closed"); + } private static final class HeadlessUserTable extends AbstractSqlTable { private final SqlUserSchema schema; 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 index eaf3a678b..a66f37b18 100644 --- 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 @@ -93,7 +93,8 @@ public void forEachUser(Consumer consumer) { private List readUserPage(String cursor) { String uuidColumn = quote(SqlUserSchema.UUID_COLUMN); String sql = "SELECT " + uuidColumn + " FROM " + quote(tableName) - + (cursor == null ? "" : " WHERE " + uuidColumn + " > ?") + + " 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; @@ -106,9 +107,7 @@ private List readUserPage(String cursor) { if (value == null) continue; UUID parsed = null; try { parsed = UUID.fromString(value); } - catch (IllegalArgumentException invalid) { - logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); - } + catch (IllegalArgumentException invalid) { logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); } page.add(new UserPageEntry(value, parsed)); } } @@ -154,11 +153,8 @@ private void ensureRegisteredColumns() throws SQLException { 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; - } + try (Connection connection = openConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); } + catch (SQLException addFailure) { if (!isDuplicateColumn(addFailure) || !hasColumn(column.name())) throw addFailure; } } } @@ -190,11 +186,7 @@ private String createTableSql() { private Connection openConnection() throws SQLException { requireOpen(); return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath()); } private void requireOpen() { - // A close marks the backend unavailable immediately to new callers, but an - // operation that already owns the read lock may finish nested JDBC work. - if (!open.get() && operations.getReadHoldCount() == 0) { - throw new IllegalStateException("SQLite user backend is closed"); - } + if (!open.get() && operations.getReadHoldCount() == 0) 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/JdbcSqlUserStorageDialectTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageDialectTest.java index 8f94bf599..d14376b09 100644 --- 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 @@ -32,7 +32,6 @@ import com.bencodez.simpleapi.sql.mysql.DbType; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; -/** JDBC contract tests, not a live PostgreSQL/MySQL integration database. */ class JdbcSqlUserStorageDialectTest { private static final UUID UUID_VALUE = UUID.fromString("542b75a0-5333-4f44-828c-94676443cf5d"); @@ -46,7 +45,6 @@ void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() throws 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", @@ -67,7 +65,7 @@ void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() throws } @Test - void mysqlAndMariaDbUseConstraintSafeInsertAndTextUuidBindings() throws Exception { + void mysqlAndMariaDbLockExistingRowsAndUseConstraintSafeInsert() 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(); @@ -75,7 +73,7 @@ void mysqlAndMariaDbUseConstraintSafeInsertAndTextUuidBindings() throws Exceptio () -> 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", + "SELECT 1 FROM `User ``Data``` WHERE `uuid`=? LIMIT 1 FOR UPDATE", "INSERT INTO `User ``Data``` (`uuid`, `Vote ``Flag```) VALUES (?, ?)", "UPDATE `User ``Data``` SET `Vote ``Flag```=? WHERE `uuid`=?"), jdbc.sql); verify(jdbc.statements.get(0)).setString(1, UUID_VALUE.toString()); @@ -144,14 +142,12 @@ void backendUsesConnectionManagerDialectAndExistingUuidSchemaType() throws Excep when(manager.getDbType()).thenReturn(type); when(manager.getConnection()).thenReturn(jdbc.connection); })) { - try (MysqlUserBackend backend = new MysqlUserBackend("ignored", config, - SqlUserSchema.builder().build(), SqlBackendLogger.NO_OP)) { + 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)); + 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`));")); 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..367ac8671 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendLifecycleReviewTest.java @@ -0,0 +1,95 @@ +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(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/tests/storage/SqliteUserEnumerationCallbackTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java index 0da20114f..3ac5b752c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java @@ -2,7 +2,11 @@ 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; @@ -37,4 +41,33 @@ void callbacksCanWriteThroughTheSameBackendWithoutHoldingTheEnumerationReadTrans } } } + + @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); + } + } } From 6a7a8d17b9ed0e7a93e107146b932f6745c30ed4 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:49:21 -0600 Subject: [PATCH 34/48] Reject ambiguous SQL batch aliases --- .../user/storage/sql/JdbcSqlUserStorage.java | 145 +++++------------- .../sql/JdbcDuplicateCanonicalColumnTest.java | 36 +++++ 2 files changed, 73 insertions(+), 108 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcDuplicateCanonicalColumnTest.java 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 index 1e05ef95e..b9bb08009 100644 --- 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 @@ -27,39 +27,25 @@ final class JdbcSqlUserStorage implements SqlUserStorage { enum Dialect { - SQLITE, - MYSQL, - POSTGRESQL; - + 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"); - } + 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()); + if (this == POSTGRESQL) statement.setObject(index, uuid); else statement.setString(index, uuid.toString()); } } - private enum BooleanStorage { - TEXT, - NATIVE, - NUMERIC - } - - @FunctionalInterface - interface ConnectionProvider { Connection open() throws SQLException; } + private enum BooleanStorage { TEXT, NATIVE, NUMERIC } + @FunctionalInterface interface ConnectionProvider { Connection open() throws SQLException; } private final UserStorage storage; private final UUID uuid; @@ -80,8 +66,7 @@ interface ConnectionProvider { Connection open() throws SQLException; } this.logger = Objects.requireNonNull(logger, "logger"); } - @Override - public List readRow(UserStorage requestedStorage) { + @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)) { @@ -103,8 +88,7 @@ public List readRow(UserStorage requestedStorage) { } catch (SQLException e) { throw failure("read user row", e); } } - @Override - public boolean contains(UserStorage requestedStorage) { + @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)) { @@ -113,27 +97,21 @@ public boolean contains(UserStorage requestedStorage) { } catch (SQLException e) { throw failure("check user row", e); } } - @Override - public void delete(UserStorage requestedStorage) { + @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(); + 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) { + @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); + HashMap values = new HashMap<>(); values.put(key, value); writeValues(requestedStorage, values); } - @Override - public void writeValues(UserStorage requestedStorage, HashMap values) { + @Override public void writeValues(UserStorage requestedStorage, HashMap values) { requireStorage(requestedStorage); Objects.requireNonNull(values, "values"); Map updates = canonicalize(values); @@ -147,31 +125,24 @@ public void writeValues(UserStorage requestedStorage, HashMap try { ensureRow(connection, updates); updateValues(connection, updates); - connection.commit(); - committed = true; - transactionEnded = true; + 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); } + 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); + 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); + 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; + if (committed) committedCleanupFailure("close SQL connection", e); else throw e; } } @@ -182,6 +153,7 @@ private Map canonicalize(Map values) { 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; @@ -191,19 +163,13 @@ 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); - } + private static void suppress(Throwable primary, Throwable secondary) { if (primary != secondary) primary.addSuppressed(secondary); } private void ensureRow(Connection connection, Map updates) throws SQLException { if (dialect != Dialect.SQLITE && rowExists(connection)) return; StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN)); StringBuilder parameters = new StringBuilder("?"); - for (String key : updates.keySet()) { - names.append(", ").append(quote(key)); - parameters.append(", ?"); - } + for (String key : updates.keySet()) { names.append(", ").append(quote(key)); parameters.append(", ?"); } 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"; @@ -211,9 +177,7 @@ private void ensureRow(Connection connection, Map updates) th 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(), schema.column(entry.getKey())); - } + for (Map.Entry entry : updates.entrySet()) bind(statement, index++, entry.getValue(), schema.column(entry.getKey())); inserted = statement.executeUpdate(); } catch (SQLException insertFailure) { if (dialect == Dialect.MYSQL && isDuplicateKey(insertFailure) && rowExists(connection)) return; @@ -222,13 +186,10 @@ private void ensureRow(Connection connection, Map updates) th if (inserted == 0 && !rowExists(connection)) throw new SQLException("SQL user row was not created"); } - private boolean isDuplicateKey(SQLException failure) { - return failure.getErrorCode() == 1062 || "23000".equals(failure.getSQLState()); - } + 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"); + 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(); } @@ -238,32 +199,21 @@ private boolean rowExists(Connection connection) throws SQLException { private void updateValues(Connection connection, Map updates) throws SQLException { 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("=?"); - } + for (String key : updates.keySet()) { if (!first) sql.append(", "); first = false; sql.append(quote(key)).append("=?"); } 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(), schema.column(entry.getKey())); - } - dialect.bindUuid(statement, index, uuid); - statement.executeUpdate(); + for (Map.Entry entry : updates.entrySet()) bind(statement, index++, entry.getValue(), schema.column(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 void requireStorage(UserStorage requestedStorage) { if (requestedStorage != storage) throw new IllegalArgumentException("Storage mismatch: backend=" + storage + ", requested=" + requestedStorage); } private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDefinition definition, DataType type) throws SQLException { if (type == DataType.INTEGER) { - try { - int value = result.getInt(index); - return new DataValueInt(result.wasNull() ? 0 : value); - } catch (SQLException invalidInteger) { + 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; @@ -271,23 +221,15 @@ private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDef } if (type == DataType.BOOLEAN) { BooleanStorage booleanStorage = booleanStorage(definition); - if (booleanStorage == BooleanStorage.NATIVE) { - boolean value = result.getBoolean(index); - return new DataValueBoolean(!result.wasNull() && value); - } - if (booleanStorage == BooleanStorage.NUMERIC) { - int value = result.getInt(index); - return new DataValueBoolean(!result.wasNull() && value != 0); - } + if (booleanStorage == BooleanStorage.NATIVE) { boolean value = result.getBoolean(index); return new DataValueBoolean(!result.wasNull() && value); } + if (booleanStorage == BooleanStorage.NUMERIC) { int value = result.getInt(index); return new DataValueBoolean(!result.wasNull() && value != 0); } String value = result.getString(index); - return new DataValueBoolean("1".equals(value) || "t".equalsIgnoreCase(value) - || "true".equalsIgnoreCase(value)); + return new DataValueBoolean("1".equals(value) || "t".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value)); } return new DataValueString(result.getString(index)); } - private void bind(PreparedStatement statement, int index, DataValue value, - SqlUserSchema.ColumnDefinition definition) throws SQLException { + 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()) statement.setInt(index, value.getInt()); @@ -303,23 +245,10 @@ 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, "TINYINT") || startsType(sqlType, "SMALLINT") || startsType(sqlType, "MEDIUMINT") - || startsType(sqlType, "INT") || startsType(sqlType, "INTEGER") || startsType(sqlType, "BIGINT") - || startsType(sqlType, "BIT")) return BooleanStorage.NUMERIC; + if (startsType(sqlType, "TINYINT") || startsType(sqlType, "SMALLINT") || startsType(sqlType, "MEDIUMINT") || startsType(sqlType, "INT") || startsType(sqlType, "INTEGER") || startsType(sqlType, "BIGINT") || startsType(sqlType, "BIT")) 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 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) { - logger.warn("Failed to " + operation + " for " + uuid, error); - return new IllegalStateException("Failed to " + operation + " for " + uuid, error); - } + private IllegalStateException failure(String operation, SQLException error) { logger.warn("Failed to " + operation + " for " + uuid, error); return new IllegalStateException("Failed to " + operation + " for " + uuid, error); } } 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(); + } +} From 0747a5c1030bf7fd4a4e250cff183731ec74b3a2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:52:07 -0600 Subject: [PATCH 35/48] Update SQLite alias regression expectations --- .../storage/SqliteRequiredColumnTest.java | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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 index 78e0461d0..5b685a2ef 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteRequiredColumnTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteRequiredColumnTest.java @@ -60,11 +60,26 @@ class SqliteRequiredColumnTest { try (SqliteUserBackend backend = open()) { assertTrue(backend.enumerateUsers().isEmpty()); } } - @Test void caseAliasesAndCopiedUuidMetadataKeepOneColumnAndTheBoundIdentity() { + @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("playername", new DataValueString("Ben")); values.put("UUID", new DataValueString(UUID.randomUUID().toString())); HashMap original = new HashMap<>(values); try (SqliteUserBackend backend = open()) { From 765d4e189e9af8b44bdcc573caf9e2b2eae4b172 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:57:36 -0600 Subject: [PATCH 36/48] Close SQLite admission race --- .../user/storage/sql/SqliteUserBackend.java | 25 ++++++--- .../SqliteUserBackendAdmissionRaceTest.java | 55 +++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendAdmissionRaceTest.java 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 index a66f37b18..3fe97bba8 100644 --- 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 @@ -50,7 +50,7 @@ public SqliteUserBackend(Path dataDirectory, String databaseName, String tableNa @Override public SqlUserStorage user(UUID uuid) { - requireOpen(); + requireAdmissionOpen(); SqlUserStorage delegate = new JdbcSqlUserStorage(UserStorage.SQLITE, uuid, tableName, schema, this::openConnection, JdbcSqlUserStorage.Dialect.SQLITE, logger); return new SqlUserStorage() { @@ -130,10 +130,16 @@ public void close() { } private T withOperation(Supplier operation) { - requireOpen(); + requireAdmissionOpen(); operations.readLock().lock(); - try { requireOpen(); return operation.get(); } - finally { operations.readLock().unlock(); } + 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() { @@ -183,10 +189,15 @@ private String createTableSql() { return sql.toString(); } - private Connection openConnection() throws SQLException { requireOpen(); return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.toAbsolutePath()); } - - private void requireOpen() { + 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/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()); + } +} From ccf8064ab6caba8283617eaf91f630e23a777bf2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 14:05:05 -0600 Subject: [PATCH 37/48] Fix SQL schema and insert follow-ups --- .../user/storage/sql/JdbcSqlUserStorage.java | 15 +- .../user/storage/sql/MysqlUserBackend.java | 109 +++---- .../core/user/storage/sql/SqlUserSchema.java | 45 +-- .../sql/JdbcSqlUserStorageDialectTest.java | 55 +--- .../JdbcSqlUserStorageWriteOutcomeTest.java | 271 +++++------------- .../MysqlUserBackendAdmissionRaceTest.java | 62 ++++ .../sql/SqlUserSchemaDuplicateColumnTest.java | 31 ++ 7 files changed, 245 insertions(+), 343 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackendAdmissionRaceTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchemaDuplicateColumnTest.java 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 index b9bb08009..12002bb23 100644 --- 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 @@ -123,8 +123,8 @@ private enum BooleanStorage { TEXT, NATIVE, NUMERIC } boolean transactionEnded = false; Throwable transactionFailure = null; try { - ensureRow(connection, updates); - updateValues(connection, updates); + boolean updateExistingRow = ensureRow(connection, updates); + if (updateExistingRow) updateValues(connection, updates); connection.commit(); committed = true; transactionEnded = true; } catch (SQLException | RuntimeException | Error e) { transactionFailure = e; @@ -165,8 +165,9 @@ private void committedCleanupFailure(String operation, Exception error) { } private static void suppress(Throwable primary, Throwable secondary) { if (primary != secondary) primary.addSuppressed(secondary); } - private void ensureRow(Connection connection, Map updates) throws SQLException { - if (dialect != Dialect.SQLITE && rowExists(connection)) return; + /** @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; StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN)); StringBuilder parameters = new StringBuilder("?"); for (String key : updates.keySet()) { names.append(", ").append(quote(key)); parameters.append(", ?"); } @@ -180,10 +181,12 @@ private void ensureRow(Connection connection, Map updates) th for (Map.Entry entry : updates.entrySet()) bind(statement, index++, entry.getValue(), schema.column(entry.getKey())); inserted = statement.executeUpdate(); } catch (SQLException insertFailure) { - if (dialect == Dialect.MYSQL && isDuplicateKey(insertFailure) && rowExists(connection)) return; + if (dialect == Dialect.MYSQL && isDuplicateKey(insertFailure) && rowExists(connection)) return true; throw insertFailure; } - if (inserted == 0 && !rowExists(connection)) throw new SQLException("SQL user row was not created"); + 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()); } 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 index b18299f23..d48c1cf13 100644 --- 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 @@ -50,7 +50,7 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema @Override public SqlUserStorage user(UUID uuid) { - requireOpen(); + 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); @@ -63,19 +63,16 @@ public SqlUserStorage user(UUID uuid) { }; } - @Override - public List enumerateUsers() { + @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"); + 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) { + @Override public void forEachUser(Consumer consumer) { Objects.requireNonNull(consumer, "consumer"); withOperation(() -> { String cursor = null; @@ -83,9 +80,7 @@ public void forEachUser(Consumer consumer) { 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()); - } + for (UserPageEntry entry : page) if (entry.uuid() != null) consumer.accept(entry.uuid()); if (page.size() < USER_PAGE_SIZE) return null; } }); @@ -93,16 +88,12 @@ public void forEachUser(Consumer consumer) { private List readUserPage(String cursor) { String uuidColumn = table.quote(SqlUserSchema.UUID_COLUMN); - String sql = "SELECT " + uuidColumn + " FROM " + table.quote(table.getTableName()) - + (cursor == null ? "" : " WHERE " + uuidColumn + " > ?") - + " ORDER BY " + uuidColumn + " ASC LIMIT ?"; + String sql = "SELECT " + uuidColumn + " FROM " + table.quote(table.getTableName()) + (cursor == null ? "" : " WHERE " + 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)) { + 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); + 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); @@ -117,48 +108,39 @@ private List readUserPage(String cursor) { } } 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); - } + } 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() { + @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(); - } + if (!tableClosed) { table.close(); tableClosed = true; } + } finally { operations.writeLock().unlock(); } } private T withOperation(Supplier operation) { - requireOpen(); + requireAdmissionOpen(); operations.readLock().lock(); - try { requireOpen(); return operation.get(); } - finally { operations.readLock().unlock(); } + try { + requireAdmissionOpen(); + return operation.get(); + } finally { operations.readLock().unlock(); } } private void ensureRegisteredColumns() { table.ensureUuidType(); - for (SqlUserSchema.ColumnDefinition column : schema.columns()) { - if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column); - } + for (SqlUserSchema.ColumnDefinition column : schema.columns()) if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column); } - private void requireOpen() { - if (!open.get() && operations.getReadHoldCount() == 0) throw new IllegalStateException("MySQL user backend is closed"); + private void requireAdmissionOpen() { + if (!open.get()) throw new IllegalStateException("MySQL user backend is closed"); } private static final class HeadlessUserTable extends AbstractSqlTable { @@ -178,14 +160,11 @@ private static final class HeadlessUserTable extends AbstractSqlTable { } @Override public String getPrimaryKeyColumn() { return SqlUserSchema.UUID_COLUMN; } - - @Override - public String buildCreateTableSql(DbType dbType) { + @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; + 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); } @@ -204,10 +183,8 @@ void ensureUuidType() { String uuidType = bestUuidType(); if (!columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) return; String uuidColumn = quote(SqlUserSchema.UUID_COLUMN); - String sql = "ALTER TABLE " + quote(tableName) + " ALTER COLUMN " + uuidColumn - + " TYPE " + uuidType + " USING NULLIF(" + uuidColumn + ", '')::uuid;"; - try (Connection connection = getMysql().getConnectionManager().getConnection(); - PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); } + String sql = "ALTER TABLE " + quote(tableName) + " ALTER COLUMN " + uuidColumn + " TYPE " + uuidType + " USING NULLIF(" + uuidColumn + ", '')::uuid;"; + 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) { @@ -224,13 +201,10 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { String storedName = findRegisteredColumn(column.name()); if (storedName != null) { if (getDbType() == DbType.POSTGRESQL && !storedName.equals(column.name())) renamePostgresColumn(storedName, column.name()); - rememberColumn(column); - return; + 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(); } + 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 { @@ -249,27 +223,18 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { 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; - } + 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 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()); + 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 foldedMatch = null; + 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 foldedMatch = null; for (int i = 1; i <= metadata.getColumnCount(); i++) { String storedName = metadata.getColumnName(i); if (name.equals(storedName)) return storedName; @@ -279,11 +244,7 @@ private String findRegisteredColumn(String name) throws SQLException { } } - private boolean isDuplicateColumn(SQLException failure) { - return getDbType() == DbType.POSTGRESQL ? "42701".equals(failure.getSQLState()) - : failure.getErrorCode() == 1060 && "42S21".equals(failure.getSQLState()); - } - + 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/SqlUserSchema.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserSchema.java index b241aea1f..842a7b9b9 100644 --- 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 @@ -22,12 +22,8 @@ public record ColumnDefinition(String name, String sqlType, DataType dataType) { 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"); - } + if (name.isBlank()) throw new IllegalArgumentException("Column name cannot be blank"); + if (sqlType.isBlank()) throw new IllegalArgumentException("SQL type cannot be blank"); } } @@ -37,45 +33,32 @@ private SqlUserSchema(Map columnsByLowerName) { this.columnsByLowerName = Collections.unmodifiableMap(new LinkedHashMap<>(columnsByLowerName)); } - public static Builder builder() { - return new Builder(); - } + public static Builder builder() { return new Builder(); } public static SqlUserSchema fromKeys(Collection 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; - } + 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 List columns() { return new ArrayList<>(columnsByLowerName.values()); } public ColumnDefinition column(String name) { - if (name == null) { - return null; - } + if (name == null) return null; return columnsByLowerName.get(name.toLowerCase(Locale.ROOT)); } - public boolean contains(String name) { - return column(name) != null; - } + public boolean contains(String name) { return column(name) != null; } public static final class Builder { private final Map columns = new LinkedHashMap<>(); private Builder() { - // Identity spelling/type is invariant. PostgreSQL quotes identifiers and - // therefore cannot tolerate a custom "UUID" replacing canonical "uuid". columns.put(UUID_COLUMN, new ColumnDefinition(UUID_COLUMN, "VARCHAR(37)", DataType.STRING)); } @@ -84,13 +67,15 @@ public Builder column(String name, String sqlType, DataType dataType) { if (UUID_COLUMN.equalsIgnoreCase(name)) { throw new IllegalArgumentException("Column name 'uuid' is reserved for user identity"); } - ColumnDefinition definition = new ColumnDefinition(name, sqlType, dataType); - columns.put(name.toLowerCase(Locale.ROOT), definition); + 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); - } + public SqlUserSchema build() { return new SqlUserSchema(columns); } } } 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 index d14376b09..9775b80b5 100644 --- 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 @@ -35,8 +35,7 @@ class JdbcSqlUserStorageDialectTest { private static final UUID UUID_VALUE = UUID.fromString("542b75a0-5333-4f44-828c-94676443cf5d"); - @Test - void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() throws Exception { + @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, @@ -50,22 +49,18 @@ void postgresqlQuotesIdentifiersAndBindsNativeUuidForEveryCrudOperation() throws "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", - "UPDATE \"User \"\"Data\"\"\" SET \"Vote \"\"Flag\"\"\"=? WHERE \"uuid\"=?"), jdbc.sql); + "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.statements.get(5)).setString(1, "true"); - verify(jdbc.statements.get(5)).setObject(2, UUID_VALUE); verify(jdbc.connection).commit(); verify(jdbc.connection, times(4)).close(); for (PreparedStatement statement : jdbc.statements) verify(statement).close(); } - @Test - void mysqlAndMariaDbLockExistingRowsAndUseConstraintSafeInsert() throws Exception { + @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(); @@ -74,41 +69,29 @@ void mysqlAndMariaDbLockExistingRowsAndUseConstraintSafeInsert() throws Exceptio 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 (?, ?)", - "UPDATE `User ``Data``` SET `Vote ``Flag```=? WHERE `uuid`=?"), jdbc.sql); + "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.statements.get(2)).setString(1, "false"); - verify(jdbc.statements.get(2)).setString(2, UUID_VALUE.toString()); verify(jdbc.connection).commit(); verify(jdbc.connection).close(); } } - @Test - void numericBooleanDefinitionsUseNumericJdbcBindingsAndReads() throws Exception { + @Test void numericBooleanDefinitionsUseNumericJdbcBindingsAndReads() throws Exception { Connection writeConnection = mock(Connection.class); when(writeConnection.getAutoCommit()).thenReturn(true); PreparedStatement exists = mock(PreparedStatement.class); PreparedStatement insert = mock(PreparedStatement.class); - PreparedStatement update = mock(PreparedStatement.class); ResultSet missing = mock(ResultSet.class); when(exists.executeQuery()).thenReturn(missing); when(insert.executeUpdate()).thenReturn(1); - when(update.executeUpdate()).thenReturn(1); - when(writeConnection.prepareStatement(anyString())).thenAnswer(call -> { - String sql = call.getArgument(0, String.class); - if (sql.startsWith("SELECT 1")) return exists; - if (sql.startsWith("INSERT")) return insert; - return update; - }); + 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); - verify(update).setInt(1, 1); Connection readConnection = mock(Connection.class); PreparedStatement read = mock(PreparedStatement.class); @@ -126,20 +109,15 @@ void numericBooleanDefinitionsUseNumericJdbcBindingsAndReads() throws Exception assertTrue(reader.readRow(UserStorage.MYSQL).get(0).getValue().getBoolean()); } - @Test - void backendUsesConnectionManagerDialectAndExistingUuidSchemaType() throws Exception { + @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"); + 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.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)) { @@ -154,14 +132,12 @@ void backendUsesConnectionManagerDialectAndExistingUuidSchemaType() throws Excep verify(lookup).setString(1, UUID_VALUE.toString()); } } - assertEquals(1, managers.constructed().size()); - verify(managers.constructed().get(0)).close(); + assertEquals(1, managers.constructed().size()); verify(managers.constructed().get(0)).close(); } } } - @Test - void dialectRejectsInvalidIdentifiersWithoutRejectingQuotedNames() { + @Test void dialectRejectsInvalidIdentifiersWithoutRejectingQuotedNames() { for (JdbcSqlUserStorage.Dialect dialect : JdbcSqlUserStorage.Dialect.values()) { assertThrows(IllegalArgumentException.class, () -> dialect.quote(null)); assertThrows(IllegalArgumentException.class, () -> dialect.quote("")); @@ -175,15 +151,12 @@ 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); + 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 index 7967e2a83..06afaf5ef 100644 --- 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 @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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; @@ -33,221 +32,116 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.simpleapi.sql.data.DataValueString; -/** Failure-injection JDBC contracts; these mocks are not live database tests. */ 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"); + @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)); + 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"); + @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)); + 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 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(); + @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(); + 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); + @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(); + 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(); + @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(); + 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); + @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(); + 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(); + @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(); + 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 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 uuidMetadataCannotChangeBoundIdentityInAnyDialect() throws Exception { + @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; + 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 ? 2 : 3, jdbc.sql.size()); - assertTrue(jdbc.sql.stream().noneMatch(sql -> sql.contains("SET " + dialect.quote("uuid")))); - if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) { - verify(jdbc.insert).setObject(1, USER); - verify(jdbc.update).setObject(2, USER); - } else { - verify(jdbc.insert).setString(1, USER.toString()); - verify(jdbc.update).setString(2, USER.toString()); - } - verify(jdbc.insert).setInt(2, 17); - verify(jdbc.update).setInt(1, 17); - verify(jdbc.connection).commit(); + 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()); + verify(jdbc.insert).setInt(2, 17); verify(jdbc.connection).commit(); } } - @Test - void bulkValuesUseOneAtomicUpdateStatement() throws Exception { - Connection connection = mock(Connection.class); - when(connection.getAutoCommit()).thenReturn(true); - PreparedStatement insert = mock(PreparedStatement.class); - PreparedStatement update = mock(PreparedStatement.class); - when(insert.executeUpdate()).thenReturn(1); - List sql = new ArrayList<>(); - when(connection.prepareStatement(anyString())).thenAnswer(call -> { - String query = call.getArgument(0, String.class); - sql.add(query); - return query.startsWith("INSERT") ? insert : update; - }); - SqlUserSchema schema = SqlUserSchema.builder() - .column("A", "INTEGER", DataType.INTEGER) - .column("B", "INTEGER", DataType.INTEGER).build(); - JdbcSqlUserStorage user = new JdbcSqlUserStorage(UserStorage.SQLITE, USER, "Users", schema, - () -> connection, JdbcSqlUserStorage.Dialect.SQLITE, SqlBackendLogger.NO_OP); - HashMap values = new LinkedHashMap<>(); - values.put("A", new DataValueInt(2)); - values.put("B", new DataValueInt(2)); - user.writeValues(UserStorage.SQLITE, values); - assertEquals(2, sql.size()); - assertEquals("UPDATE `Users` SET `A`=?, `B`=? WHERE `uuid`=?", sql.get(1)); - verify(update).setInt(1, 2); - verify(update).setInt(2, 2); - verify(update).setString(3, USER.toString()); + @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).setInt(1, 2); verify(statement).setInt(2, 2); 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"))); + @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(); + 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).setInt(1, 23); - verify(jdbc.connection).commit(); - verify(row).close(); + @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).setInt(1, 23); verify(jdbc.connection).commit(); verify(row).close(); } private static final class Jdbc { @@ -256,27 +150,20 @@ private static final class Jdbc { 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(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; - }); + 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); + return new JdbcSqlUserStorage(type, USER, "Users", SqlUserSchema.builder().column("Points", "INTEGER", DataType.INTEGER).build(), () -> connection, dialect, logger); } - void write() { - user(UserStorage.SQLITE, JdbcSqlUserStorage.Dialect.SQLITE) - .write(UserStorage.SQLITE, "Points", new DataValueInt(17)); + 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/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/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()); + } +} From 5c0fddba274199024f68c6880a2714892b182dad Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 14:09:44 -0600 Subject: [PATCH 38/48] Fix PostgreSQL bit boolean binding --- .../user/storage/sql/JdbcSqlUserStorage.java | 33 +++++++- .../sql/JdbcPostgresBitBooleanTest.java | 77 +++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcPostgresBitBooleanTest.java 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 index 12002bb23..8ca12fad3 100644 --- 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 @@ -44,7 +44,7 @@ void bindUuid(PreparedStatement statement, int index, UUID uuid) throws SQLExcep } } - private enum BooleanStorage { TEXT, NATIVE, NUMERIC } + private enum BooleanStorage { TEXT, NATIVE, NUMERIC, POSTGRES_BIT } @FunctionalInterface interface ConnectionProvider { Connection open() throws SQLException; } private final UserStorage storage; @@ -170,7 +170,10 @@ private boolean ensureRow(Connection connection, Map updates) if (dialect != Dialect.SQLITE && rowExists(connection)) return true; StringBuilder names = new StringBuilder(quote(SqlUserSchema.UUID_COLUMN)); StringBuilder parameters = new StringBuilder("?"); - for (String key : updates.keySet()) { names.append(", ").append(quote(key)); parameters.append(", ?"); } + for (String key : updates.keySet()) { + names.append(", ").append(quote(key)); + parameters.append(", ").append(parameterExpression(schema.column(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"; @@ -202,7 +205,12 @@ private boolean rowExists(Connection connection) throws SQLException { private void updateValues(Connection connection, Map updates) throws SQLException { 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("=?"); } + for (String key : updates.keySet()) { + if (!first) sql.append(", "); + first = false; + SqlUserSchema.ColumnDefinition definition = schema.column(key); + sql.append(quote(key)).append('=').append(parameterExpression(definition)); + } sql.append(" WHERE ").append(quote(SqlUserSchema.UUID_COLUMN)).append("=?"); try (PreparedStatement statement = connection.prepareStatement(sql.toString())) { int index = 1; @@ -240,15 +248,32 @@ else if (value.isBoolean()) { BooleanStorage booleanStorage = booleanStorage(definition); 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) { + if (booleanStorage(definition) != BooleanStorage.POSTGRES_BIT) return "?"; + return "CAST(? AS " + postgresBitType(definition) + ")"; + } + + private String postgresBitType(SqlUserSchema.ColumnDefinition definition) { + String sqlType = definition.sqlType().strip(); + int separator = sqlType.indexOf(' '); + String token = separator < 0 ? sqlType : sqlType.substring(0, separator); + if (!token.matches("(?i)BIT(?:\\(\\d+\\))?")) { + throw new IllegalArgumentException("Unsupported PostgreSQL bit type: " + definition.sqlType()); + } + return token.toUpperCase(Locale.ROOT); + } + 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, "TINYINT") || startsType(sqlType, "SMALLINT") || startsType(sqlType, "MEDIUMINT") || startsType(sqlType, "INT") || startsType(sqlType, "INTEGER") || startsType(sqlType, "BIGINT") || startsType(sqlType, "BIT")) return BooleanStorage.NUMERIC; + if (startsType(sqlType, "BIT")) 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 == '('; } 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..17253ce58 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcPostgresBitBooleanTest.java @@ -0,0 +1,77 @@ +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.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +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; + +class JdbcPostgresBitBooleanTest { + private static final UUID USER = UUID.fromString("51829cd0-c37c-45bf-9910-57914800e0a1"); + + @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 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); + } +} From d46909046467d41141e2d99fba7aa46be8589e2b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:49:42 -0600 Subject: [PATCH 39/48] Migrate narrow MySQL UUID columns --- .../user/storage/sql/MysqlUserBackend.java | 9 ++-- .../sql/MysqlBackendReviewRegressionTest.java | 9 +++- .../sql/MysqlConcurrentSchemaTest.java | 5 +- .../MysqlUserBackendSchemaExpansionTest.java | 5 +- .../MysqlUserBackendUuidMigrationTest.java | 54 ++++++++++++++++--- 5 files changed, 69 insertions(+), 13 deletions(-) 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 index d48c1cf13..c88c37587 100644 --- 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 @@ -178,12 +178,15 @@ private static final class HeadlessUserTable extends AbstractSqlTable { @Override public void debug(String message) { logger.info(message); } void ensureUuidType() { - if (getDbType() != DbType.POSTGRESQL) return; try { String uuidType = bestUuidType(); if (!columnNeedsAlter(SqlUserSchema.UUID_COLUMN, uuidType)) return; String uuidColumn = quote(SqlUserSchema.UUID_COLUMN); - String sql = "ALTER TABLE " + quote(tableName) + " ALTER COLUMN " + uuidColumn + " TYPE " + uuidType + " USING NULLIF(" + uuidColumn + ", '')::uuid;"; + 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; } @@ -192,7 +195,7 @@ void ensureUuidType() { throw ddlFailure; } } - } catch (SQLException failure) { throw new IllegalStateException("Failed to initialize PostgreSQL UUID column", failure); } + } catch (SQLException failure) { throw new IllegalStateException("Failed to initialize SQL UUID column", failure); } } void ensureColumn(SqlUserSchema.ColumnDefinition column) { 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 index 56048cb06..be82e2d66 100644 --- 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 @@ -105,7 +105,11 @@ private static final class Fixture { boolean competitorConverts; SQLException migrationFailure, reinspectionFailure; - Fixture(DbType type, String storedColumn) { this.type = type; this.storedColumn = storedColumn; } + 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) -> { @@ -153,6 +157,9 @@ Connection connection() throws SQLException { 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); 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 index 9cb9a9d67..6ed54e311 100644 --- 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 @@ -118,7 +118,10 @@ Connection connection() throws SQLException { results.add(result); if (sql.toLowerCase(Locale.ROOT).startsWith("select data_type,")) { when(result.next()).thenReturn(true, false); - when(result.getString(1)).thenReturn("uuid"); + 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); 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 index 27e3e3301..9cdcfbb12 100644 --- 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 @@ -123,7 +123,10 @@ Connection connection() throws SQLException { results.add(result); if (query.toLowerCase(Locale.ROOT).startsWith("select data_type,")) { when(result.next()).thenReturn(true, false); - when(result.getString(1)).thenReturn("uuid"); + 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.endsWith("WHERE 1=0")) { ResultSetMetaData metadata = mock(ResultSetMetaData.class); when(result.getMetaData()).thenReturn(metadata); 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 index a5c594ec8..80fd88e59 100644 --- 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 @@ -35,6 +35,28 @@ class MysqlUserBackendUuidMigrationTest { 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 { @@ -99,8 +121,12 @@ void inspectionFailureDoesNotExposeUnknownUuidTypeOrLeakPool() throws Exception } private static MysqlUserBackend openBackend() { + return openBackend(DbType.POSTGRESQL); + } + + private static MysqlUserBackend openBackend(DbType dbType) { MysqlConfig config = new MysqlConfig(); - config.setDbType(DbType.POSTGRESQL); + config.setDbType(dbType); config.setDatabase("test_database"); config.setMaxThreads(2); config.setTablePrefix("prefix-"); @@ -115,19 +141,27 @@ private static final class JdbcFixture { 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.POSTGRESQL); + when(manager.getDbType()).thenReturn(dbType); when(manager.getConnection()).thenAnswer(ignored -> connection()); }); } @@ -141,10 +175,11 @@ Connection connection() throws SQLException { PreparedStatement statement = mock(PreparedStatement.class); statements.add(statement); when(statement.executeUpdate()).thenAnswer(ignored -> { - if (ALTER_UUID.equals(query)) { + if (ALTER_UUID.equals(query) || ALTER_MYSQL_UUID.equals(query)) { migrationThread = Thread.currentThread(); if (migrationFailure != null) throw migrationFailure; - uuidType = "uuid"; + 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. @@ -156,16 +191,21 @@ Connection connection() throws SQLException { verify(statement).setString(1, TABLE); verify(statement).setString(2, "uuid"); ResultSet result = row(uuidType); - when(result.getObject(2)).thenReturn(37L); + 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 (!"uuid".equals(uuidType)) throw new SQLException("varchar = uuid is invalid"); + if (dbType == DbType.POSTGRESQL && !"uuid".equals(uuidType)) { + throw new SQLException("varchar = uuid is invalid"); + } lookup = statement; - events.add("native-uuid-lookup"); + events.add(dbType == DbType.POSTGRESQL ? "native-uuid-lookup" : "text-uuid-lookup"); return row("1"); } return row(UUID_VALUE.toString()); From 0bbe368448061e2f9cb837e1542817db095a1522 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:59:48 -0600 Subject: [PATCH 40/48] Harden SQL decoding and diagnostics --- .../user/storage/sql/JdbcSqlUserStorage.java | 7 +++++- .../user/storage/sql/SqliteUserBackend.java | 8 +++++- .../sql/JdbcPostgresBitBooleanTest.java | 20 +++++++++++++++ .../JdbcSqlUserStorageWriteOutcomeTest.java | 19 ++++++++++++++ .../SqliteUserEnumerationCallbackTest.java | 25 +++++++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) 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 index 8ca12fad3..e236b7de2 100644 --- 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 @@ -235,6 +235,7 @@ private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDef if (booleanStorage == BooleanStorage.NATIVE) { boolean value = result.getBoolean(index); return new DataValueBoolean(!result.wasNull() && value); } if (booleanStorage == BooleanStorage.NUMERIC) { int value = result.getInt(index); return new DataValueBoolean(!result.wasNull() && value != 0); } String value = result.getString(index); + if (booleanStorage == BooleanStorage.POSTGRES_BIT) return new DataValueBoolean(value != null && value.indexOf('1') >= 0); return new DataValueBoolean("1".equals(value) || "t".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value)); } return new DataValueString(result.getString(index)); @@ -278,5 +279,9 @@ private BooleanStorage booleanStorage(SqlUserSchema.ColumnDefinition definition) } 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) { logger.warn("Failed to " + operation + " for " + uuid, error); return new IllegalStateException("Failed to " + operation + " for " + uuid, error); } + 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/SqliteUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java index 3fe97bba8..b710e84bd 100644 --- 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 @@ -31,6 +31,7 @@ public final class SqliteUserBackend implements SqlUserBackend { 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) { @@ -107,7 +108,12 @@ private List readUserPage(String cursor) { if (value == null) continue; UUID parsed = null; try { parsed = UUID.fromString(value); } - catch (IllegalArgumentException invalid) { logger.warn("Skipping invalid UUID in " + tableName + ": " + value, invalid); } + catch (IllegalArgumentException invalid) { + 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)); } } 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 index 17253ce58..dc28644da 100644 --- 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 @@ -74,4 +74,24 @@ void postgresBitReadsOneAsTrue() throws Exception { 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/JdbcSqlUserStorageWriteOutcomeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/JdbcSqlUserStorageWriteOutcomeTest.java index 06afaf5ef..1124fdc87 100644 --- 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 @@ -102,6 +102,25 @@ class JdbcSqlUserStorageWriteOutcomeTest { 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; 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 index 3ac5b752c..d0f31a681 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java @@ -70,4 +70,29 @@ void nullUuidRowsDoNotShortenTheFirstPaginationPage() throws Exception { 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(); + + assertEquals(List.of(), backend.enumerateUsers()); + } + assertEquals(List.of("Skipping malformed UUID entries while enumerating SQLite users; further diagnostics suppressed"), warnings); + } } From fbcff000f7ad0469bed5266723ce47adb089e847 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:09:47 -0600 Subject: [PATCH 41/48] Preserve legacy SQL write compatibility --- .../user/storage/sql/JdbcSqlUserStorage.java | 9 +++++- .../user/storage/sql/MysqlUserBackend.java | 8 +++++- .../sql/JdbcPostgresBitBooleanTest.java | 22 +++++++++++++++ .../JdbcSqlUserStorageWriteOutcomeTest.java | 9 ++++-- .../sql/MysqlBackendReviewRegressionTest.java | 28 ++++++++++++++++++- 5 files changed, 70 insertions(+), 6 deletions(-) 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 index e236b7de2..bac55cfa8 100644 --- 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 @@ -6,6 +6,7 @@ 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; @@ -244,7 +245,13 @@ private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDef 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()) statement.setInt(index, value.getInt()); + 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); if (booleanStorage == BooleanStorage.NATIVE) statement.setBoolean(index, value.getBoolean()); 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 index c88c37587..cb35f2b81 100644 --- 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 @@ -30,6 +30,7 @@ public final class MysqlUserBackend implements SqlUserBackend { 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; @@ -103,7 +104,12 @@ private List readUserPage(String cursor) { if (value == null) continue; UUID parsed = null; try { parsed = UUID.fromString(value); } - catch (IllegalArgumentException invalid) { logger.warn("Skipping invalid UUID in " + table.getTableName() + ": " + value, invalid); } + catch (IllegalArgumentException invalid) { + 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)); } } 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 index dc28644da..997402724 100644 --- 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 @@ -12,6 +12,7 @@ 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; @@ -21,10 +22,31 @@ 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 postgresBitUsesExplicitBitCastAndStringBindingOnInsert() throws Exception { Connection connection = mock(Connection.class); 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 index 1124fdc87..9341a29f8 100644 --- 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 @@ -17,6 +17,7 @@ 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; @@ -132,7 +133,9 @@ class JdbcSqlUserStorageWriteOutcomeTest { 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()); - verify(jdbc.insert).setInt(2, 17); verify(jdbc.connection).commit(); + if (dialect == JdbcSqlUserStorage.Dialect.POSTGRESQL) verify(jdbc.insert).setObject(2, "17", Types.OTHER); + else verify(jdbc.insert).setInt(2, 17); + verify(jdbc.connection).commit(); } } @@ -145,7 +148,7 @@ class JdbcSqlUserStorageWriteOutcomeTest { 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).setInt(1, 2); verify(statement).setInt(2, 2); verify(statement).setObject(3, USER); + verify(statement).setObject(1, "2", Types.OTHER); verify(statement).setObject(2, "2", Types.OTHER); verify(statement).setObject(3, USER); } @Test void uuidOnlyBulkIsNoOpButExplicitIdentityMutationIsRejected() throws Exception { @@ -160,7 +163,7 @@ class JdbcSqlUserStorageWriteOutcomeTest { @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).setInt(1, 23); verify(jdbc.connection).commit(); verify(row).close(); + 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 { 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 index be82e2d66..b314ae6b0 100644 --- 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 @@ -12,6 +12,7 @@ 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; @@ -23,6 +24,22 @@ /** 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("private-one", "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 postgresRenamesCaseOnlyHistoricalColumnInsteadOfCreatingAParallelColumn() throws Exception { Fixture fixture = new Fixture(DbType.POSTGRESQL, "points"); try (var managers = fixture.managers()) { @@ -104,6 +121,7 @@ private static final class Fixture { int uuidInspections, migrations; boolean competitorConverts; SQLException migrationFailure, reinspectionFailure; + List enumerationRows = List.of(); Fixture(DbType type, String storedColumn) { this.type = type; @@ -120,6 +138,10 @@ MockedConstruction managers() { } MysqlUserBackend open() { + return open(SqlBackendLogger.NO_OP); + } + + MysqlUserBackend open(SqlBackendLogger logger) { MysqlConfig config = new MysqlConfig(); config.setDbType(type); config.setDatabase("test_database"); @@ -127,7 +149,7 @@ MysqlUserBackend open() { config.setTableName("Users"); config.setMaxThreads(1); return new MysqlUserBackend("Users", config, SqlUserSchema.builder() - .column("Points", "INT DEFAULT '0'", DataType.INTEGER).build(), SqlBackendLogger.NO_OP); + .column("Points", "INT DEFAULT '0'", DataType.INTEGER).build(), logger); } Connection connection() throws SQLException { @@ -166,6 +188,10 @@ Connection connection() throws SQLException { 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; }); From 7bdd69086f0ed3afbe1653d866367cf2bd328014 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:24:54 -0600 Subject: [PATCH 42/48] Preserve canonical SQL user identities --- .../user/storage/sql/JdbcSqlUserStorage.java | 2 +- .../user/storage/sql/MysqlUserBackend.java | 6 ++++- .../user/storage/sql/SqliteUserBackend.java | 6 ++++- .../sql/JdbcSqlUserStorageDialectTest.java | 22 +++++++++++++++++++ .../sql/MysqlBackendReviewRegressionTest.java | 2 +- .../SqliteUserEnumerationCallbackTest.java | 2 ++ 6 files changed, 36 insertions(+), 4 deletions(-) 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 index bac55cfa8..78cbcec8f 100644 --- 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 @@ -80,7 +80,7 @@ private enum BooleanStorage { TEXT, NATIVE, NUMERIC, POSTGRES_BIT } String name = metadata.getColumnLabel(i); SqlUserSchema.ColumnDefinition definition = schema.column(name); DataType type = definition == null ? DataType.STRING : definition.dataType(); - Column column = new Column(name, type); + Column column = new Column(definition == null ? name : definition.name(), type); column.setValue(readValue(result, i, definition, type)); columns.add(column); } 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 index cb35f2b81..35607093c 100644 --- 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 @@ -103,8 +103,12 @@ private List readUserPage(String cursor) { String value = result.getString(1); if (value == null) continue; UUID parsed = null; - try { parsed = UUID.fromString(value); } + try { + parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(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")); 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 index b710e84bd..0e2bcd690 100644 --- 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 @@ -107,8 +107,12 @@ private List readUserPage(String cursor) { String value = result.getString(1); if (value == null) continue; UUID parsed = null; - try { parsed = UUID.fromString(value); } + try { + parsed = UUID.fromString(value); + if (!parsed.toString().equalsIgnoreCase(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")); 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 index 9775b80b5..7a1706672 100644 --- 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 @@ -35,6 +35,28 @@ 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(); 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 index b314ae6b0..c51319ddc 100644 --- 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 @@ -26,7 +26,7 @@ class MysqlBackendReviewRegressionTest { @Test void malformedUuidDiagnosticsAreBoundedAndValueFree() throws Exception { Fixture fixture = new Fixture(DbType.MYSQL, "Points"); - fixture.enumerationRows = List.of("private-one", "private-two"); + 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) { } 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 index d0f31a681..19238ac1c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java @@ -90,6 +90,8 @@ void malformedUuidWarningIsBoundedAndOmitsStoredValue() throws Exception { statement.executeUpdate(); statement.setString(1, malformed + "another"); statement.executeUpdate(); + statement.setString(1, "1-1-1-1-1"); + statement.executeUpdate(); assertEquals(List.of(), backend.enumerateUsers()); } From f63dbd4e353ad3fc85bcf0701887dd21e0e720e5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:57:05 -0600 Subject: [PATCH 43/48] Validate retained SQL identity and boolean schemas --- .../user/storage/sql/JdbcSqlUserStorage.java | 11 ++- .../user/storage/sql/SqliteUserBackend.java | 70 +++++++++++++++++++ .../sql/JdbcPostgresBitBooleanTest.java | 20 ++++++ .../SqliteUserBackendCompatibilityTest.java | 54 ++++++++++++++ 4 files changed, 154 insertions(+), 1 deletion(-) 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 index 78cbcec8f..97ad93707 100644 --- 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 @@ -254,7 +254,16 @@ else if (value.isInt()) { } else if (value.isBoolean()) { BooleanStorage booleanStorage = booleanStorage(definition); - if (booleanStorage == BooleanStorage.NATIVE) statement.setBoolean(index, value.getBoolean()); + // 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())); 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 index 0e2bcd690..03f91918d 100644 --- 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 @@ -166,6 +166,17 @@ private void initialize() { } 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 (!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(); @@ -187,6 +198,65 @@ private boolean hasColumn(String name) throws SQLException { } } + 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; 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 index 997402724..8152cc4d2 100644 --- 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 @@ -47,6 +47,26 @@ void postgresIntegerBindingIsTargetTypedForLegacyTextColumns() throws Exception 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); 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 index 7f0f8f630..38a8c7b8d 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java @@ -10,6 +10,7 @@ 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; @@ -105,6 +106,59 @@ void readsLegacyNumericTextAndNullBooleansWithoutMigration() throws Exception { } } + @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 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\""; From 96ef5e0e08ca1f8f425c3f86fb2e682d265bf78a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:43:39 -0600 Subject: [PATCH 44/48] Harden retained SQL schema compatibility --- .../user/storage/sql/JdbcSqlUserStorage.java | 89 ++++++++++++++++--- .../user/storage/sql/MysqlUserBackend.java | 67 ++++++++++++++ .../user/storage/sql/SqliteUserBackend.java | 18 ++++ .../sql/JdbcPostgresBitBooleanTest.java | 39 ++++++++ .../SqliteUserBackendCompatibilityTest.java | 24 +++++ 5 files changed, 226 insertions(+), 11 deletions(-) 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 index 97ad93707..385667ed0 100644 --- 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 @@ -169,11 +169,12 @@ private void committedCleanupFailure(String operation, Exception error) { /** @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(schema.column(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 + ")"; @@ -182,7 +183,7 @@ private boolean ensureRow(Connection connection, Map updates) 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(), schema.column(entry.getKey())); + 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; @@ -204,18 +205,18 @@ private boolean rowExists(Connection connection) throws SQLException { } 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; - SqlUserSchema.ColumnDefinition definition = schema.column(key); - sql.append(quote(key)).append('=').append(parameterExpression(definition)); + 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(), schema.column(entry.getKey())); + for (Map.Entry entry : updates.entrySet()) bind(statement, index++, entry.getValue(), definitions.get(entry.getKey())); dialect.bindUuid(statement, index, uuid); statement.executeUpdate(); } } @@ -270,26 +271,92 @@ else if (value.isBoolean()) { } else statement.setObject(index, value.toString()); } + private BooleanStorage booleanStorage(String sqlType) { + String normalized = sqlType.strip().toUpperCase(Locale.ROOT); + if (startsType(normalized, "BOOLEAN") || startsType(normalized, "BOOL")) return BooleanStorage.NATIVE; + if (startsType(normalized, "BIT") || startsType(normalized, "VARBIT")) return dialect == Dialect.POSTGRESQL ? BooleanStorage.POSTGRES_BIT : BooleanStorage.NUMERIC; + if (startsType(normalized, "TINYINT") || startsType(normalized, "SMALLINT") || startsType(normalized, "MEDIUMINT") || startsType(normalized, "INT") || startsType(normalized, "INTEGER") || startsType(normalized, "BIGINT")) return BooleanStorage.NUMERIC; + return BooleanStorage.TEXT; + } + private String parameterExpression(SqlUserSchema.ColumnDefinition definition) { - if (booleanStorage(definition) != BooleanStorage.POSTGRES_BIT) return "?"; + 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 || dialect != Dialect.POSTGRESQL) return definition; + java.sql.DatabaseMetaData metadata = connection.getMetaData(); + if (metadata == null) return definition; + try (ResultSet columns = metadata.getColumns(null, metadataSchema(connection), tableName, definition.name())) { + if (columns.next()) { + String type = columns.getString("TYPE_NAME"); + if (type != null && !type.isBlank()) { + String normalized = type.strip().toUpperCase(Locale.ROOT); + if (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; + } + + private boolean postgresVaryingBit(String normalizedType) { + return startsType(normalizedType, "VARBIT") || normalizedType.matches("^BIT\\s+VARYING(?:\\(\\d+\\))?(?:\\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(); - int separator = sqlType.indexOf(' '); - String token = separator < 0 ? sqlType : sqlType.substring(0, separator); - if (!token.matches("(?i)BIT(?:\\(\\d+\\))?")) { + java.util.regex.Matcher type = java.util.regex.Pattern + .compile("(?i)^(BIT(?:\\(\\d+\\)|\\s+VARYING(?:\\(\\d+\\))?)?)(?:\\s+.*)?$") + .matcher(sqlType); + if (!type.matches()) { throw new IllegalArgumentException("Unsupported PostgreSQL bit type: " + definition.sqlType()); } - return token.toUpperCase(Locale.ROOT); + return type.group(1).toUpperCase(Locale.ROOT); } 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")) return dialect == Dialect.POSTGRESQL ? BooleanStorage.POSTGRES_BIT : BooleanStorage.NUMERIC; + 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; } 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 index 35607093c..bdf81add9 100644 --- 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 @@ -8,6 +8,7 @@ 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; @@ -146,6 +147,7 @@ private T withOperation(Supplier operation) { private void ensureRegisteredColumns() { table.ensureUuidType(); + table.ensureUuidUnique(); for (SqlUserSchema.ColumnDefinition column : schema.columns()) if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column); } @@ -208,6 +210,71 @@ void ensureUuidType() { } 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 { 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 index 03f91918d..ee8addc1e 100644 --- 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 @@ -174,6 +174,9 @@ private void ensureRegisteredColumns() throws SQLException { 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"); } @@ -198,6 +201,21 @@ private boolean hasColumn(String name) throws SQLException { } } + 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) + ")"; 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 index 8152cc4d2..7d48174de 100644 --- 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 @@ -9,6 +9,7 @@ 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; @@ -95,6 +96,44 @@ void postgresBitUsesExplicitBitCastAndStringBindingOnInsert() throws Exception { verify(statements.get(1), never()).setInt(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 postgresBitReadsOneAsTrue() throws Exception { Connection connection = mock(Connection.class); 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 index 38a8c7b8d..0b8398c2a 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserBackendCompatibilityTest.java @@ -146,6 +146,30 @@ void acceptsPreexistingUuidUniqueIndex() throws Exception { } } + @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()) { From 643d0fe7fef3d352fc749202c7013cee8c51ad88 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:18:52 -0600 Subject: [PATCH 45/48] Harden SQL metadata type handling --- .../user/storage/sql/JdbcSqlUserStorage.java | 54 ++++++++---- .../user/storage/sql/MysqlUserBackend.java | 10 ++- .../sql/JdbcPostgresBitBooleanTest.java | 86 +++++++++++++++++++ .../sql/JdbcSqlUserStorageDialectTest.java | 9 +- .../MysqlUserBackendSchemaExpansionTest.java | 24 +++++- 5 files changed, 154 insertions(+), 29 deletions(-) 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 index 385667ed0..9d7d382b7 100644 --- 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 @@ -81,7 +81,7 @@ private enum BooleanStorage { TEXT, NATIVE, NUMERIC, POSTGRES_BIT } 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, definition, type)); + column.setValue(readValue(result, i, type)); columns.add(column); } return columns; @@ -223,7 +223,7 @@ private void updateValues(Connection connection, Map updates) private void requireStorage(UserStorage requestedStorage) { if (requestedStorage != storage) throw new IllegalArgumentException("Storage mismatch: backend=" + storage + ", requested=" + requestedStorage); } - private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDefinition definition, DataType type) throws SQLException { + 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) { @@ -233,12 +233,17 @@ private DataValue readValue(ResultSet result, int index, SqlUserSchema.ColumnDef } } if (type == DataType.BOOLEAN) { - BooleanStorage booleanStorage = booleanStorage(definition); - if (booleanStorage == BooleanStorage.NATIVE) { boolean value = result.getBoolean(index); return new DataValueBoolean(!result.wasNull() && value); } - if (booleanStorage == BooleanStorage.NUMERIC) { int value = result.getInt(index); return new DataValueBoolean(!result.wasNull() && value != 0); } String value = result.getString(index); - if (booleanStorage == BooleanStorage.POSTGRES_BIT) return new DataValueBoolean(value != null && value.indexOf('1') >= 0); - return new DataValueBoolean("1".equals(value) || "t".equalsIgnoreCase(value) || "true".equalsIgnoreCase(value)); + 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)); } @@ -271,14 +276,6 @@ else if (value.isBoolean()) { } else statement.setObject(index, value.toString()); } - private BooleanStorage booleanStorage(String sqlType) { - String normalized = sqlType.strip().toUpperCase(Locale.ROOT); - if (startsType(normalized, "BOOLEAN") || startsType(normalized, "BOOL")) return BooleanStorage.NATIVE; - if (startsType(normalized, "BIT") || startsType(normalized, "VARBIT")) return dialect == Dialect.POSTGRESQL ? BooleanStorage.POSTGRES_BIT : BooleanStorage.NUMERIC; - if (startsType(normalized, "TINYINT") || startsType(normalized, "SMALLINT") || startsType(normalized, "MEDIUMINT") || startsType(normalized, "INT") || startsType(normalized, "INTEGER") || startsType(normalized, "BIGINT")) return BooleanStorage.NUMERIC; - return BooleanStorage.TEXT; - } - private String parameterExpression(SqlUserSchema.ColumnDefinition definition) { BooleanStorage storage = booleanStorage(definition); if (storage != BooleanStorage.POSTGRES_BIT) return "?"; @@ -298,7 +295,8 @@ private SqlUserSchema.ColumnDefinition retainedDefinition(Connection connection, if (definition == null || definition.dataType() != DataType.BOOLEAN || dialect != Dialect.POSTGRESQL) return definition; java.sql.DatabaseMetaData metadata = connection.getMetaData(); if (metadata == null) return definition; - try (ResultSet columns = metadata.getColumns(null, metadataSchema(connection), tableName, definition.name())) { + try (ResultSet columns = metadata.getColumns(null, 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()) { @@ -324,8 +322,22 @@ private SqlUserSchema.ColumnDefinition retainedDefinition(Connection connection, 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(?:\\(\\d+\\))?(?:\\s+.*)?$"); + return startsType(normalizedType, "VARBIT") || normalizedType.matches("^BIT\\s+VARYING(?:\\s*\\(\\s*\\d+\\s*\\))?(?:\\s+.*)?$"); } private String metadataSchema(Connection connection) throws SQLException { @@ -343,13 +355,17 @@ private String metadataSchema(Connection connection) throws SQLException { 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(?:\\(\\d+\\)|\\s+VARYING(?:\\(\\d+\\))?)?)(?:\\s+.*)?$") + .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).toUpperCase(Locale.ROOT); + return (type.group(1) == null ? "BIT" : "BIT VARYING") + + (type.group(2) == null ? "" : "(" + type.group(2) + ")"); } private BooleanStorage booleanStorage(SqlUserSchema.ColumnDefinition definition) { 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 index bdf81add9..4a0db6817 100644 --- 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 @@ -314,13 +314,15 @@ private void rememberColumn(SqlUserSchema.ColumnDefinition column) { 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 foldedMatch = null; + 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)) return storedName; - if (foldedMatch == null && name.equalsIgnoreCase(storedName)) foldedMatch = storedName; + if (name.equals(storedName)) exactMatch = storedName; + if (name.equalsIgnoreCase(storedName)) { foldedMatches++; if (foldedMatch == null) foldedMatch = storedName; } } - return foldedMatch; + if (getDbType() == DbType.POSTGRESQL && foldedMatches > 1) + throw new SQLException("Ambiguous case-folded SQL columns for registered name: " + name); + return exactMatch == null ? foldedMatch : exactMatch; } } 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 index 7d48174de..3e114aa1d 100644 --- 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 @@ -96,6 +96,54 @@ void postgresBitUsesExplicitBitCastAndStringBindingOnInsert() throws Exception { 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); @@ -134,6 +182,44 @@ void postgresRetainsResolvedSchemaVarbitWidthForBooleanWrites() throws Exception 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); 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 index 7a1706672..4fd88219b 100644 --- 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 @@ -100,7 +100,7 @@ class JdbcSqlUserStorageDialectTest { } } - @Test void numericBooleanDefinitionsUseNumericJdbcBindingsAndReads() throws Exception { + @Test void booleanReadsUseARepresentationIndependentParser() throws Exception { Connection writeConnection = mock(Connection.class); when(writeConnection.getAutoCommit()).thenReturn(true); PreparedStatement exists = mock(PreparedStatement.class); @@ -125,10 +125,15 @@ class JdbcSqlUserStorageDialectTest { when(result.getMetaData()).thenReturn(metadata); when(metadata.getColumnCount()).thenReturn(1); when(metadata.getColumnLabel(1)).thenReturn("Flag"); - when(result.getInt(1)).thenReturn(1); + // 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 backendUsesConnectionManagerDialectAndExistingUuidSchemaType() throws Exception { 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 index 9cdcfbb12..153679b9a 100644 --- 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 @@ -51,6 +51,19 @@ private void checkTypes(DbType type) throws Exception { 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 failedAddRejectsInitializationAndClosesThePool() throws Exception { Fixture fixture = new Fixture(DbType.POSTGRESQL); fixture.addFailure = new SQLException("DDL denied"); @@ -94,14 +107,17 @@ MockedConstruction managers() { } 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); - SqlUserSchema schema = SqlUserSchema.builder() - .column("Player Name", "VARCHAR(30)", DataType.STRING) - .column("History", "MEDIUMTEXT", DataType.STRING) - .column("Votes", "INT DEFAULT '0'", DataType.INTEGER).build(); return new MysqlUserBackend("Users", config, schema, SqlBackendLogger.NO_OP); } From 8ef1a22a5d7aed344c2a682aa77389641af82f9a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:29:53 -0600 Subject: [PATCH 46/48] Honor retained MySQL storage types --- .../user/storage/sql/JdbcSqlUserStorage.java | 8 +++-- .../user/storage/sql/MysqlUserBackend.java | 5 ++- .../sql/JdbcSqlUserStorageDialectTest.java | 31 +++++++++++++++++++ .../MysqlUserBackendLifecycleReviewTest.java | 2 ++ 4 files changed, 42 insertions(+), 4 deletions(-) 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 index 9d7d382b7..3fde49890 100644 --- 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 @@ -292,16 +292,18 @@ private Map retainedDefinitions(Connecti } private SqlUserSchema.ColumnDefinition retainedDefinition(Connection connection, SqlUserSchema.ColumnDefinition definition) throws SQLException { - if (definition == null || definition.dataType() != DataType.BOOLEAN || dialect != Dialect.POSTGRESQL) return definition; + if (definition == null || definition.dataType() != DataType.BOOLEAN) return definition; java.sql.DatabaseMetaData metadata = connection.getMetaData(); if (metadata == null) return definition; - try (ResultSet columns = metadata.getColumns(null, metadataPattern(metadata, metadataSchema(connection)), + 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 (startsType(normalized, "BIT") || startsType(normalized, "VARBIT")) { + 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 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 index 4a0db6817..b12b5ab08 100644 --- 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 @@ -90,7 +90,10 @@ public SqlUserStorage user(UUID uuid) { private List readUserPage(String cursor) { String uuidColumn = table.quote(SqlUserSchema.UUID_COLUMN); - String sql = "SELECT " + uuidColumn + " FROM " + table.quote(table.getTableName()) + (cursor == null ? "" : " WHERE " + uuidColumn + " > ?") + " ORDER BY " + uuidColumn + " ASC LIMIT ?"; + 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; 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 index 4fd88219b..5a5cf6a04 100644 --- 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 @@ -5,6 +5,7 @@ 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; @@ -13,6 +14,7 @@ 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; @@ -136,6 +138,35 @@ class JdbcSqlUserStorageDialectTest { 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(); 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 index 367ac8671..5b26986f1 100644 --- 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 @@ -83,6 +83,8 @@ void closeRejectsNewWorkAndWaitsForActiveEnumerationCallbacks() throws Exception 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(); From 3bf33054e130e5407fce393035698531f80574e7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:50:48 -0600 Subject: [PATCH 47/48] Harden retained SQL schema migration --- .../user/storage/sql/MysqlUserBackend.java | 59 +++++++++++++++++++ .../user/storage/sql/SqliteUserBackend.java | 2 +- .../MysqlUserBackendSchemaExpansionTest.java | 40 +++++++++++++ .../SqliteUserEnumerationCallbackTest.java | 16 +++++ 4 files changed, 116 insertions(+), 1 deletion(-) 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 index b12b5ab08..d290bd2db 100644 --- 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 @@ -283,6 +283,8 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { try { String storedName = findRegisteredColumn(column.name()); if (storedName != null) { + if (getDbType() == DbType.POSTGRESQL && column.dataType() == DataType.STRING) + migratePostgresNumericColumnToString(storedName, column); if (getDbType() == DbType.POSTGRESQL && !storedName.equals(column.name())) renamePostgresColumn(storedName, column.name()); rememberColumn(column); return; } @@ -310,6 +312,63 @@ private void renamePostgresColumn(String storedName, String requestedName) throw catch (SQLException renameFailure) { String current = findRegisteredColumn(requestedName); if (!requestedName.equals(current)) throw renameFailure; } } + private void migratePostgresNumericColumnToString(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) return; + String column = quote(storedName); + 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 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()); 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 index ee8addc1e..5a7ed8b44 100644 --- 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 @@ -109,7 +109,7 @@ private List readUserPage(String cursor) { UUID parsed = null; try { parsed = UUID.fromString(value); - if (!parsed.toString().equalsIgnoreCase(value)) throw new IllegalArgumentException("Non-canonical UUID"); + if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Non-canonical UUID"); } catch (IllegalArgumentException invalid) { parsed = null; 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 index 153679b9a..297fd5243 100644 --- 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 @@ -64,6 +64,32 @@ private void checkTypes(DbType type) throws Exception { 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 failedAddRejectsInitializationAndClosesThePool() throws Exception { Fixture fixture = new Fixture(DbType.POSTGRESQL); fixture.addFailure = new SQLException("DDL denied"); @@ -91,6 +117,8 @@ 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 java.util.Map columnDefaults = new java.util.HashMap<>(); final List connections = new ArrayList<>(); final List statements = new ArrayList<>(); final List results = new ArrayList<>(); @@ -129,6 +157,11 @@ Connection connection() throws SQLException { 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; @@ -143,12 +176,19 @@ Connection connection() throws SQLException { 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 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( + numericColumns.contains(existing.get(i)) ? java.sql.Types.INTEGER : java.sql.Types.VARCHAR); } } return result; 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 index 19238ac1c..42b2c620a 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserEnumerationCallbackTest.java @@ -97,4 +97,20 @@ void malformedUuidWarningIsBoundedAndOmitsStoredValue() throws Exception { } 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()); + } + } } From c91e273819a4fe43364567b2ba7b12b2470819a3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:21:00 -0600 Subject: [PATCH 48/48] Preserve retained SQL column attributes --- .../user/storage/sql/MysqlUserBackend.java | 68 +++++++++++++++++-- .../sql/MysqlBackendReviewRegressionTest.java | 10 +++ .../sql/MysqlConcurrentSchemaTest.java | 3 +- .../MysqlUserBackendSchemaExpansionTest.java | 48 ++++++++++++- 4 files changed, 121 insertions(+), 8 deletions(-) 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 index d290bd2db..24cd41307 100644 --- 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 @@ -109,7 +109,7 @@ private List readUserPage(String cursor) { UUID parsed = null; try { parsed = UUID.fromString(value); - if (!parsed.toString().equalsIgnoreCase(value)) throw new IllegalArgumentException("Non-canonical UUID"); + if (!parsed.toString().equals(value)) throw new IllegalArgumentException("Non-canonical UUID"); } catch (IllegalArgumentException invalid) { parsed = null; @@ -283,8 +283,8 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { try { String storedName = findRegisteredColumn(column.name()); if (storedName != null) { - if (getDbType() == DbType.POSTGRESQL && column.dataType() == DataType.STRING) - migratePostgresNumericColumnToString(storedName, column); + if (column.dataType() == DataType.STRING) + migrateRetainedColumnToString(storedName, column); if (getDbType() == DbType.POSTGRESQL && !storedName.equals(column.name())) renamePostgresColumn(storedName, column.name()); rememberColumn(column); return; } @@ -295,6 +295,8 @@ void ensureColumn(SqlUserSchema.ColumnDefinition column) { 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); @@ -312,15 +314,36 @@ private void renamePostgresColumn(String storedName, String requestedName) throw catch (SQLException renameFailure) { String current = findRegisteredColumn(requestedName); if (!requestedName.equals(current)) throw renameFailure; } } - private void migratePostgresNumericColumnToString(String storedName, + 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) return; + && 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 @@ -338,6 +361,41 @@ private void migratePostgresNumericColumnToString(String storedName, } } + 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) " 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 index c51319ddc..3a3d8e28a 100644 --- 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 @@ -40,6 +40,16 @@ class MysqlBackendReviewRegressionTest { 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()) { 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 index 6ed54e311..26a4cdab0 100644 --- 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 @@ -28,7 +28,8 @@ private void concurrent(DbType type) throws Exception { fixture.columnAppears = true; try (var managers = fixture.managers(); var backend = fixture.open()) { assertTrue(backend.isOpen()); - assertEquals(2, fixture.inspections); + 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(); } 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 index 297fd5243..94bcedc28 100644 --- 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 @@ -90,6 +90,40 @@ private void checkTypes(DbType type) throws Exception { 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"); @@ -118,6 +152,8 @@ private static final class Fixture { 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<>(); @@ -176,6 +212,13 @@ Connection connection() throws SQLException { 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); @@ -187,8 +230,9 @@ Connection connection() throws SQLException { 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( - numericColumns.contains(existing.get(i)) ? java.sql.Types.INTEGER : java.sql.Types.VARCHAR); + 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;