diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java index 8204df656..4348de0d8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java @@ -633,7 +633,7 @@ public UserDataManager(AdvancedCorePlugin plugin) { }, 60 * 3, 60 * 60, TimeUnit.SECONDS); } - public void addKey(UserDataKey userDataKey) { + public synchronized void addKey(UserDataKey userDataKey) { keys.add(userDataKey); if (userDataKey instanceof UserDataKeyInt) intColumns.add(userDataKey.getKey()); else if (userDataKey instanceof UserDataKeyBoolean) booleanColumns.add(userDataKey.getKey()); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java index bef301a5d..d53dc4a9f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java @@ -12,9 +12,12 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.MysqlUserBackend; import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackendFactory; @@ -34,7 +37,9 @@ public final class BukkitSqlUserBackend implements SqlUserBackend { private final MySQL mysql; private final UserTable table; private final Object sqliteOperations = new Object(); + private final Object mysqlSchemaOperations = new Object(); private final AtomicBoolean open = new AtomicBoolean(true); + private volatile String reconciledMysqlSchemaSignature = ""; public BukkitSqlUserBackend(AdvancedCorePlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -151,8 +156,10 @@ private void write(UserStorage storage, UUID uuid, String key, DataValue value) Objects.requireNonNull(value, "value"); requireOpen(); requireStorage(storage); - if (storage == UserStorage.MYSQL) mysql().update(uuid.toString(), key, value); - else synchronized (sqliteOperations) { table().update(primary(uuid), new ArrayList<>(List.of(new Column(key, value)))); } + if (storage == UserStorage.MYSQL) { + reconcileMysqlSchema(registeredSchema(), sqlLogger()); + mysql().update(uuid.toString(), key, value); + } else synchronized (sqliteOperations) { table().update(primary(uuid), new ArrayList<>(List.of(new Column(key, value)))); } } private void writeValues(UserStorage storage, UUID uuid, HashMap values) { @@ -204,7 +211,7 @@ private static boolean containsPlayerName(java.util.Map value private T withSqlUser(UserStorage storage, UUID uuid, java.util.Map updates, java.util.function.Function work) { - SqlUserSchema registered = SqlUserSchema.fromKeys(plugin.getUserManager().getDataManager().getKeys()); + SqlUserSchema registered = registeredSchema(); SqlUserSchema.Builder builder = SqlUserSchema.builder(); for (SqlUserSchema.ColumnDefinition column : registered.columns()) { if (!"uuid".equalsIgnoreCase(column.name())) builder.column(column.name(), column.sqlType(), column.dataType()); @@ -218,13 +225,9 @@ private T withSqlUser(UserStorage storage, UUID uuid, java.util.Map T withSqlUser(UserStorage storage, UUID uuid, java.util.Map keys; + synchronized (dataManager) { + keys = new ArrayList<>(dataManager.getKeys()); + } + return SqlUserSchema.fromKeys(keys); + } + + private SqlBackendLogger sqlLogger() { + return new SqlBackendLogger() { + @Override public void info(String message) { plugin.getLogger().info(message); } + @Override public void warn(String message, Throwable error) { + plugin.getLogger().warning(message + (error == null ? "" : ": " + error.getMessage())); + } + }; + } + + private void reconcileMysqlSchema(SqlUserSchema schema, SqlBackendLogger logger) { + String signature = schemaSignature(schema); + if (signature.equals(reconciledMysqlSchemaSignature)) return; + synchronized (mysqlSchemaOperations) { + if (signature.equals(reconciledMysqlSchemaSignature)) return; + MysqlUserBackend.reconcileExistingTable(mysql().getTableName(), mysql().getMysql(), schema, logger); + reconciledMysqlSchemaSignature = signature; + } + } + + private static String schemaSignature(SqlUserSchema schema) { + StringBuilder signature = new StringBuilder(); + for (SqlUserSchema.ColumnDefinition column : schema.columns()) { + signature.append(column.name().toLowerCase(java.util.Locale.ROOT)).append('\u0000') + .append(column.sqlType()).append('\u0000').append(column.dataType().name()).append('\u0001'); + } + return signature.toString(); + } + private Column primary(UUID uuid) { return new Column("uuid", new DataValueString(uuid.toString())); } private MySQL mysql() { return requireMysql(); } private UserTable table() { return requireTable(); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java index 3c2837aea..0e24a6af2 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java @@ -152,7 +152,25 @@ private T withOperation(Supplier operation) { } finally { operations.readLock().unlock(); } } + /** + * Reconcile a platform-owned MySQL/MariaDB/PostgreSQL user table without + * taking ownership of its connection pool. + */ + public static void reconcileExistingTable(String tableName, + com.bencodez.simpleapi.sql.mysql.MySQL mysql, SqlUserSchema schema, SqlBackendLogger logger) { + Objects.requireNonNull(tableName, "tableName"); + Objects.requireNonNull(mysql, "mysql"); + Objects.requireNonNull(schema, "schema"); + HeadlessUserTable table = new HeadlessUserTable(tableName, mysql, schema, + logger == null ? SqlBackendLogger.NO_OP : logger); + ensureRegisteredColumns(table, schema); + } + private void ensureRegisteredColumns() { + ensureRegisteredColumns(table, schema); + } + + private static void ensureRegisteredColumns(HeadlessUserTable table, SqlUserSchema schema) { table.ensureUuidType(); table.ensureUuidUnique(); for (SqlUserSchema.ColumnDefinition column : schema.columns()) if (!SqlUserSchema.UUID_COLUMN.equalsIgnoreCase(column.name())) table.ensureColumn(column); @@ -178,6 +196,18 @@ private static final class HeadlessUserTable extends AbstractSqlTable { } } + /** Borrow an existing pool; reconciliation must never close or replace it. */ + HeadlessUserTable(String tableName, com.bencodez.simpleapi.sql.mysql.MySQL mysql, + SqlUserSchema schema, SqlBackendLogger logger) { + super(tableName, mysql, true); + this.schema = schema; + this.logger = logger; + // Reconciliation needs table DDL and live schema inspection only. Calling + // init() here would also load the borrowed table's complete primary-key + // cache, needlessly enumerating every user during a schema check. + ensureTable(); + } + @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(" ("); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBorrowedSchemaReconciliationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBorrowedSchemaReconciliationTest.java new file mode 100644 index 000000000..bfde21482 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlBorrowedSchemaReconciliationTest.java @@ -0,0 +1,217 @@ +package com.bencodez.advancedcore.core.user.storage.sql; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.sql.DataType; +import com.bencodez.simpleapi.sql.mysql.ConnectionManager; +import com.bencodez.simpleapi.sql.mysql.DbType; + +class MysqlBorrowedSchemaReconciliationTest { + @Test + void proxyFirstUuidOnlyTableGetsVotingPluginColumnsWithRegisteredDefinitions() throws Exception { + Fixture fixture = new Fixture(); + SqlUserSchema schema = votingPluginSchema(); + + MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, schema, SqlBackendLogger.NO_OP); + + assertTrue(fixture.sql.contains( + "CREATE TABLE IF NOT EXISTS `VotingPlugin_Users` (`uuid` VARCHAR(37), `LastVotes` TEXT, `VoteRemindersMap` LONGTEXT, PRIMARY KEY (`uuid`));")); + assertTrue(fixture.sql.contains( + "ALTER TABLE `VotingPlugin_Users` ADD COLUMN `LastVotes` TEXT;")); + assertTrue(fixture.sql.contains( + "ALTER TABLE `VotingPlugin_Users` ADD COLUMN `VoteRemindersMap` LONGTEXT;")); + assertEquals(List.of("uuid", "LastVotes", "VoteRemindersMap"), fixture.existing); + assertFalse(fixture.sql.stream().anyMatch(sql -> + sql.startsWith("SELECT `uuid` FROM `VotingPlugin_Users`")), + "schema-only reconciliation must not enumerate every user row"); + + long alters = fixture.sql.stream().filter(sql -> sql.startsWith("ALTER TABLE")).count(); + MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, schema, SqlBackendLogger.NO_OP); + assertEquals(alters, fixture.sql.stream().filter(sql -> sql.startsWith("ALTER TABLE")).count(), + "restarting against an already-correct schema must be idempotent"); + + fixture.assertBorrowedPoolOpen(); + fixture.assertJdbcClosed(); + } + + @Test + void partiallyExistingRegisteredSchemaAddsOnlyTheMissingColumn() throws Exception { + Fixture fixture = new Fixture(); + fixture.existing.add("LastVotes"); + + MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, + votingPluginSchema(), SqlBackendLogger.NO_OP); + + assertFalse(fixture.sql.contains( + "ALTER TABLE `VotingPlugin_Users` ADD COLUMN `LastVotes` TEXT;")); + assertTrue(fixture.sql.contains( + "ALTER TABLE `VotingPlugin_Users` ADD COLUMN `VoteRemindersMap` LONGTEXT;")); + assertEquals(List.of("uuid", "LastVotes", "VoteRemindersMap"), fixture.existing); + fixture.assertBorrowedPoolOpen(); + fixture.assertJdbcClosed(); + } + + @Test + void duplicateColumnRaceIsAcceptedOnlyAfterPhysicalRecheck() throws Exception { + Fixture fixture = new Fixture(); + fixture.addFailure = new SQLException("duplicate column", "42S21", 1060); + fixture.columnAppearsOnFailure = true; + + assertDoesNotThrow(() -> MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, + SqlUserSchema.builder().column("VoteRemindersMap", "LONGTEXT", DataType.STRING).build(), + SqlBackendLogger.NO_OP)); + assertTrue(fixture.existing.contains("VoteRemindersMap")); + fixture.assertBorrowedPoolOpen(); + fixture.assertJdbcClosed(); + } + + @Test + void duplicateColumnResponseWithoutPhysicalColumnStillFails() throws Exception { + Fixture fixture = new Fixture(); + fixture.addFailure = new SQLException("duplicate column", "42S21", 1060); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, + SqlUserSchema.builder().column("VoteRemindersMap", "LONGTEXT", DataType.STRING).build(), + SqlBackendLogger.NO_OP)); + + assertSame(fixture.addFailure, failure.getCause()); + fixture.assertBorrowedPoolOpen(); + fixture.assertJdbcClosed(); + } + + @Test + void unrelatedDdlFailureRemainsFatalEvenIfColumnAppears() throws Exception { + Fixture fixture = new Fixture(); + fixture.addFailure = new SQLException("permission denied", "42000", 1142); + fixture.columnAppearsOnFailure = true; + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> MysqlUserBackend.reconcileExistingTable("VotingPlugin_Users", fixture.mysql, + SqlUserSchema.builder().column("VoteRemindersMap", "LONGTEXT", DataType.STRING).build(), + SqlBackendLogger.NO_OP)); + + assertSame(fixture.addFailure, failure.getCause()); + fixture.assertBorrowedPoolOpen(); + fixture.assertJdbcClosed(); + } + + private static SqlUserSchema votingPluginSchema() { + return SqlUserSchema.builder() + .column("LastVotes", "TEXT", DataType.STRING) + .column("VoteRemindersMap", "LONGTEXT", DataType.STRING) + .build(); + } + + private static final class Fixture { + final com.bencodez.simpleapi.sql.mysql.MySQL mysql = + mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); + final ConnectionManager manager = mock(ConnectionManager.class); + final List existing = new ArrayList<>(List.of("uuid")); + final List sql = new ArrayList<>(); + final List connections = new ArrayList<>(); + final List statements = new ArrayList<>(); + final List results = new ArrayList<>(); + SQLException addFailure; + boolean columnAppearsOnFailure; + + Fixture() throws SQLException { + when(mysql.getConnectionManager()).thenReturn(manager); + when(manager.getDbType()).thenReturn(DbType.MARIADB); + when(manager.getConnection()).thenAnswer(ignored -> connection()); + } + + private Connection connection() throws SQLException { + Connection connection = mock(Connection.class); + connections.add(connection); + when(connection.prepareStatement(anyString())).thenAnswer(call -> { + String query = call.getArgument(0, String.class); + sql.add(query); + PreparedStatement statement = mock(PreparedStatement.class); + statements.add(statement); + when(statement.executeUpdate()).thenAnswer(ignored -> { + if (query.contains(" ADD COLUMN ")) { + String column = addedColumn(query); + if (addFailure != null) { + if (columnAppearsOnFailure && existing.stream().noneMatch(column::equalsIgnoreCase)) { + existing.add(column); + } + throw addFailure; + } + if (existing.stream().noneMatch(column::equalsIgnoreCase)) existing.add(column); + } + return 0; + }); + when(statement.executeQuery()).thenAnswer(ignored -> resultFor(query)); + return statement; + }); + return connection; + } + + private ResultSet resultFor(String query) throws SQLException { + 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("varchar"); + when(result.getString("DATA_TYPE")).thenReturn("varchar"); + when(result.getObject("CHARACTER_MAXIMUM_LENGTH")).thenReturn(37L); + when(result.getString("COLUMN_DEFAULT")).thenReturn(null); + } else if (query.endsWith("WHERE 1=0")) { + ResultSetMetaData metadata = mock(ResultSetMetaData.class); + when(result.getMetaData()).thenReturn(metadata); + when(metadata.getColumnCount()).thenAnswer(ignored -> existing.size()); + for (int i = 0; i < existing.size(); i++) { + int column = i + 1; + when(metadata.getColumnName(column)).thenAnswer(ignored -> existing.get(column - 1)); + when(metadata.getColumnType(column)).thenReturn(Types.VARCHAR); + } + } + return result; + } + + private String addedColumn(String query) { + String marker = " ADD COLUMN `"; + int start = query.indexOf(marker); + if (start < 0) throw new AssertionError("Missing ADD COLUMN marker: " + query); + start += marker.length(); + int end = query.indexOf('`', start); + if (end < 0) throw new AssertionError("Missing closing identifier quote: " + query); + return query.substring(start, end); + } + + void assertBorrowedPoolOpen() { + verify(mysql, never()).disconnect(); + verify(manager, never()).close(); + } + + void assertJdbcClosed() throws SQLException { + for (Connection connection : connections) verify(connection, atLeastOnce()).close(); + for (PreparedStatement statement : statements) verify(statement, atLeastOnce()).close(); + for (ResultSet result : results) verify(result, atLeastOnce()).close(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserBackendSchemaReconciliationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserBackendSchemaReconciliationTest.java new file mode 100644 index 000000000..0117a7573 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserBackendSchemaReconciliationTest.java @@ -0,0 +1,143 @@ +package com.bencodez.advancedcore.tests.storage; + +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.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +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.util.ArrayList; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey; +import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyString; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend; +import com.bencodez.advancedcore.core.user.storage.sql.MysqlUserBackend; +import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackendFactory; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.simpleapi.sql.mysql.ConnectionManager; +import com.bencodez.simpleapi.sql.mysql.DbType; + +class BukkitSqlUserBackendSchemaReconciliationTest { + @Test + void registeredSchemaFailureStopsBulkWriteBeforeSharedSqlIsAdmitted() { + Fixture fixture = new Fixture(); + IllegalStateException expected = new IllegalStateException("schema fixture"); + + try (MockedStatic reconciler = mockStatic(MysqlUserBackend.class); + MockedStatic factory = mockStatic(SqlUserBackendFactory.class)) { + reconciler.when(() -> MysqlUserBackend.reconcileExistingTable( + eq("VotingPlugin_Users"), same(fixture.pooled), + argThat(schema -> schema.contains("LastVotes") && schema.contains("VoteRemindersMap")), + any(SqlBackendLogger.class))).thenThrow(expected); + + HashMap values = new HashMap<>(); + values.put("LastVotes", new DataValueString("{}")); + + IllegalStateException actual = assertThrows(IllegalStateException.class, + () -> fixture.backend().user(UUID.randomUUID()).writeValues(UserStorage.MYSQL, values)); + + assertSame(expected, actual); + factory.verifyNoInteractions(); + verify(fixture.mysql, never()).checkColumn(anyString(), any()); + } + } + + @Test + void unchangedSchemaIsCachedButLateRegisteredKeyForcesReconciliation() { + Fixture fixture = new Fixture(); + UUID uuid = UUID.randomUUID(); + BukkitSqlUserBackend backend = fixture.backend(); + + try (MockedStatic reconciler = mockStatic(MysqlUserBackend.class)) { + backend.user(uuid).write(UserStorage.MYSQL, "LastVotes", new DataValueString("{}")); + backend.user(uuid).write(UserStorage.MYSQL, "LastVotes", new DataValueString("{site:1}")); + + reconciler.verify(() -> MysqlUserBackend.reconcileExistingTable( + eq("VotingPlugin_Users"), same(fixture.pooled), + argThat(schema -> schema.contains("LastVotes") && schema.contains("VoteRemindersMap") + && !schema.contains("VoteStreakProgress_daily")), + any(SqlBackendLogger.class)), times(1)); + + fixture.keys.add(new UserDataKeyString("VoteStreakProgress_daily").setColumnType("LONGTEXT")); + backend.user(uuid).write(UserStorage.MYSQL, "VoteStreakProgress_daily", new DataValueString("2")); + + reconciler.verify(() -> MysqlUserBackend.reconcileExistingTable( + eq("VotingPlugin_Users"), same(fixture.pooled), + argThat(schema -> schema.contains("LastVotes") && schema.contains("VoteRemindersMap") + && schema.contains("VoteStreakProgress_daily")), + any(SqlBackendLogger.class)), times(1)); + } + + verify(fixture.mysql, times(2)).update(eq(uuid.toString()), eq("LastVotes"), any()); + verify(fixture.mysql).update(eq(uuid.toString()), eq("VoteStreakProgress_daily"), any()); + } + + @Test + void registeredSchemaCopiesKeysUnderTheRegistrationLock() { + Fixture fixture = new Fixture(); + AtomicBoolean accessedUnderLock = new AtomicBoolean(); + when(fixture.dataManager.getKeys()).thenAnswer(ignored -> { + accessedUnderLock.set(Thread.holdsLock(fixture.dataManager)); + return fixture.keys; + }); + + try (MockedStatic reconciler = mockStatic(MysqlUserBackend.class)) { + fixture.backend().user(UUID.randomUUID()).write(UserStorage.MYSQL, "LastVotes", new DataValueString("{}")); + } + + assertTrue(accessedUnderLock.get()); + } + + private static final class Fixture { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final UserManager userManager = mock(UserManager.class); + final UserDataManager dataManager = mock(UserDataManager.class); + final MySQL mysql = mock(MySQL.class); + final com.bencodez.simpleapi.sql.mysql.MySQL pooled = + mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); + final ConnectionManager connectionManager = mock(ConnectionManager.class); + final Logger logger = mock(Logger.class); + final ArrayList keys = new ArrayList<>(); + + Fixture() { + keys.add(new UserDataKeyString("LastVotes")); + keys.add(new UserDataKeyString("VoteRemindersMap").setColumnType("LONGTEXT")); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + when(plugin.getUserManager()).thenReturn(userManager); + when(plugin.getLogger()).thenReturn(logger); + when(userManager.getDataManager()).thenReturn(dataManager); + when(dataManager.getKeys()).thenReturn(keys); + when(mysql.getTableName()).thenReturn("VotingPlugin_Users"); + when(mysql.getMysql()).thenReturn(pooled); + when(pooled.getConnectionManager()).thenReturn(connectionManager); + when(connectionManager.getDbType()).thenReturn(DbType.MARIADB); + } + + BukkitSqlUserBackend backend() { + return new BukkitSqlUserBackend(plugin); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java index bcf5963f3..9cbbbb500 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java @@ -38,6 +38,7 @@ import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.MysqlUserBackend; 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; @@ -434,19 +435,23 @@ private static boolean applyOnce(SqlUserStorage user, String key) { when(exists.executeQuery()).thenReturn(existsResult); when(existsResult.next()).thenReturn(true); BukkitSqlUserBackend backend = new BukkitSqlUserBackend(plugin, UserStorage.MYSQL, mysql, null); - assertEquals("accepted", backend.user(uuid).transaction(UserStorage.MYSQL, scope -> "accepted")); - org.mockito.InOrder order = inOrder(connection, mysql); - order.verify(connection).commit(); - order.verify(mysql).recordCommittedUser(uuid, false); - clearInvocations(connection, mysql); - assertEquals("named", backend.user(uuid).transaction(UserStorage.MYSQL, - Map.of("PlayerName", new DataValueString("New")), scope -> "named")); - verify(mysql).recordCommittedUser(uuid, false); - clearInvocations(connection, mysql); - assertThrows(IllegalStateException.class, () -> backend.user(uuid).transaction(UserStorage.MYSQL, - scope -> { throw new SQLException("receipt failed"); })); - verify(connection).rollback(); - verify(mysql, never()).recordCommittedUser(any(), anyBoolean()); + // This test isolates post-commit identity publication. Registered-schema + // reconciliation has dedicated JDBC coverage in MysqlBorrowedSchemaReconciliationTest. + try (var reconciliation = mockStatic(MysqlUserBackend.class)) { + assertEquals("accepted", backend.user(uuid).transaction(UserStorage.MYSQL, scope -> "accepted")); + org.mockito.InOrder order = inOrder(connection, mysql); + order.verify(connection).commit(); + order.verify(mysql).recordCommittedUser(uuid, false); + clearInvocations(connection, mysql); + assertEquals("named", backend.user(uuid).transaction(UserStorage.MYSQL, + Map.of("PlayerName", new DataValueString("New")), scope -> "named")); + verify(mysql).recordCommittedUser(uuid, false); + clearInvocations(connection, mysql); + assertThrows(IllegalStateException.class, () -> backend.user(uuid).transaction(UserStorage.MYSQL, + scope -> { throw new SQLException("receipt failed"); })); + verify(connection).rollback(); + verify(mysql, never()).recordCommittedUser(any(), anyBoolean()); + } } @Test void committedMysqlIdentityInvalidatesStaleNameCache() throws Exception {