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