diff --git a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java index ab2b560..0ed9308 100644 --- a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java @@ -38,7 +38,6 @@ import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; import net.neoforged.neoforge.event.entity.player.PlayerEvent; import net.neoforged.neoforge.event.entity.player.PlayerNegotiationEvent; import net.neoforged.neoforge.event.server.ServerStoppedEvent; -import net.neoforged.neoforge.event.tick.LevelTickEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; import net.neoforged.neoforge.server.ServerLifecycleHooks; import vip.fubuki.playersync.PlayerSync; @@ -82,16 +81,26 @@ public class VanillaSync { final String player_uuid = serverPlayer.getUUID().toString(); PlayerSync.LOGGER.info("Player entity joining level {}", player_uuid); - JDBCsetUp.QueryResult advancementsQuery = JDBCsetUp - .executeQuery("SELECT advancements FROM player_data WHERE uuid='" + player_uuid + "'"); - ResultSet advancementsResultSet = advancementsQuery.resultSet(); + // Use try-with-resources to prevent connection leaks + String advancementsData; + try (JDBCsetUp.QueryResult advancementsQuery = JDBCsetUp.executePreparedQuery( + "SELECT advancements FROM player_data WHERE uuid=?", player_uuid)) { + ResultSet advancementsResultSet = advancementsQuery.resultSet(); - if (!advancementsResultSet.next()) { - PlayerSync.LOGGER.debug("No advancements found for player {}", player_uuid); - advancementsResultSet.close(); + if (!advancementsResultSet.next()) { + PlayerSync.LOGGER.debug("No advancements found for player {}", player_uuid); + return; + } + advancementsData = advancementsResultSet.getString("advancements"); + } + + if (advancementsData == null || advancementsData.length() < 2) { + PlayerSync.LOGGER.debug("Skip writing advancements for player {} (empty data)", player_uuid); return; } + byte[] bytes = advancementsData.getBytes(StandardCharsets.UTF_8); + // Restore Advancements Path path = serverPlayer.getServer().getServerDirectory().resolve(getSyncWorldForServer()); File gameDir = path.toFile(); @@ -101,14 +110,6 @@ public class VanillaSync { PlayerSync.LOGGER.debug("Attempting to write dedicated server advancement file"); File advancements = new File(gameDir, "/advancements" + "/" + player_uuid + ".json"); - byte[] bytes = advancementsResultSet.getString("advancements").getBytes(); - advancementsResultSet.close(); - - // only create advancements file if at least "{}" has been stored in the field - if (bytes.length < 2) { - PlayerSync.LOGGER.debug("Skip writing advancements for player {}", player_uuid); - return; - } File advancementsDir = advancements.getParentFile(); if (advancementsDir != null && !advancementsDir.exists()) { @@ -143,49 +144,44 @@ public class VanillaSync { for (File file : files) { if (file == null) continue; - byte[] bytes = advancementsResultSet.getString("advancements").getBytes(); Files.write(file.toPath(), bytes); } - advancementsResultSet.close(); } } public static void doPlayerConnect(PlayerNegotiationEvent event) { try { String player_uuid = event.getProfile().getId().toString(); - PlayerSync.LOGGER.info("Detected connection from player{},starting checking", player_uuid); + PlayerSync.LOGGER.info("Detected connection from player {}, starting checking", player_uuid); boolean online; int lastServer; - // First query: check basic player data and check whether player can join into server. - JDBCsetUp.QueryResult qr1 = JDBCsetUp.executeQuery("SELECT online, last_server FROM player_data WHERE uuid='" + player_uuid + "'"); - - try (ResultSet rs1 = qr1.resultSet()) { + // First query: check basic player data using prepared statement + try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery( + "SELECT online, last_server FROM player_data WHERE uuid=?", player_uuid)) { + ResultSet rs1 = qr1.resultSet(); if (!rs1.next()) { PlayerSync.LOGGER.info("A new-player connection detected"); - qr1.connection().close(); return; } online = rs1.getBoolean("online"); lastServer = rs1.getInt("last_server"); - qr1.connection().close(); } // Second query: Check if player is already online on another server if (JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get() && online && lastServer != JdbcConfig.SERVER_ID.get()) { - JDBCsetUp.QueryResult qr2 = JDBCsetUp.executeQuery("SELECT last_update,enable FROM server_info WHERE id='" + lastServer + "'"); - try (ResultSet rs2 = qr2.resultSet()) { + try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery( + "SELECT last_update, enable FROM server_info WHERE id=?", lastServer)) { + ResultSet rs2 = qr2.resultSet(); if (rs2.next()) { long last_update = rs2.getLong("last_update"); boolean enable = rs2.getBoolean("enable"); - if (enable && System.currentTimeMillis() < last_update + 300000.0) { + if (enable && System.currentTimeMillis() < last_update + 300000L) { event.getConnection().disconnect(Component.translatableWithFallback("playersync.already_online","You can't join more than one synchronization server at the same time.")); - qr2.connection().close(); return; } - JDBCsetUp.executeUpdate("UPDATE server_info SET enable= '0' WHERE id=" + lastServer); + JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", lastServer); } - qr2.connection().close(); } } } catch (Exception e) { @@ -230,10 +226,10 @@ public class VanillaSync { joinedPlayer.setHealth(1); try { - JDBCsetUp.executeUpdate("UPDATE server_info SET last_update=" + System.currentTimeMillis() + " WHERE id=" + JdbcConfig.SERVER_ID.get()); - JDBCsetUp.executeUpdate("UPDATE player_data SET online= '1',last_server=" + JdbcConfig.SERVER_ID.get() + " WHERE uuid='" + player_uuid + "'"); + JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid); } catch (SQLException e) { - PlayerSync.LOGGER.error("An error occurred while trying to execute a dead or dying player{}", e.getMessage()); + PlayerSync.LOGGER.error("An error occurred while handling dead/dying player {}", e.getMessage()); } joinedPlayer.connection.disconnect(Component.translatableWithFallback("playersync.wrong_entity_status","An error occurred while creating playerEntity in the world,please login again.")); return; @@ -242,101 +238,122 @@ public class VanillaSync { try { PlayerSync.LOGGER.info("Starting synchronization for player {}", player_uuid); - // First query: check basic player data syncNotCompletedPlayer.add(player_uuid); - JDBCsetUp.QueryResult qr1 = JDBCsetUp.executeQuery("SELECT online, last_server FROM player_data WHERE uuid='" + player_uuid + "'"); - ResultSet rs1 = qr1.resultSet(); ServerPlayer serverPlayer = (ServerPlayer) event.getEntity(); - // Mod support - ModsSupport modsSupport = new ModsSupport(); - modsSupport.doCuriosRestore(serverPlayer); + // First query: check if player exists in DB + boolean playerExists; + try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery( + "SELECT uuid FROM player_data WHERE uuid=?", player_uuid)) { + playerExists = qr1.resultSet().next(); + } - if (!rs1.next()) { + if (!playerExists) { + // New player - init and save + ModsSupport modsSupport = new ModsSupport(); + modsSupport.doCuriosRestore(serverPlayer); store(event.getEntity(), true); - JDBCsetUp.executeUpdate("UPDATE server_info SET last_update=" + System.currentTimeMillis() + " WHERE id=" + JdbcConfig.SERVER_ID.get()); - JDBCsetUp.executeUpdate("UPDATE player_data SET online= '1',last_server=" + JdbcConfig.SERVER_ID.get() + " WHERE uuid='" + player_uuid + "'"); - rs1.close(); - qr1.close(); - PlayerSync.LOGGER.info("New player detected,init completed."); + JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid); + PlayerSync.LOGGER.info("New player detected, init completed."); syncNotCompletedPlayer.remove(player_uuid); return; } - // Second query: retrieve full player data - JDBCsetUp.QueryResult qr2 = JDBCsetUp.executeQuery("SELECT * FROM player_data WHERE uuid='" + player_uuid + "'"); - ResultSet rs2 = qr2.resultSet(); + // Mark player as online immediately to prevent race conditions + JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=1, last_server=? WHERE uuid=?", JdbcConfig.SERVER_ID.get(), player_uuid); - JDBCsetUp.executeUpdate("UPDATE server_info SET last_update=" + System.currentTimeMillis() + " WHERE id=" + JdbcConfig.SERVER_ID.get()); - JDBCsetUp.executeUpdate("UPDATE player_data SET online= '1',last_server=" + JdbcConfig.SERVER_ID.get() + " WHERE uuid='" + player_uuid + "'"); + // Retrieve full player data + try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery( + "SELECT * FROM player_data WHERE uuid=?", player_uuid)) { + ResultSet rs2 = qr2.resultSet(); - if (rs2.next()) { - // Restore basic attributes - int health = rs2.getInt("health"); - if (health <= 0) { - serverPlayer.setHealth(1); - } else { - serverPlayer.setHealth(health); - } - serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level")); - - setXpForPlayer(serverPlayer, rs2.getInt("xp")); - serverPlayer.setScore(rs2.getInt("score")); - - // Restore left-hand item - String leftHandEncoded = rs2.getString("left_hand"); - serverPlayer.setItemInHand(InteractionHand.OFF_HAND, - deserializeAndCreatePlaceholderIfNeeded(leftHandEncoded)); - - // Restore cursor item - String cursorsEncoded = rs2.getString("cursors"); - serverPlayer.containerMenu.setCarried( - deserializeAndCreatePlaceholderIfNeeded(cursorsEncoded)); - - // Restore armor - String armor_data = rs2.getString("armor"); - if (armor_data.length() > 2) { - Map equipment = LocalJsonUtil.StringToEntryMap(armor_data); - for (Map.Entry entry : equipment.entrySet()) { - serverPlayer.getInventory().armor.set(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); + if (rs2.next()) { + // === ANTI-DUPLICATION: Clear all inventories BEFORE restoring from DB === + serverPlayer.getInventory().clearContent(); + serverPlayer.getEnderChestInventory().clearContent(); + serverPlayer.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY); + serverPlayer.containerMenu.setCarried(ItemStack.EMPTY); + for (int i = 0; i < serverPlayer.getInventory().armor.size(); i++) { + serverPlayer.getInventory().armor.set(i, ItemStack.EMPTY); } - } - // Restore inventory - Map inventory = LocalJsonUtil.StringToEntryMap(rs2.getString("inventory")); - for (Map.Entry entry : inventory.entrySet()) { - serverPlayer.getInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); - } + // Restore basic attributes + int health = rs2.getInt("health"); + if (health <= 0) { + serverPlayer.setHealth(1); + } else { + serverPlayer.setHealth(health); + } + serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level")); - // Restore Ender Chest - Map ender_chest = LocalJsonUtil.StringToEntryMap(rs2.getString("enderchest")); - for (Map.Entry entry : ender_chest.entrySet()) { - serverPlayer.getEnderChestInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); - } + setXpForPlayer(serverPlayer, rs2.getInt("xp")); + serverPlayer.setScore(rs2.getInt("score")); - // Restore Effects - String effectData = rs2.getString("effects"); - if (effectData.length() > 2) { - serverPlayer.removeAllEffects(); - Map effects = LocalJsonUtil.StringToEntryMap(effectData); - for (Map.Entry entry : effects.entrySet()) { - CompoundTag effectTag = NbtUtils.snbtToStructure(deserializeString(entry.getValue())); - MobEffectInstance mobEffectInstance = MobEffectInstance.load(effectTag); - if (mobEffectInstance != null) { - serverPlayer.addEffect(mobEffectInstance); + // Restore left-hand item + String leftHandEncoded = rs2.getString("left_hand"); + serverPlayer.setItemInHand(InteractionHand.OFF_HAND, + deserializeAndCreatePlaceholderIfNeeded(leftHandEncoded)); + + // Restore cursor item + String cursorsEncoded = rs2.getString("cursors"); + serverPlayer.containerMenu.setCarried( + deserializeAndCreatePlaceholderIfNeeded(cursorsEncoded)); + + // Restore armor + String armor_data = rs2.getString("armor"); + if (armor_data != null && armor_data.length() > 2) { + Map equipment = LocalJsonUtil.StringToEntryMap(armor_data); + for (Map.Entry entry : equipment.entrySet()) { + serverPlayer.getInventory().armor.set(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); + } + } + + // Restore inventory + String inventoryData = rs2.getString("inventory"); + if (inventoryData != null && inventoryData.length() > 2) { + Map inventory = LocalJsonUtil.StringToEntryMap(inventoryData); + for (Map.Entry entry : inventory.entrySet()) { + serverPlayer.getInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); + } + } + + // Restore Ender Chest + String enderChestData = rs2.getString("enderchest"); + if (enderChestData != null && enderChestData.length() > 2) { + Map ender_chest = LocalJsonUtil.StringToEntryMap(enderChestData); + for (Map.Entry entry : ender_chest.entrySet()) { + serverPlayer.getEnderChestInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue())); + } + } + + // Restore Effects + String effectData = rs2.getString("effects"); + if (effectData != null && effectData.length() > 2) { + serverPlayer.removeAllEffects(); + Map effects = LocalJsonUtil.StringToEntryMap(effectData); + for (Map.Entry entry : effects.entrySet()) { + CompoundTag effectTag = NbtUtils.snbtToStructure(deserializeString(entry.getValue())); + MobEffectInstance mobEffectInstance = MobEffectInstance.load(effectTag); + if (mobEffectInstance != null) { + serverPlayer.addEffect(mobEffectInstance); + } } } } } + // Restore mod data AFTER main inventory is restored + ModsSupport modsSupport = new ModsSupport(); + modsSupport.doCuriosRestore(serverPlayer); modsSupport.doBackPackRestore(serverPlayer); + if (ModList.get().isLoaded("sophisticatedstorage")) { + ModsSupport.restoreSophisticatedStorageItems(serverPlayer); + } serverPlayer.addTag("player_synced"); - rs2.close(); - qr2.close(); - rs1.close(); - qr1.close(); PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid); syncNotCompletedPlayer.remove(player_uuid); } catch (Exception e) { @@ -347,13 +364,14 @@ public class VanillaSync { @SubscribeEvent public static void onPlayerConnect(PlayerNegotiationEvent event) { - executorService.submit(() -> { - try { - doPlayerConnect(event); - } catch (Exception e) { - e.printStackTrace(); - } - }); + // MUST run synchronously to block login until the duplicate check completes. + // Running async allowed players to join before the kick check finished. + try { + doPlayerConnect(event); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error during player connection check", e); + event.getConnection().disconnect(Component.translatableWithFallback("playersync.sqlexception","SqlException detected!Connection lost,please contact with your admin.")); + } } @SubscribeEvent @@ -544,7 +562,7 @@ public class VanillaSync { } public static void doPlayerSaveToFile(PlayerEvent.SaveToFile event) throws SQLException, IOException { - JDBCsetUp.executeUpdate("UPDATE server_info SET last_update=" + System.currentTimeMillis() + " WHERE id=" + JdbcConfig.SERVER_ID.get()); + JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); if (!event.getEntity().getTags().contains("player_synced")) return; store(event.getEntity(), false); } @@ -562,35 +580,44 @@ public class VanillaSync { @SubscribeEvent public static void onServerShutdown(ServerStoppedEvent event) throws SQLException { - JDBCsetUp.executeUpdate("UPDATE server_info SET enable= '0' WHERE id=" + JdbcConfig.SERVER_ID.get()); + JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", JdbcConfig.SERVER_ID.get()); } public static void doPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) throws SQLException, IOException { String player_uuid = event.getEntity().getUUID().toString(); - JDBCsetUp.executeUpdate("UPDATE player_data SET online= '0' WHERE uuid='" + player_uuid + "'"); + // FIX: Save data BEFORE marking offline to prevent data loss on quick reconnect store(event.getEntity(), false); + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); } @SubscribeEvent public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) throws SQLException { String player_uuid = event.getEntity().getUUID().toString(); if (deadPlayerWhileLogging.contains(player_uuid)) { - PlayerSync.LOGGER.warn("A dead or dying player was kicked,which uuid is:{}", player_uuid); - JDBCsetUp.executeUpdate("UPDATE player_data SET online= '0' WHERE uuid='" + player_uuid + "'"); + PlayerSync.LOGGER.warn("A dead or dying player was kicked, uuid: {}", player_uuid); + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); deadPlayerWhileLogging.remove(player_uuid); } else if (syncNotCompletedPlayer.contains(player_uuid)) { - PlayerSync.LOGGER.warn("A player logged out with uncompleted sync data,which uuid is:{}.For the safety,the new data won't be saved", player_uuid); - JDBCsetUp.executeUpdate("UPDATE player_data SET online= '0' WHERE uuid='" + player_uuid + "'"); + PlayerSync.LOGGER.warn("Player {} logged out with uncompleted sync. Data won't be saved for safety.", player_uuid); + JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid); syncNotCompletedPlayer.remove(player_uuid); } else { - // Mod support + // Mod support - save curios ModsSupport modsSupport = new ModsSupport(); - modsSupport.onPlayerLeave(event.getEntity()); + Player player = event.getEntity(); + + // FIX: If player is dead/dying, use curios cache instead of reading from API (which returns empty) + if (player.isDeadOrDying()) { + modsSupport.saveCuriosFromCacheOrApi(player); + } else { + modsSupport.onPlayerLeave(player); + } + executorService.submit(() -> { try { doPlayerLogout(event); } catch (Exception e) { - e.printStackTrace(); + PlayerSync.LOGGER.error("Error during player logout save for {}", player_uuid, e); } }); } @@ -687,6 +714,9 @@ public class VanillaSync { if(ModList.get().isLoaded("sophisticatedbackpacks")){ ModsSupport.storeSophisticatedBackpacks(player); } + if(ModList.get().isLoaded("sophisticatedstorage")){ + ModsSupport.storeSophisticatedStorageItems(player); + } // Effects Map, MobEffectInstance> effects = player.getActiveEffectsMap(); @@ -719,25 +749,28 @@ public class VanillaSync { } } } - if (!advancements.exists()) { - PlayerSync.LOGGER.warn("Advancements file for {} does not exist (yet).", player_uuid); - } - if (advancements.exists()) { + // FIX: Null safety - advancements file may be null if no files were found + if (advancements != null && advancements.exists()) { PlayerSync.LOGGER.debug("Storing advancements for {} from {}", player_uuid, advancements.toPath()); advancementBytes = Files.readAllBytes(advancements.toPath()); } else { - PlayerSync.LOGGER.error("Unable to save advancements for player {}", player_uuid); + PlayerSync.LOGGER.warn("Unable to save advancements for player {} (file not found)", player_uuid); } } String json = new String(advancementBytes, StandardCharsets.UTF_8); PlayerSync.LOGGER.trace("Storing advancements for player {}: {}", player_uuid, json); - // SQL Operation for player data + // SQL Operation for player data - using prepared statements to prevent + // SQL injection and data corruption from special characters (especially in advancement JSON) if (init) { - JDBCsetUp.executeUpdate("INSERT INTO player_data (uuid,armor,inventory,enderchest,advancements,effects,xp,food_level,health,score,left_hand,cursors,online) VALUES ('" + player_uuid + "','" + equipment + "','" + inventoryMap + "','" + ender_chest + "','" + json + "','" + effectMap + "','" + XP + "','" + food_level + "','" + health + "','" + score + "','" + left_hand + "','" + cursors + "',online=true)"); + JDBCsetUp.executePreparedUpdate( + "INSERT INTO player_data (uuid, armor, inventory, enderchest, advancements, effects, xp, food_level, health, score, left_hand, cursors, online) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", + player_uuid, equipment.toString(), inventoryMap.toString(), ender_chest.toString(), json, effectMap.toString(), XP, food_level, health, score, left_hand, cursors); } else { - JDBCsetUp.executeUpdate("UPDATE player_data SET inventory = '" + inventoryMap + "',armor='" + equipment + "' ,xp='" + XP + "',effects='" + effectMap + "',enderchest='" + ender_chest + "',score='" + score + "',food_level='" + food_level + "',health='" + health + "',advancements='" + json + "',left_hand='" + left_hand + "',cursors='" + cursors + "' WHERE uuid = '" + player_uuid + "'"); + JDBCsetUp.executePreparedUpdate( + "UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=?, left_hand=?, cursors=? WHERE uuid=?", + inventoryMap.toString(), equipment.toString(), XP, effectMap.toString(), ender_chest.toString(), score, food_level, health, json, left_hand, cursors, player_uuid); } } @@ -770,41 +803,47 @@ public class VanillaSync { return files; } - static int tick = 0; - - @SubscribeEvent - public static void onUpdate(LevelTickEvent.Post event) throws SQLException { - tick++; - if (tick == 1800) { - tick = 0; - long current = System.currentTimeMillis(); - JDBCsetUp.executeUpdate("UPDATE server_info SET last_update =" + current + " WHERE id= " + JdbcConfig.SERVER_ID.get()); - } - } - - - // New fields for auto-save + // All periodic tasks merged into a single ServerTickEvent handler. + // FIX: Previously used LevelTickEvent which fires once per dimension, causing the tick counter + // to increment 3x faster than expected (once per overworld, nether, end). + private static int heartbeatTickCounter = 0; + private static final int HEARTBEAT_INTERVAL_TICKS = 600; // Every 30 seconds (20 tps * 30s) private static int autoSaveTickCounter = 0; - private static final int AUTO_SAVE_INTERVAL_TICKS = 1200; // Every Minute + private static final int AUTO_SAVE_INTERVAL_TICKS = 1200; // Every minute private static int autoCleanCuriosCacheTickCounter = 0; private static final int AUTO_CLEAN_CURIOS_CACHE_INTERVAL_TICKS = 36000; // Every 30 min - //AutoSave @SubscribeEvent public static void onServerTick(ServerTickEvent.Post event) { - // Run at the end phase to avoid interfering with game logic + heartbeatTickCounter++; autoSaveTickCounter++; autoCleanCuriosCacheTickCounter++; + + // Heartbeat: update server_info to prove this server is alive + if (heartbeatTickCounter >= HEARTBEAT_INTERVAL_TICKS) { + heartbeatTickCounter = 0; + executorService.submit(() -> { + try { + JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?", + System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()); + } catch (SQLException e) { + PlayerSync.LOGGER.error("Error updating server heartbeat", e); + } + }); + } + + // Auto-save all online players if (autoSaveTickCounter >= AUTO_SAVE_INTERVAL_TICKS) { autoSaveTickCounter = 0; - // Retrieve the current server instance MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if (server != null) { - // Iterate through all online players for (ServerPlayer player : server.getPlayerList().getPlayers()) { + // Skip dead players and players whose sync hasn't completed yet + if (player.isDeadOrDying() || syncNotCompletedPlayer.contains(player.getUUID().toString())) { + continue; + } executorService.submit(() -> { try { - // Call the same store method used in logout and file save events. store(player, false); } catch (Exception e) { PlayerSync.LOGGER.error("Error auto-saving player {}", player.getUUID(), e); @@ -812,22 +851,26 @@ public class VanillaSync { }); executorService.submit(() -> { try { - new ModsSupport().StoreCurios(player, false); + // Only auto-save curios for alive players to prevent saving empty data + if (!player.isDeadOrDying()) { + new ModsSupport().StoreCurios(player, false); + } } catch (SQLException e) { PlayerSync.LOGGER.error("Error auto-saving Curios data for player {}", player.getUUID(), e); } }); - - } + } } } + + // Clean expired curios cache if (autoCleanCuriosCacheTickCounter >= AUTO_CLEAN_CURIOS_CACHE_INTERVAL_TICKS) { autoCleanCuriosCacheTickCounter = 0; executorService.submit(() -> { try { CuriosCache.RemoveExpiredCuriosCache(); } catch (Exception e) { - PlayerSync.LOGGER.error("An error occurred while cleaning curios cache:{}", e.getMessage()); + PlayerSync.LOGGER.error("An error occurred while cleaning curios cache: {}", e.getMessage()); } }); } diff --git a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java index 324aa5c..0ee8d14 100644 --- a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java +++ b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java @@ -1,10 +1,13 @@ package vip.fubuki.playersync.sync.addons; import com.mojang.brigadier.exceptions.CommandSyntaxException; +import net.minecraft.core.component.DataComponents; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.TagParser; +import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.component.CustomData; import net.neoforged.fml.ModList; import top.theillusivec4.curios.api.CuriosApi; import top.theillusivec4.curios.api.type.capability.ICuriosItemHandler; @@ -27,141 +30,199 @@ import java.util.UUID; public class ModsSupport { public void doBackPackRestore(Player player) { if (ModList.get().isLoaded("sophisticatedbackpacks")) { - // --- Begin Backpack Data Restore --- - PlayerSync.LOGGER.info("Restoring backpack data for player " + player.getUUID()); + PlayerSync.LOGGER.info("Restoring backpack data for player {}", player.getUUID()); net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, (ItemStack backpackItem, String handler, String identifier, int slot) -> { net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.IBackpackWrapper backpackWrapper = net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.BackpackWrapper .fromStack(backpackItem); - // Retrieve the contents UUID from the backpack's NBT using NBTHelper Optional uuidOpt = backpackWrapper.getContentsUuid(); if (uuidOpt.isPresent()) { UUID contentsUuid = uuidOpt.get(); - try { - JDBCsetUp.QueryResult qrBackpack = JDBCsetUp.executeQuery("SELECT backpack_nbt FROM backpack_data WHERE uuid='" + contentsUuid + "'"); - ResultSet rsBackpack = qrBackpack.resultSet(); - if (rsBackpack.next()) { - String serialized = rsBackpack.getString("backpack_nbt"); - CompoundTag backpackNbt; - if (serialized.startsWith("BNBT:")) { - backpackNbt = VanillaSync.deserializeBinaryBase64Tag(serialized); - } else { - String nbtString = VanillaSync.deserializeString(serialized); - try { - backpackNbt = TagParser.parseTag(nbtString); - } catch (CommandSyntaxException ex) { - PlayerSync.LOGGER.warn("TagParser.parseTag failed for backpack UUID {}, trying fallback", contentsUuid); - backpackNbt = net.minecraft.nbt.NbtUtils.snbtToStructure(nbtString); - } - } - // Update BackpackStorage with the retrieved NBT - net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, backpackNbt); - PlayerSync.LOGGER.info("Restored backpack data for UUID " + contentsUuid); - } - rsBackpack.close(); - qrBackpack.connection().close(); - } catch (SQLException e) { - PlayerSync.LOGGER.error("Error restoring backpack data for UUID " + contentsUuid, e); - } catch (CommandSyntaxException e) { - PlayerSync.LOGGER.error("Error parsing backpack NBT for UUID {}. Skipping backpack.", contentsUuid, e); - } catch (IOException e) { - PlayerSync.LOGGER.error("Error reading binary backpack NBT for UUID {}. Skipping backpack.", contentsUuid, e); - } + restoreStorageContents(contentsUuid, (nbt) -> { + net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, nbt); + PlayerSync.LOGGER.info("Restored backpack data for UUID {}", contentsUuid); + }); } else { - PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid during restore"); + PlayerSync.LOGGER.warn("Backpack item in slot {} has no contentsUuid during restore", slot); } return false; }); - // --- End Backpack Data Restore --- } } + /** - * Restores the Curios inventory for a player. - * The saved data is stored as a flat map with composite keys ("slotType:index"). + * Generic method to restore storage contents from DB for a given UUID. + * Used for both Sophisticated Backpacks and Sophisticated Storage items. */ - public void doCuriosRestore(Player player) throws SQLException { - if (ModList.get().isLoaded("curios")) { - // Obtain the handler from the API. - Optional handlerOpt = CuriosApi.getCuriosInventory(player); - JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery("SELECT curios_item FROM curios WHERE uuid = '" + player.getUUID() + "'"); + private static void restoreStorageContents(UUID contentsUuid, StorageRestoreCallback callback) { + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT backpack_nbt FROM backpack_data WHERE uuid=?", contentsUuid.toString())) { ResultSet rs = qr.resultSet(); if (rs.next()) { - String curiosData = rs.getString("curios_item"); - // Parse the stored data (assumes a simple Map.toString() format: "{key=value, key2=value2, ...}") - Map storedMap = LocalJsonUtil.StringToMap(curiosData); - // Clear current Curios slots to avoid conflicts. - handlerOpt.ifPresent(handler -> handler.getCurios().forEach((slotType, stacksHandler) -> { - // Use the dynamic stack handler to clear slots. - IDynamicStackHandler dynStacks = stacksHandler.getStacks(); - for (int i = 0; i < dynStacks.getSlots(); i++) { - dynStacks.setStackInSlot(i, ItemStack.EMPTY); + String serialized = rs.getString("backpack_nbt"); + CompoundTag nbt; + if (serialized.startsWith("BNBT:")) { + nbt = VanillaSync.deserializeBinaryBase64Tag(serialized); + } else { + String nbtString = VanillaSync.deserializeString(serialized); + try { + nbt = TagParser.parseTag(nbtString); + } catch (CommandSyntaxException ex) { + PlayerSync.LOGGER.warn("TagParser failed for storage UUID {}, trying fallback", contentsUuid); + nbt = net.minecraft.nbt.NbtUtils.snbtToStructure(nbtString); } - })); - - if (curiosData.length() <= 2) { - rs.close(); - qr.connection().close(); - return; } + callback.restore(nbt); + } + } catch (SQLException e) { + PlayerSync.LOGGER.error("Error restoring storage data for UUID {}", contentsUuid, e); + } catch (CommandSyntaxException e) { + PlayerSync.LOGGER.error("Error parsing storage NBT for UUID {}. Skipping.", contentsUuid, e); + } catch (IOException e) { + PlayerSync.LOGGER.error("Error reading binary storage NBT for UUID {}. Skipping.", contentsUuid, e); + } + } - // Restore each saved item. - handlerOpt.ifPresent(handler -> { - for (Map.Entry entry : storedMap.entrySet()) { - String compositeKey = entry.getKey(); // Expected format: "slotType:index" - // Use lastIndexOf to correctly handle slot type names that may contain ':' - int lastColon = compositeKey.lastIndexOf(':'); - if (lastColon < 0) { - continue; - } - String slotType = compositeKey.substring(0, lastColon); - int slotIndex; - try { - slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1)); - } catch (NumberFormatException ex) { - continue; - } - String serialized = entry.getValue(); - try { - ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(serialized); - if (handler.getCurios().containsKey(slotType)) { - ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType); - IDynamicStackHandler dynStacks = stacksHandler.getStacks(); - if (slotIndex < dynStacks.getSlots()) { - dynStacks.setStackInSlot(slotIndex, stack); - } - } - } catch (CommandSyntaxException e) { - PlayerSync.LOGGER.error("Error deserializing Curio data for key {}. Skipping this slot. Data: {}", compositeKey, serialized, e); - } catch (Exception e) { - PlayerSync.LOGGER.error("Unexpected error restoring Curio data for key {}. Skipping this slot.", compositeKey, e); - } - } - }); - rs.close(); - qr.connection().close(); - } else { + @FunctionalInterface + private interface StorageRestoreCallback { + void restore(CompoundTag nbt); + } + + /** + * Generic method to save storage contents to DB for a given UUID. + * Used for both Sophisticated Backpacks and Sophisticated Storage items. + */ + private static void saveStorageContents(UUID contentsUuid, CompoundTag nbt) { + String serialized = VanillaSync.serializeTagToBinaryBase64(nbt); + try { + JDBCsetUp.executePreparedUpdate( + "REPLACE INTO backpack_data (uuid, backpack_nbt) VALUES (?, ?)", + contentsUuid.toString(), serialized); + } catch (SQLException e) { + PlayerSync.LOGGER.error("Error saving storage data for UUID {}", contentsUuid, e); + } + } + + /** + * Restores the Curios inventory for a player. + * FIX: Slots are now cleared AFTER validating that data exists, preventing + * curios from being wiped when DB contains empty/minimal data. + */ + public void doCuriosRestore(Player player) throws SQLException { + if (!ModList.get().isLoaded("curios")) return; + + Optional handlerOpt = CuriosApi.getCuriosInventory(player); + if (handlerOpt.isEmpty()) { + PlayerSync.LOGGER.warn("Could not get Curios handler for player {}", player.getUUID()); + return; + } + + String curiosData; + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT curios_item FROM curios WHERE uuid=?", player.getUUID().toString())) { + ResultSet rs = qr.resultSet(); + if (!rs.next()) { // No stored data; perform an initial save. StoreCurios(player, true); + return; + } + curiosData = rs.getString("curios_item"); + } + + // FIX: Check if data is valid BEFORE clearing slots + if (curiosData == null || curiosData.length() <= 2) { + PlayerSync.LOGGER.debug("Empty curios data for player {}, skipping restore", player.getUUID()); + return; + } + + Map storedMap = LocalJsonUtil.StringToMap(curiosData); + if (storedMap.isEmpty()) { + PlayerSync.LOGGER.debug("No curios entries for player {}, skipping restore", player.getUUID()); + return; + } + + ICuriosItemHandler handler = handlerOpt.get(); + + // Clear current Curios slots ONLY after confirming valid data exists + handler.getCurios().forEach((slotType, stacksHandler) -> { + IDynamicStackHandler dynStacks = stacksHandler.getStacks(); + for (int i = 0; i < dynStacks.getSlots(); i++) { + dynStacks.setStackInSlot(i, ItemStack.EMPTY); + } + }); + + // Restore each saved item + for (Map.Entry entry : storedMap.entrySet()) { + String compositeKey = entry.getKey(); + int lastColon = compositeKey.lastIndexOf(':'); + if (lastColon < 0) continue; + + String slotType = compositeKey.substring(0, lastColon); + int slotIndex; + try { + slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1)); + } catch (NumberFormatException ex) { + continue; + } + + String serialized = entry.getValue(); + try { + ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(serialized); + if (handler.getCurios().containsKey(slotType)) { + ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType); + IDynamicStackHandler dynStacks = stacksHandler.getStacks(); + if (slotIndex < dynStacks.getSlots()) { + dynStacks.setStackInSlot(slotIndex, stack); + } + } + } catch (CommandSyntaxException e) { + PlayerSync.LOGGER.error("Error deserializing Curio data for key {}. Skipping.", compositeKey, e); + } catch (Exception e) { + PlayerSync.LOGGER.error("Unexpected error restoring Curio data for key {}. Skipping.", compositeKey, e); } } } /** - * Saves the current Curios inventory for a player. - * It builds a flat map keyed by "slotType:index" using the dynamic stack handler. + * Saves the current Curios inventory for a player (normal case - player alive). */ - public void onPlayerLeave(net.minecraft.world.entity.player.Player player) throws SQLException { + public void onPlayerLeave(Player player) throws SQLException { if (ModList.get().isLoaded("curios")) { StoreCurios(player, false); } } - public void StoreCurios(net.minecraft.world.entity.player.Player player, boolean init) throws SQLException { + /** + * FIX: Saves curios from cache if player is dead/dying, or from API if alive. + * When a player dies, the Curios API may return empty data. The CuriosCache + * stores a snapshot taken at death time, so we use that instead. + */ + public void saveCuriosFromCacheOrApi(Player player) throws SQLException { + if (!ModList.get().isLoaded("curios")) return; + + UUID playerUuid = player.getUUID(); + CuriosCache.CuriosCacheEntry cached = CuriosCache.curiosCache.get(playerUuid); + + if (cached != null && !cached.isExpired()) { + // Use cached data from death event + PlayerSync.LOGGER.info("Using cached curios data for dead player {}", playerUuid); + JDBCsetUp.executePreparedUpdate( + "UPDATE curios SET curios_item=? WHERE uuid=?", + cached.serializedData, playerUuid.toString()); + CuriosCache.curiosCache.remove(playerUuid); + } else { + // Fallback: try to read from API (may be empty for dead players) + StoreCurios(player, false); + } + } + + public void StoreCurios(Player player, boolean init) throws SQLException { + if (!ModList.get().isLoaded("curios")) return; + Optional handlerOpt = CuriosApi.getCuriosInventory(player); Map flatMap = new HashMap<>(); handlerOpt.ifPresent(handler -> { - // Iterate over each slot type. handler.getCurios().forEach((slotType, stacksHandler) -> { IDynamicStackHandler dynStacks = stacksHandler.getStacks(); for (int i = 0; i < dynStacks.getSlots(); i++) { @@ -175,38 +236,144 @@ public class ModsSupport { }); String serializedData = flatMap.toString(); + + // Use prepared statements to prevent SQL injection / data corruption if (init) { - JDBCsetUp.executeUpdate("INSERT INTO curios (uuid,curios_item) VALUES ('" + player.getUUID() + "', '" + serializedData + "')"); + JDBCsetUp.executePreparedUpdate( + "INSERT INTO curios (uuid, curios_item) VALUES (?, ?)", + player.getUUID().toString(), serializedData); } else { - JDBCsetUp.executeUpdate("UPDATE curios SET curios_item = '" + serializedData + "' WHERE uuid = '" + player.getUUID() + "'"); + JDBCsetUp.executePreparedUpdate( + "UPDATE curios SET curios_item=? WHERE uuid=?", + serializedData, player.getUUID().toString()); } } + // ============================ + // Sophisticated Backpacks + // ============================ + public static void storeSophisticatedBackpacks(Player player) { - PlayerSync.LOGGER.info("Storing backpack data for player " + player.getUUID()); + PlayerSync.LOGGER.info("Storing backpack data for player {}", player.getUUID()); net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, (ItemStack backpackItem, String handler, String identifier, int slot) -> { net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.IBackpackWrapper backpackWrapper = net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.BackpackWrapper .fromStack(backpackItem); - // Retrieve the contents UUID from the backpack's NBT using NBTHelper Optional uuidOpt = backpackWrapper.getContentsUuid(); if (uuidOpt.isPresent()) { UUID contentsUuid = uuidOpt.get(); - // Get internal backpack data from BackpackStorage (creates it if missing) CompoundTag backpackNbt = net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().getOrCreateBackpackContents(contentsUuid); - String serialized = VanillaSync.serializeTagToBinaryBase64(backpackNbt); - try { - // Use REPLACE INTO so existing records are updated - JDBCsetUp.executeUpdate("REPLACE INTO backpack_data (uuid, backpack_nbt) VALUES ('" + contentsUuid + "', '" + serialized + "')"); - PlayerSync.LOGGER.info("Saved backpack data for UUID " + contentsUuid); - } catch (SQLException e) { - PlayerSync.LOGGER.error("Error saving backpack data for UUID " + contentsUuid, e); - } + saveStorageContents(contentsUuid, backpackNbt); + PlayerSync.LOGGER.info("Saved backpack data for UUID {}", contentsUuid); } else { - PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid"); + PlayerSync.LOGGER.warn("Backpack item in slot {} has no contentsUuid", slot); } - return false; // Continue processing all backpack items. + return false; }); } + // ============================ + // Sophisticated Storage (barrels, shulkers, chests) + // ============================ + + /** + * Scans the player's inventory for packed Sophisticated Storage items (barrels, shulkers, chests) + * and saves their contents to the database. + * + * These items store their contents externally using a UUID reference, similar to backpacks. + * The item's CustomData contains a "contentsUuid" field pointing to the storage data. + */ + public static void storeSophisticatedStorageItems(Player player) { + PlayerSync.LOGGER.info("Scanning inventory for Sophisticated Storage items for player {}", player.getUUID()); + Inventory inventory = player.getInventory(); + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (stack.isEmpty()) continue; + + // Check if this item is from the sophisticatedstorage namespace + String itemId = stack.getItem().toString(); + if (!isSophisticatedStorageItem(stack)) continue; + + // Try to extract contentsUuid from the item's custom data + UUID contentsUuid = extractContentsUuid(stack); + if (contentsUuid == null) continue; + + try { + // Read the storage contents from the world save data via BackpackStorage + // Sophisticated Storage uses the same BackpackStorage mechanism from sophisticatedcore + CompoundTag storageNbt = net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().getOrCreateBackpackContents(contentsUuid); + if (storageNbt != null && !storageNbt.isEmpty()) { + saveStorageContents(contentsUuid, storageNbt); + PlayerSync.LOGGER.info("Saved Sophisticated Storage item data for UUID {}", contentsUuid); + } + } catch (Exception e) { + PlayerSync.LOGGER.error("Error saving Sophisticated Storage data for UUID {}", contentsUuid, e); + } + } + } + + /** + * Restores packed Sophisticated Storage items' contents from the database. + */ + public static void restoreSophisticatedStorageItems(Player player) { + PlayerSync.LOGGER.info("Restoring Sophisticated Storage items for player {}", player.getUUID()); + Inventory inventory = player.getInventory(); + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (stack.isEmpty()) continue; + + if (!isSophisticatedStorageItem(stack)) continue; + + UUID contentsUuid = extractContentsUuid(stack); + if (contentsUuid == null) continue; + + restoreStorageContents(contentsUuid, (nbt) -> { + try { + net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, nbt); + PlayerSync.LOGGER.info("Restored Sophisticated Storage item data for UUID {}", contentsUuid); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error restoring Sophisticated Storage data for UUID {}", contentsUuid, e); + } + }); + } + } + + /** + * Checks if an item is from the Sophisticated Storage mod by examining its registry name. + */ + private static boolean isSophisticatedStorageItem(ItemStack stack) { + try { + net.minecraft.resources.ResourceLocation loc = net.minecraft.core.registries.BuiltInRegistries.ITEM.getKey(stack.getItem()); + return loc != null && loc.getNamespace().equals("sophisticatedstorage"); + } catch (Exception e) { + return false; + } + } + + /** + * Extracts the contents UUID from an item's custom data (used by Sophisticated Core). + * Both Sophisticated Backpacks and Sophisticated Storage store a "contentsUuid" in the item's NBT. + */ + private static UUID extractContentsUuid(ItemStack stack) { + try { + if (!stack.has(DataComponents.CUSTOM_DATA)) return null; + CustomData customData = stack.get(DataComponents.CUSTOM_DATA); + if (customData == null) return null; + CompoundTag tag = customData.copyTag(); + if (tag.hasUUID("contentsUuid")) { + return tag.getUUID("contentsUuid"); + } + // Some versions use a string format + if (tag.contains("contentsUuid")) { + try { + return UUID.fromString(tag.getString("contentsUuid")); + } catch (IllegalArgumentException ignored) {} + } + } catch (Exception e) { + PlayerSync.LOGGER.debug("Could not extract contentsUuid from item: {}", e.getMessage()); + } + return null; + } } diff --git a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java index 98e04b0..ec17b64 100644 --- a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java +++ b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java @@ -99,6 +99,36 @@ public class JDBCsetUp { } } + /** + * Executes a parameterized update using PreparedStatement with proper escaping. + * This prevents SQL injection and data corruption from special characters in values. + */ + public static void executePreparedUpdate(String sql, Object... params) throws SQLException { + LOGGER.trace(sql); + try (Connection connection = getConnection(); + PreparedStatement stmt = connection.prepareStatement(sql)) { + for (int i = 0; i < params.length; i++) { + stmt.setObject(i + 1, params[i]); + } + stmt.executeUpdate(); + } + } + + /** + * Executes a parameterized query using PreparedStatement with proper escaping. + * Caller MUST close the returned QueryResult (use try-with-resources). + */ + public static QueryResult executePreparedQuery(String sql, Object... params) throws SQLException { + LOGGER.trace(sql); + Connection connection = getConnection(); + PreparedStatement stmt = connection.prepareStatement(sql); + for (int i = 0; i < params.length; i++) { + stmt.setObject(i + 1, params[i]); + } + ResultSet rs = stmt.executeQuery(); + return new QueryResult(connection, stmt, rs); + } + public record QueryResult(Connection connection,PreparedStatement preparedStatement, ResultSet resultSet) implements AutoCloseable { @Override public void close() {