From a6a2abfae538e6dec66ec368c9a250a9d1a0d2c6 Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:20:56 +0200 Subject: [PATCH 1/6] feat(api): add material filters to typed block lookup --- docs/api/version/v13.md | 4 +- .../java/net/coreprotect/api/BlockAPI.java | 3 ++ .../net/coreprotect/api/LookupFilter.java | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index 3d71c3b79..4798d5a00 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -10,7 +10,7 @@ API version 13 adds entity-spawn lookup support while retaining all API version ## Upgrading from API v12 -- `LookupOptions` supports material inclusion/exclusion filters for typed container, item, and inventory lookups, and `users(List)` / `excludeUsers(List)` filters for all typed lookups. +- `LookupOptions` supports material inclusion/exclusion filters for typed block, container, item, and inventory lookups, and `users(List)` / `excludeUsers(List)` filters for all typed lookups. - `LookupOptions` supports `blockActions(List)`, `containerActions(List)`, `itemActions(List)`, `inventoryActions(List)`, and `sessionActions(List)` to select events returned by `blockLookup`, `containerLookup`, `itemLookup`, `inventoryLookup`, and `sessionLookup`, respectively. Each filter only affects its corresponding lookup. Select multiple actions to match any of them, or omit the filter or pass an empty list to keep the default results. - Added `CoreProtectAction.ENTITY_SPAWN` with action ID `13`. - Added `CoreProtectPreLogEvent.Action.ENTITY_SPAWN`. @@ -29,6 +29,8 @@ Entity-container location filters match either the transaction's immutable origi Use `blockActions(List.of(BlockAction.BREAK))` to find broken blocks, or select `BlockAction.PLACE` or `BlockAction.INTERACTION` for placements or interactions. Use `sessionActions(List.of(SessionAction.LOGIN))` for player logins or `SessionAction.LOGOUT` for logouts. +Block material filters match the logged block material, not the block currently at that location. Including materials returns only matching block events; excluding materials leaves entity events unchanged. + To find items deposited into containers, use `containerActions(List.of(ContainerAction.ADD))`. For inventory lookups, select `InventoryAction.CONTAINER_ADD` for deposits or `InventoryAction.BLOCK_PLACE` for block placement. Container filters also cover tracked boat and minecart transactions. For example, `itemActions(List.of(ItemAction.DROP, ItemAction.PICKUP))` selects dropped and picked-up items. To query `ItemAction.BREAK`, `DESTROY`, `CREATE`, `SELL`, or `BUY`, select them explicitly with `itemActions`; these events are not included by default. diff --git a/src/main/java/net/coreprotect/api/BlockAPI.java b/src/main/java/net/coreprotect/api/BlockAPI.java index a33c2c106..7f625003d 100644 --- a/src/main/java/net/coreprotect/api/BlockAPI.java +++ b/src/main/java/net/coreprotect/api/BlockAPI.java @@ -163,6 +163,9 @@ public static List performLookup(Block block, LookupOptions options query.append(" AND ").append(ConfigHandler.databaseType.getUserColumn()).append(" = ?"); } LookupFilter.appendUserWhere(query, "", LookupFilter.userIds(connection, options.getUsers()), LookupFilter.userIds(connection, options.getExcludeUsers())); + if (!options.getIncludeMaterials().isEmpty() || !options.getExcludeMaterials().isEmpty()) { + LookupFilter.fromOptions(connection, options).appendBlockMaterialWhere(query); + } LookupFilter.appendActionWhere(query, "", options.getBlockActions().stream().mapToInt(BlockAction::id).toArray()); query.append(" ORDER BY ").append(ConfigHandler.getDescendingEventOrder()); if (options.hasLimit()) { diff --git a/src/main/java/net/coreprotect/api/LookupFilter.java b/src/main/java/net/coreprotect/api/LookupFilter.java index 5b45610cd..4a99ffea8 100644 --- a/src/main/java/net/coreprotect/api/LookupFilter.java +++ b/src/main/java/net/coreprotect/api/LookupFilter.java @@ -7,6 +7,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.StringJoiner; @@ -17,8 +18,10 @@ import net.coreprotect.database.DuckDBLookupQuery; import net.coreprotect.database.DuckDBSpatialIndex; import net.coreprotect.database.LocationQuery; +import net.coreprotect.model.action.LookupActions; import net.coreprotect.utility.ItemUtils; import net.coreprotect.utility.MaterialUtils; +import net.coreprotect.utility.StringUtils; import net.coreprotect.utility.WorldUtils; final class LookupFilter { @@ -196,6 +199,44 @@ void appendMaterialWhere(StringBuilder query, String alias, boolean inventoryBlo } } + void appendBlockMaterialWhere(StringBuilder query) { + String entityActions = LookupActions.ENTITY_KILL + "," + LookupActions.ENTITY_SPAWN; + if (!includeMaterials.isEmpty()) { + query.append(" AND action NOT IN (").append(entityActions).append(") AND ").append(blockMaterialPredicate(includeMaterials)); + } + if (!excludeMaterials.isEmpty()) { + query.append(" AND (action IN (").append(entityActions).append(") OR NOT ").append(blockMaterialPredicate(excludeMaterials)).append(")"); + } + } + + private String blockMaterialPredicate(List materials) { + StringJoiner ids = new StringJoiner(","); + StringJoiner predicates = new StringJoiner(" OR ", "(", ")"); + boolean includeStone = materials.contains(Material.STONE); + StringJoiner stoneData = new StringJoiner(","); + for (int data = 1; data <= 6; data++) { + Material material = Material.getMaterial(StringUtils.nameFilter("stone", data).toUpperCase(Locale.ROOT)); + if (materials.contains(material) != includeStone) { + stoneData.add(String.valueOf(data)); + } + } + for (Map.Entry entry : materialTypes.entrySet()) { + if (entry.getValue() == Material.STONE) { + if (stoneData.length() > 0) { + predicates.add("(type = " + entry.getKey() + " AND COALESCE(data,0)" + (includeStone ? " NOT IN (" : " IN (") + stoneData + "))"); + } + else if (includeStone) { + ids.add(String.valueOf(entry.getKey())); + } + } + else if (entry.getValue() != null && materials.contains(entry.getValue())) { + ids.add(String.valueOf(entry.getKey())); + } + } + predicates.add("type IN (" + (ids.length() == 0 ? "-1" : ids.toString()) + ")"); + return predicates.toString(); + } + String table(Connection connection, String table, String alias) { if (location == null) { return ConfigHandler.prefix + table + alias(alias); From 817ded89bdaad50891515e9ff5f1f77c76662610 Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:31:52 +0200 Subject: [PATCH 2/6] feat(api): support scoped typed block lookups --- docs/api/version/v13.md | 2 + .../java/net/coreprotect/CoreProtectAPI.java | 16 +++- .../java/net/coreprotect/api/BlockAPI.java | 76 ++++++++----------- .../net/coreprotect/api/LookupFilter.java | 29 +++++-- .../net/coreprotect/api/LookupOptions.java | 17 +++++ 5 files changed, 91 insertions(+), 49 deletions(-) diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index 4798d5a00..0d2f25dc5 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -11,6 +11,8 @@ API version 13 adds entity-spawn lookup support while retaining all API version ## Upgrading from API v12 - `LookupOptions` supports material inclusion/exclusion filters for typed block, container, item, and inventory lookups, and `users(List)` / `excludeUsers(List)` filters for all typed lookups. +- Added `blockLookup(LookupOptions)` for block history at a location, within a radius, in a world, or across all worlds. `blockLookup(Block, LookupOptions)` continues to use the supplied block's coordinates. +- `LookupOptions.world(World)` selects a whole world for spatial typed lookups. The last call to `world`, `location`, or `radius` determines the search area. Username history has no world, so this option is ignored by `usernameLookup`. - `LookupOptions` supports `blockActions(List)`, `containerActions(List)`, `itemActions(List)`, `inventoryActions(List)`, and `sessionActions(List)` to select events returned by `blockLookup`, `containerLookup`, `itemLookup`, `inventoryLookup`, and `sessionLookup`, respectively. Each filter only affects its corresponding lookup. Select multiple actions to match any of them, or omit the filter or pass an empty list to keep the default results. - Added `CoreProtectAction.ENTITY_SPAWN` with action ID `13`. - Added `CoreProtectPreLogEvent.Action.ENTITY_SPAWN`. diff --git a/src/main/java/net/coreprotect/CoreProtectAPI.java b/src/main/java/net/coreprotect/CoreProtectAPI.java index 9e5e0f74f..5da2c3032 100755 --- a/src/main/java/net/coreprotect/CoreProtectAPI.java +++ b/src/main/java/net/coreprotect/CoreProtectAPI.java @@ -136,7 +136,7 @@ public List blockLookup(Block block, int time) { * @param block * The block to look up * @param options - * Lookup options. User, time, and limit are applied; location and radius are ignored because the block supplies the exact location. + * Lookup options. World, location, and radius are ignored because the block supplies the exact location. * @return List of results or null if API is disabled */ public List blockLookup(Block block, LookupOptions options) { @@ -146,6 +146,20 @@ public List blockLookup(Block block, LookupOptions options) { return null; } + /** + * Performs a typed block lookup using shared lookup options. + * + * @param options + * Lookup options + * @return List of results or null if API is disabled + */ + public List blockLookup(LookupOptions options) { + if (isEnabled()) { + return BlockAPI.performLookup(options); + } + return null; + } + /** * Performs a lookup on the queue data for the specified block. * diff --git a/src/main/java/net/coreprotect/api/BlockAPI.java b/src/main/java/net/coreprotect/api/BlockAPI.java index 7f625003d..bb8475903 100644 --- a/src/main/java/net/coreprotect/api/BlockAPI.java +++ b/src/main/java/net/coreprotect/api/BlockAPI.java @@ -111,17 +111,36 @@ public static List performLookup(Block block, int offset) { * @param block * The block to look up * @param options - * Lookup options. User, time, and limit are applied; location and radius are ignored because the block supplies the exact location. + * Lookup options. World, location, and radius are ignored because the block supplies the exact location. * @return List of results in a BlockResult format */ public static List performLookup(Block block, LookupOptions options) { - List result = new ArrayList<>(); + if (!Config.getGlobal().API_ENABLED || block == null || block.getWorld() == null) { + return new ArrayList<>(); + } - if (!Config.getGlobal().API_ENABLED) { - return result; + if (options == null) { + options = LookupOptions.builder().build(); } - if (block == null || block.getWorld() == null) { + return performLookup(LookupOptions.builder().location(block.getLocation()) + .user(options.getUser()).users(options.getUsers()).excludeUsers(options.getExcludeUsers()) + .time(options.getTime()).limit(options.getLimitOffset(), options.getLimitCount()) + .includeMaterials(options.getIncludeMaterials()).excludeMaterials(options.getExcludeMaterials()) + .blockActions(options.getBlockActions()).build()); + } + + /** + * Performs a typed lookup of block-related actions using shared lookup options. + * + * @param options + * Lookup options + * @return List of results in a BlockResult format + */ + public static List performLookup(LookupOptions options) { + List result = new ArrayList<>(); + + if (!Config.getGlobal().API_ENABLED) { return result; } @@ -134,57 +153,28 @@ public static List performLookup(Block block, LookupOptions options return result; } - Integer userId = MessageAPI.getUserId(connection, options.getUser()); - if (userId != null && userId == -1) { + LookupFilter filter = LookupFilter.fromOptions(connection, options); + if (filter.hasInvalidUser() || filter.hasInvalidLocation()) { return result; } - int checkTime = 0; - if (options.getTime() > 0) { - checkTime = (int) (System.currentTimeMillis() / 1000L) - options.getTime(); - } - - int x = block.getX(); - int y = block.getY(); - int z = block.getZ(); - String worldName = block.getWorld().getName(); - int worldId = WorldUtils.getWorldId(worldName); - StringBuilder query = new StringBuilder("SELECT time," + ConfigHandler.databaseType.getUserColumn() + ",action,type,data,blockdata,rolled_back,wid,x,y,z FROM "); - query.append(DuckDBLookupQuery.spatialTable(connection, "block", worldId, x, x, z, z, "spatial_rows")).append(' '); - if (!ConfigHandler.databaseType.isDuckDB()) { + query.append(filter.table(connection, "block", "")).append(' '); + if (filter.hasLocation() && !ConfigHandler.databaseType.isDuckDB()) { query.append(WorldUtils.getWidIndex("block")); } - query.append("WHERE ").append(LocationQuery.predicate("wid", " = ?")) - .append(" AND ").append(LocationQuery.predicate("x", " = ?")) - .append(" AND ").append(LocationQuery.predicate("z", " = ?")) - .append(" AND y = ? AND time > ?"); - if (userId != null) { - query.append(" AND ").append(ConfigHandler.databaseType.getUserColumn()).append(" = ?"); - } - LookupFilter.appendUserWhere(query, "", LookupFilter.userIds(connection, options.getUsers()), LookupFilter.userIds(connection, options.getExcludeUsers())); - if (!options.getIncludeMaterials().isEmpty() || !options.getExcludeMaterials().isEmpty()) { - LookupFilter.fromOptions(connection, options).appendBlockMaterialWhere(query); - } + filter.appendWhere(query); + filter.appendBlockMaterialWhere(query); LookupFilter.appendActionWhere(query, "", options.getBlockActions().stream().mapToInt(BlockAction::id).toArray()); query.append(" ORDER BY ").append(ConfigHandler.getDescendingEventOrder()); - if (options.hasLimit()) { - query.append(" LIMIT ").append(options.getLimitCount()).append(" OFFSET ").append(options.getLimitOffset()); - } + filter.appendLimit(query); try (PreparedStatement statement = connection.prepareStatement(query.toString())) { - statement.setInt(1, worldId); - statement.setInt(2, x); - statement.setInt(3, z); - statement.setInt(4, y); - statement.setInt(5, checkTime); - if (userId != null) { - statement.setInt(6, userId); - } + filter.bind(statement); try (ResultSet results = statement.executeQuery()) { while (results.next()) { - result.add(parseBlockResult(connection, results, worldName)); + result.add(parseBlockResult(connection, results, WorldUtils.getWorldName(results.getInt("wid")))); } } } diff --git a/src/main/java/net/coreprotect/api/LookupFilter.java b/src/main/java/net/coreprotect/api/LookupFilter.java index 4a99ffea8..96a357ac8 100644 --- a/src/main/java/net/coreprotect/api/LookupFilter.java +++ b/src/main/java/net/coreprotect/api/LookupFilter.java @@ -13,6 +13,7 @@ import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.World; import net.coreprotect.config.ConfigHandler; import net.coreprotect.database.DuckDBLookupQuery; @@ -28,6 +29,7 @@ final class LookupFilter { private final Integer userId; private final int checkTime; private final Location location; + private final World world; private final int radius; private final int limitOffset; private final int limitCount; @@ -37,10 +39,11 @@ final class LookupFilter { private final String includeUserIds; private final String excludeUserIds; - private LookupFilter(Integer userId, int checkTime, Location location, int radius, int limitOffset, int limitCount, List includeMaterials, List excludeMaterials, Map materialTypes, String includeUserIds, String excludeUserIds) { + private LookupFilter(Integer userId, int checkTime, Location location, World world, int radius, int limitOffset, int limitCount, List includeMaterials, List excludeMaterials, Map materialTypes, String includeUserIds, String excludeUserIds) { this.userId = userId; this.checkTime = checkTime; this.location = location; + this.world = location == null ? world : location.getWorld(); this.radius = radius; this.limitOffset = limitOffset; this.limitCount = limitCount; @@ -72,7 +75,7 @@ static LookupFilter fromOptions(Connection connection, LookupOptions options) th } } - return new LookupFilter(userId, checkTime, options.getLocation(), options.getRadius(), options.getLimitOffset(), options.getLimitCount(), + return new LookupFilter(userId, checkTime, options.getLocation(), options.getWorld(), options.getRadius(), options.getLimitOffset(), options.getLimitCount(), options.getIncludeMaterials(), options.getExcludeMaterials(), materialTypes, userIds(connection, options.getUsers()), userIds(connection, options.getExcludeUsers())); } @@ -122,8 +125,10 @@ void appendWhere(StringBuilder query, String alias) { } appendUserWhere(query, alias, includeUserIds, excludeUserIds); - if (location != null) { + if (world != null) { query.append(" AND ").append(LocationQuery.predicate(qualifier + "wid", " = ?")); + } + if (location != null) { if (radius > 0) { query.append(" AND ").append(LocationQuery.predicate(qualifier + "x", " >= ?")) .append(" AND ").append(LocationQuery.predicate(qualifier + "x", " <= ?")) @@ -145,7 +150,12 @@ void appendEntityContainerWhere(StringBuilder query, String transactionAlias, St query.append(" AND ").append(transaction).append(ConfigHandler.databaseType.getUserColumn()).append(" = ?"); } appendUserWhere(query, transactionAlias, includeUserIds, excludeUserIds); + if (world == null) { + return; + } + if (location == null) { + query.append(" AND ((").append(LocationQuery.predicate(transaction + "wid", " = ?")).append(") OR ").append(entity).append("current_wid = ?)"); return; } @@ -339,12 +349,14 @@ int bind(PreparedStatement statement, int parameterIndex) throws Exception { statement.setInt(parameterIndex++, userId); } + if (world != null) { + statement.setInt(parameterIndex++, WorldUtils.getWorldId(world.getName())); + } + if (location != null) { int x = location.getBlockX(); int y = location.getBlockY(); int z = location.getBlockZ(); - statement.setInt(parameterIndex++, WorldUtils.getWorldId(location.getWorld().getName())); - if (radius > 0) { statement.setInt(parameterIndex++, MessageAPI.clampToInt((long) x - radius)); statement.setInt(parameterIndex++, MessageAPI.clampToInt((long) x + radius)); @@ -366,7 +378,14 @@ int bindEntityContainer(PreparedStatement statement, int parameterIndex) throws if (userId != null) { statement.setInt(parameterIndex++, userId); } + if (world == null) { + return parameterIndex; + } + if (location == null) { + int worldId = WorldUtils.getWorldId(world.getName()); + statement.setInt(parameterIndex++, worldId); + statement.setInt(parameterIndex++, worldId); return parameterIndex; } diff --git a/src/main/java/net/coreprotect/api/LookupOptions.java b/src/main/java/net/coreprotect/api/LookupOptions.java index 1b5bbe1a3..53de16a8d 100644 --- a/src/main/java/net/coreprotect/api/LookupOptions.java +++ b/src/main/java/net/coreprotect/api/LookupOptions.java @@ -4,6 +4,7 @@ import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.World; /** * Shared options for typed lookup API methods. @@ -13,6 +14,7 @@ public final class LookupOptions { private final int time; private final int radius; private final Location location; + private final World world; private final int limitOffset; private final int limitCount; private final List includeMaterials; @@ -30,6 +32,7 @@ private LookupOptions(Builder builder) { this.time = builder.time; this.radius = builder.radius; this.location = builder.location; + this.world = builder.world; this.limitOffset = builder.limitOffset; this.limitCount = builder.limitCount; this.includeMaterials = builder.includeMaterials; @@ -63,6 +66,10 @@ public Location getLocation() { return location; } + public World getWorld() { + return world; + } + public int getLimitOffset() { return limitOffset; } @@ -116,6 +123,7 @@ public static final class Builder { private int time; private int radius = -1; private Location location; + private World world; private int limitOffset = -1; private int limitCount = -1; private List includeMaterials = List.of(); @@ -143,16 +151,25 @@ public Builder time(int time) { public Builder location(Location location) { this.location = location; + this.world = null; this.radius = 0; return this; } public Builder radius(Location location, int radius) { this.location = location; + this.world = null; this.radius = radius; return this; } + public Builder world(World world) { + this.world = world; + this.location = null; + this.radius = -1; + return this; + } + public Builder limit(int offset, int count) { this.limitOffset = offset; this.limitCount = count; From ced8cac43b24590f5f4e1b472c3483ea49b89498 Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:01:03 +0200 Subject: [PATCH 3/6] refactor(api): simplify block material predicates --- .../net/coreprotect/api/LookupFilter.java | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main/java/net/coreprotect/api/LookupFilter.java b/src/main/java/net/coreprotect/api/LookupFilter.java index 96a357ac8..fe3c6df0f 100644 --- a/src/main/java/net/coreprotect/api/LookupFilter.java +++ b/src/main/java/net/coreprotect/api/LookupFilter.java @@ -220,31 +220,32 @@ void appendBlockMaterialWhere(StringBuilder query) { } private String blockMaterialPredicate(List materials) { - StringJoiner ids = new StringJoiner(","); - StringJoiner predicates = new StringJoiner(" OR ", "(", ")"); - boolean includeStone = materials.contains(Material.STONE); + List blockMaterials = new ArrayList<>(materials); + blockMaterials.removeAll(List.of(Material.STONE)); + return "(type IN (" + materialIds(blockMaterials, false) + ") OR " + legacyStonePredicate(materials) + ")"; + } + + private String legacyStonePredicate(List materials) { StringJoiner stoneData = new StringJoiner(","); for (int data = 1; data <= 6; data++) { Material material = Material.getMaterial(StringUtils.nameFilter("stone", data).toUpperCase(Locale.ROOT)); - if (materials.contains(material) != includeStone) { + if (materials.contains(material)) { stoneData.add(String.valueOf(data)); } } - for (Map.Entry entry : materialTypes.entrySet()) { - if (entry.getValue() == Material.STONE) { - if (stoneData.length() > 0) { - predicates.add("(type = " + entry.getKey() + " AND COALESCE(data,0)" + (includeStone ? " NOT IN (" : " IN (") + stoneData + "))"); - } - else if (includeStone) { - ids.add(String.valueOf(entry.getKey())); - } - } - else if (entry.getValue() != null && materials.contains(entry.getValue())) { - ids.add(String.valueOf(entry.getKey())); - } + + StringJoiner predicates = new StringJoiner(" OR "); + if (materials.contains(Material.STONE)) { + predicates.add("COALESCE(data,0) NOT BETWEEN 1 AND 6"); + } + if (stoneData.length() > 0) { + predicates.add("COALESCE(data,0) IN (" + stoneData + ")"); } - predicates.add("type IN (" + (ids.length() == 0 ? "-1" : ids.toString()) + ")"); - return predicates.toString(); + if (predicates.length() == 0) { + return "1 = 0"; + } + + return "(type IN (" + materialIds(List.of(Material.STONE), false) + ") AND (" + predicates + "))"; } String table(Connection connection, String table, String alias) { From d4aee7f1ca8d362f1b9d5b14f815864273a1946f Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:16:17 +0200 Subject: [PATCH 4/6] feat(api): add typed entity lookup and filters --- docs/api/version/v13.md | 3 + .../java/net/coreprotect/CoreProtectAPI.java | 16 +++ .../java/net/coreprotect/api/EntityAPI.java | 111 ++++++++++++++++++ .../net/coreprotect/api/EntityAction.java | 21 ++++ .../net/coreprotect/api/LookupFilter.java | 44 +++++-- .../net/coreprotect/api/LookupOptions.java | 37 ++++++ .../coreprotect/api/result/EntityResult.java | 76 ++++++++++++ 7 files changed, 297 insertions(+), 11 deletions(-) create mode 100644 src/main/java/net/coreprotect/api/EntityAPI.java create mode 100644 src/main/java/net/coreprotect/api/EntityAction.java create mode 100644 src/main/java/net/coreprotect/api/result/EntityResult.java diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index 0d2f25dc5..67b4d8f19 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -13,6 +13,7 @@ API version 13 adds entity-spawn lookup support while retaining all API version - `LookupOptions` supports material inclusion/exclusion filters for typed block, container, item, and inventory lookups, and `users(List)` / `excludeUsers(List)` filters for all typed lookups. - Added `blockLookup(LookupOptions)` for block history at a location, within a radius, in a world, or across all worlds. `blockLookup(Block, LookupOptions)` continues to use the supplied block's coordinates. - `LookupOptions.world(World)` selects a whole world for spatial typed lookups. The last call to `world`, `location`, or `radius` determines the search area. Username history has no world, so this option is ignored by `usernameLookup`. +- Added `entityLookup(LookupOptions)` returning `EntityResult` for entity spawns and kills. Use `entityActions(List)` to select `SPAWN` or `KILL`, and `includeEntities(List)` / `excludeEntities(List)` to filter entity types. Empty filters return both actions and all entity types. These filters only affect `entityLookup`. - `LookupOptions` supports `blockActions(List)`, `containerActions(List)`, `itemActions(List)`, `inventoryActions(List)`, and `sessionActions(List)` to select events returned by `blockLookup`, `containerLookup`, `itemLookup`, `inventoryLookup`, and `sessionLookup`, respectively. Each filter only affects its corresponding lookup. Select multiple actions to match any of them, or omit the filter or pass an empty list to keep the default results. - Added `CoreProtectAction.ENTITY_SPAWN` with action ID `13`. - Added `CoreProtectPreLogEvent.Action.ENTITY_SPAWN`. @@ -25,6 +26,8 @@ Entity-spawn records are available through normal lookup, rollback, and restore Radius lookups match entity spawns at either their original spawn location or their tracked current/final location. Whole-world lookups likewise match either the original or tracked current/final world, while rollback and restore selection uses the tracked current/final world. +`entityLookup` supports the existing user, time, location, radius, world, and limit options. Spawn events match either their original or persisted current/final location; kill events match their logged location. Results always return the original event coordinates and world. Material filters and other lookup-specific action filters do not affect entity lookups. No blocking live-entity scans are performed. + Command filters present boats and minecarts as block changes: `a:+block` includes placements, `a:-block` includes destruction, and `a:block` includes both. The corresponding `a:spawn` and `a:kill` filters exclude those aliased vehicle records. This is a command and display alias only; API results and action lists continue to identify the rows as `CoreProtectAction.ENTITY_SPAWN` (action ID `13`) and `CoreProtectAction.ENTITY_KILL` (action ID `3`). Entity-container location filters match either the transaction's immutable original location or the entity's persisted current/final location, and typed results expose the persisted current/final location. The persisted position is checkpointed when a transaction is logged and during relevant entity lifecycle changes; the synchronous typed APIs do not schedule blocking live-entity scans. diff --git a/src/main/java/net/coreprotect/CoreProtectAPI.java b/src/main/java/net/coreprotect/CoreProtectAPI.java index 5da2c3032..4f16bbae7 100755 --- a/src/main/java/net/coreprotect/CoreProtectAPI.java +++ b/src/main/java/net/coreprotect/CoreProtectAPI.java @@ -19,6 +19,7 @@ import org.bukkit.entity.Player; import net.coreprotect.api.BlockAPI; +import net.coreprotect.api.EntityAPI; import net.coreprotect.api.InventoryAPI; import net.coreprotect.api.ItemAPI; import net.coreprotect.api.LookupOptions; @@ -29,6 +30,7 @@ import net.coreprotect.api.UsernameAPI; import net.coreprotect.api.result.BlockResult; import net.coreprotect.api.result.ContainerResult; +import net.coreprotect.api.result.EntityResult; import net.coreprotect.api.result.InventoryResult; import net.coreprotect.api.result.ItemResult; import net.coreprotect.api.result.MessageResult; @@ -160,6 +162,20 @@ public List blockLookup(LookupOptions options) { return null; } + /** + * Performs a typed lookup of entity spawn and kill events. + * + * @param options + * Lookup options + * @return List of results in an EntityResult format + */ + public List entityLookup(LookupOptions options) { + if (isEnabled()) { + return EntityAPI.performLookup(options); + } + return null; + } + /** * Performs a lookup on the queue data for the specified block. * diff --git a/src/main/java/net/coreprotect/api/EntityAPI.java b/src/main/java/net/coreprotect/api/EntityAPI.java new file mode 100644 index 000000000..8b1f61894 --- /dev/null +++ b/src/main/java/net/coreprotect/api/EntityAPI.java @@ -0,0 +1,111 @@ +package net.coreprotect.api; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.StringJoiner; + +import org.bukkit.entity.EntityType; + +import net.coreprotect.api.result.EntityResult; +import net.coreprotect.config.Config; +import net.coreprotect.config.ConfigHandler; +import net.coreprotect.database.Database; +import net.coreprotect.database.statement.UserStatement; +import net.coreprotect.model.action.LookupActions; +import net.coreprotect.utility.ErrorReporter; +import net.coreprotect.utility.WorldUtils; + +/** + * Provides API methods for looking up entity spawn and kill events. + */ +public class EntityAPI { + + private EntityAPI() { + throw new IllegalStateException("API class"); + } + + /** + * Performs a typed entity lookup, matching original or persisted current/final spawn locations. + * + * @param options + * Lookup options + * @return List of entity results at their original event locations + */ + public static List performLookup(LookupOptions options) { + List result = new ArrayList<>(); + if (!Config.getGlobal().API_ENABLED) { + return result; + } + if (options == null) { + options = LookupOptions.builder().build(); + } + + try (Connection connection = Database.getConnection(false, 1000)) { + if (connection == null) { + return result; + } + LookupFilter filter = LookupFilter.fromOptions(connection, options); + if (filter.hasInvalidUser() || filter.hasInvalidLocation()) { + return result; + } + + boolean snapshot = filter.beginDuckDBSnapshot(connection); + try { + StringBuilder query = new StringBuilder("SELECT entity_rows.time,entity_rows." + ConfigHandler.databaseType.getUserColumn() + ",entity_rows.wid,entity_rows.x,entity_rows.y,entity_rows.z,entity_rows.type,entity_rows.action,entity_rows.rolled_back FROM "); + query.append(filter.entityTable(connection, "entity_rows")); + query.append(" LEFT JOIN ").append(ConfigHandler.prefix).append("entity_spawn spawn_rows ON entity_rows.action=").append(LookupActions.ENTITY_SPAWN) + .append(" AND spawn_rows.rowid=entity_rows.data AND spawn_rows.block_rowid=entity_rows.rowid "); + filter.appendTrackedEntityWhere(query, "entity_rows", "spawn_rows", true); + int[] actions = options.getEntityActions().isEmpty() + ? new int[] { LookupActions.ENTITY_KILL, LookupActions.ENTITY_SPAWN } + : options.getEntityActions().stream().mapToInt(EntityAction::id).toArray(); + LookupFilter.appendActionWhere(query, "entity_rows", actions); + if (!options.getIncludeEntities().isEmpty()) { + query.append(" AND ").append(entityPredicate(options.getIncludeEntities())); + } + if (!options.getExcludeEntities().isEmpty()) { + query.append(" AND NOT ").append(entityPredicate(options.getExcludeEntities())); + } + query.append(" ORDER BY ").append(ConfigHandler.getDescendingEventOrder().replace("time", "entity_rows.time").replace("rowid", "entity_rows.rowid")); + filter.appendLimit(query); + + try (PreparedStatement statement = connection.prepareStatement(query.toString())) { + filter.bindTrackedEntity(statement, 1); + try (ResultSet results = statement.executeQuery()) { + while (results.next()) { + result.add(new EntityResult( + results.getLong("time"), UserStatement.getName(connection, results.getInt("user")), WorldUtils.getWorldName(results.getInt("wid")), + results.getInt("x"), results.getInt("y"), results.getInt("z"), results.getInt("type"), results.getInt("action"), results.getInt("rolled_back") + )); + } + } + } + } + finally { + filter.endDuckDBSnapshot(connection, snapshot); + } + } + catch (Exception e) { + ErrorReporter.report(e); + } + return result; + } + + private static String entityPredicate(List entities) { + StringJoiner names = new StringJoiner(","); + for (EntityType entity : entities) { + String name = entity.name().toLowerCase(Locale.ROOT); + names.add("'" + name + "'"); + names.add("'minecraft:" + name + "'"); + } + String predicate = "entity_rows.type IN (SELECT id FROM " + ConfigHandler.prefix + "entity_map WHERE LOWER(entity) IN (" + names + "))"; + if (entities.contains(EntityType.PLAYER)) { + predicate += " OR (entity_rows.action=" + LookupActions.ENTITY_KILL + " AND entity_rows.type=0)"; + } + return "(" + predicate + ")"; + } +} diff --git a/src/main/java/net/coreprotect/api/EntityAction.java b/src/main/java/net/coreprotect/api/EntityAction.java new file mode 100644 index 000000000..79e51e6bd --- /dev/null +++ b/src/main/java/net/coreprotect/api/EntityAction.java @@ -0,0 +1,21 @@ +package net.coreprotect.api; + +import net.coreprotect.model.action.LookupActions; + +/** + * Entity actions used by typed entity lookup filters. + */ +public enum EntityAction { + KILL(LookupActions.ENTITY_KILL), + SPAWN(LookupActions.ENTITY_SPAWN); + + private final int id; + + EntityAction(int id) { + this.id = id; + } + + public int id() { + return id; + } +} diff --git a/src/main/java/net/coreprotect/api/LookupFilter.java b/src/main/java/net/coreprotect/api/LookupFilter.java index fe3c6df0f..c819fd328 100644 --- a/src/main/java/net/coreprotect/api/LookupFilter.java +++ b/src/main/java/net/coreprotect/api/LookupFilter.java @@ -10,6 +10,7 @@ import java.util.Locale; import java.util.Map; import java.util.StringJoiner; +import java.util.stream.Collectors; import org.bukkit.Location; import org.bukkit.Material; @@ -143,8 +144,13 @@ void appendWhere(StringBuilder query, String alias) { } void appendEntityContainerWhere(StringBuilder query, String transactionAlias, String entityAlias) { + appendTrackedEntityWhere(query, transactionAlias, entityAlias, false); + } + + void appendTrackedEntityWhere(StringBuilder query, String transactionAlias, String entityAlias, boolean requireEntityMatch) { String transaction = transactionAlias + "."; String entity = entityAlias + "."; + String entityMatch = requireEntityMatch ? entity + "rowid > 0 AND " : ""; query.append("WHERE ").append(transaction).append("time > ?"); if (userId != null) { query.append(" AND ").append(transaction).append(ConfigHandler.databaseType.getUserColumn()).append(" = ?"); @@ -155,7 +161,7 @@ void appendEntityContainerWhere(StringBuilder query, String transactionAlias, St } if (location == null) { - query.append(" AND ((").append(LocationQuery.predicate(transaction + "wid", " = ?")).append(") OR ").append(entity).append("current_wid = ?)"); + query.append(" AND ((").append(LocationQuery.predicate(transaction + "wid", " = ?")).append(") OR (").append(entityMatch).append(entity).append("current_wid = ?))"); return; } @@ -171,7 +177,7 @@ void appendEntityContainerWhere(StringBuilder query, String transactionAlias, St .append(" AND ").append(transaction).append("y = ? AND ").append(LocationQuery.predicate(transaction + "z", " = ?")); } - query.append(") OR (").append(entity).append("current_wid = ?"); + query.append(") OR (").append(entityMatch).append(entity).append("current_wid = ?"); if (radius > 0) { query.append(" AND ").append(entity).append("x >= ? AND ").append(entity).append("x < ? AND ").append(entity).append("z >= ? AND ").append(entity).append("z < ?"); } @@ -264,8 +270,16 @@ String table(Connection connection, String table, String alias) { } String entityContainerTable(Connection connection, String alias) throws Exception { + return trackedEntityTable(connection, "entity_container", alias); + } + + String entityTable(Connection connection, String alias) throws Exception { + return trackedEntityTable(connection, "block", alias); + } + + private String trackedEntityTable(Connection connection, String table, String alias) throws Exception { if (location == null || !ConfigHandler.databaseType.isDuckDB()) { - return ConfigHandler.prefix + "entity_container" + alias(alias); + return ConfigHandler.prefix + table + alias(alias); } int x = location.getBlockX(); @@ -275,25 +289,26 @@ String entityContainerTable(Connection connection, String alias) throws Exceptio int minimumZ = radius > 0 ? MessageAPI.clampToInt((long) z - radius) : z; int maximumZ = radius > 0 ? MessageAPI.clampToInt((long) z + radius) : z; int worldId = WorldUtils.getWorldId(location.getWorld().getName()); - List entitySpawnRowIds = loadCurrentEntitySpawnRowIds(connection); + boolean block = table.equals("block"); + List rowIds = loadCurrentEntityRowIds(connection, block ? "block_rowid" : "rowid"); return DuckDBSpatialIndex.tableExpression( connection, ConfigHandler.prefix, - "entity_container", + table, worldId, minimumX, maximumX, minimumZ, maximumZ, - entitySpawnRowIds, - Collections.emptySet(), + block ? Collections.emptySet() : rowIds.stream().map(Long::intValue).collect(Collectors.toList()), + block ? rowIds : Collections.emptySet(), alias ); } - private List loadCurrentEntitySpawnRowIds(Connection connection) throws Exception { - List rowIds = new ArrayList<>(); - StringBuilder query = new StringBuilder("SELECT rowid FROM ").append(ConfigHandler.prefix).append("entity_spawn WHERE current_wid=?"); + private List loadCurrentEntityRowIds(Connection connection, String column) throws Exception { + List rowIds = new ArrayList<>(); + StringBuilder query = new StringBuilder("SELECT ").append(column).append(" FROM ").append(ConfigHandler.prefix).append("entity_spawn WHERE current_wid=?"); if (radius > 0) { query.append(" AND x>=? AND x=? AND z loadCurrentEntitySpawnRowIds(Connection connection) throws } try (ResultSet resultSet = statement.executeQuery()) { while (resultSet.next() && rowIds.size() <= 4_096) { - rowIds.add(resultSet.getInt(1)); + long rowId = resultSet.getLong(1); + if (!resultSet.wasNull()) { + rowIds.add(rowId); + } } } } @@ -375,6 +393,10 @@ int bind(PreparedStatement statement, int parameterIndex) throws Exception { } int bindEntityContainer(PreparedStatement statement, int parameterIndex) throws Exception { + return bindTrackedEntity(statement, parameterIndex); + } + + int bindTrackedEntity(PreparedStatement statement, int parameterIndex) throws Exception { statement.setInt(parameterIndex++, checkTime); if (userId != null) { statement.setInt(parameterIndex++, userId); diff --git a/src/main/java/net/coreprotect/api/LookupOptions.java b/src/main/java/net/coreprotect/api/LookupOptions.java index 53de16a8d..ab994eaf9 100644 --- a/src/main/java/net/coreprotect/api/LookupOptions.java +++ b/src/main/java/net/coreprotect/api/LookupOptions.java @@ -5,6 +5,7 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.entity.EntityType; /** * Shared options for typed lookup API methods. @@ -26,6 +27,9 @@ public final class LookupOptions { private final List inventoryActions; private final List blockActions; private final List sessionActions; + private final List entityActions; + private final List includeEntities; + private final List excludeEntities; private LookupOptions(Builder builder) { this.user = builder.user; @@ -44,6 +48,9 @@ private LookupOptions(Builder builder) { this.inventoryActions = builder.inventoryActions; this.blockActions = builder.blockActions; this.sessionActions = builder.sessionActions; + this.entityActions = builder.entityActions; + this.includeEntities = builder.includeEntities; + this.excludeEntities = builder.excludeEntities; } public static Builder builder() { @@ -118,6 +125,18 @@ public List getSessionActions() { return sessionActions; } + public List getEntityActions() { + return entityActions; + } + + public List getIncludeEntities() { + return includeEntities; + } + + public List getExcludeEntities() { + return excludeEntities; + } + public static final class Builder { private String user; private int time; @@ -135,6 +154,9 @@ public static final class Builder { private List inventoryActions = List.of(); private List blockActions = List.of(); private List sessionActions = List.of(); + private List entityActions = List.of(); + private List includeEntities = List.of(); + private List excludeEntities = List.of(); private Builder() { } @@ -221,6 +243,21 @@ public Builder sessionActions(List actions) { return this; } + public Builder entityActions(List actions) { + this.entityActions = List.copyOf(actions); + return this; + } + + public Builder includeEntities(List entities) { + this.includeEntities = List.copyOf(entities); + return this; + } + + public Builder excludeEntities(List entities) { + this.excludeEntities = List.copyOf(entities); + return this; + } + public LookupOptions build() { return new LookupOptions(this); } diff --git a/src/main/java/net/coreprotect/api/result/EntityResult.java b/src/main/java/net/coreprotect/api/result/EntityResult.java new file mode 100644 index 000000000..747170e1f --- /dev/null +++ b/src/main/java/net/coreprotect/api/result/EntityResult.java @@ -0,0 +1,76 @@ +package net.coreprotect.api.result; + +import org.bukkit.entity.EntityType; + +import net.coreprotect.model.action.LookupActions; +import net.coreprotect.utility.EntityUtils; + +/** + * Represents a logged entity spawn or kill at its original event location. + */ +public class EntityResult implements CoreProtectResult { + private final long time; + private final String username; + private final String world; + private final int x; + private final int y; + private final int z; + private final int type; + private final int actionId; + private final int rolledBack; + + public EntityResult(long time, String username, String world, int x, int y, int z, int type, int actionId, int rolledBack) { + this.time = time; + this.username = username; + this.world = world; + this.x = x; + this.y = y; + this.z = z; + this.type = type; + this.actionId = actionId; + this.rolledBack = rolledBack; + } + + public int getActionId() { + return actionId; + } + + public String getActionString() { + return LookupActions.getActionString(actionId); + } + + public String getPlayer() { + return username; + } + + public long getTimestamp() { + return time * 1000L; + } + + public EntityType getEntityType() { + if (actionId == LookupActions.ENTITY_KILL && type == 0) { + return EntityType.PLAYER; + } + return EntityUtils.getEntityType(type); + } + + public int getX() { + return x; + } + + public int getY() { + return y; + } + + public int getZ() { + return z; + } + + public boolean isRolledBack() { + return rolledBack == 1 || rolledBack == 3; + } + + public String worldName() { + return world; + } +} From 66d4fd957af9528f2c83e252595b130dc3f5160a Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:50:28 +0200 Subject: [PATCH 5/6] fix(api): separate block history and optimize entity scopes --- docs/api/version/v13.md | 4 +- .../java/net/coreprotect/CoreProtectAPI.java | 2 +- .../java/net/coreprotect/api/BlockAPI.java | 11 +++- .../java/net/coreprotect/api/EntityAPI.java | 5 +- .../net/coreprotect/api/LookupFilter.java | 50 ++++++++++++++----- 5 files changed, 51 insertions(+), 21 deletions(-) diff --git a/docs/api/version/v13.md b/docs/api/version/v13.md index 67b4d8f19..a473a0043 100644 --- a/docs/api/version/v13.md +++ b/docs/api/version/v13.md @@ -11,7 +11,7 @@ API version 13 adds entity-spawn lookup support while retaining all API version ## Upgrading from API v12 - `LookupOptions` supports material inclusion/exclusion filters for typed block, container, item, and inventory lookups, and `users(List)` / `excludeUsers(List)` filters for all typed lookups. -- Added `blockLookup(LookupOptions)` for block history at a location, within a radius, in a world, or across all worlds. `blockLookup(Block, LookupOptions)` continues to use the supplied block's coordinates. +- Added `blockLookup(LookupOptions)` for block breaks, placements, and interactions at a location, within a radius, in a world, or across all worlds. Entity events are excluded; use `entityLookup` instead. `blockLookup(Block, LookupOptions)` continues to use the supplied block's coordinates and retains entity events for compatibility. - `LookupOptions.world(World)` selects a whole world for spatial typed lookups. The last call to `world`, `location`, or `radius` determines the search area. Username history has no world, so this option is ignored by `usernameLookup`. - Added `entityLookup(LookupOptions)` returning `EntityResult` for entity spawns and kills. Use `entityActions(List)` to select `SPAWN` or `KILL`, and `includeEntities(List)` / `excludeEntities(List)` to filter entity types. Empty filters return both actions and all entity types. These filters only affect `entityLookup`. - `LookupOptions` supports `blockActions(List)`, `containerActions(List)`, `itemActions(List)`, `inventoryActions(List)`, and `sessionActions(List)` to select events returned by `blockLookup`, `containerLookup`, `itemLookup`, `inventoryLookup`, and `sessionLookup`, respectively. Each filter only affects its corresponding lookup. Select multiple actions to match any of them, or omit the filter or pass an empty list to keep the default results. @@ -34,7 +34,7 @@ Entity-container location filters match either the transaction's immutable origi Use `blockActions(List.of(BlockAction.BREAK))` to find broken blocks, or select `BlockAction.PLACE` or `BlockAction.INTERACTION` for placements or interactions. Use `sessionActions(List.of(SessionAction.LOGIN))` for player logins or `SessionAction.LOGOUT` for logouts. -Block material filters match the logged block material, not the block currently at that location. Including materials returns only matching block events; excluding materials leaves entity events unchanged. +Block material filters match the logged block material, not the block currently at that location. Including materials returns only matching block events; excluding materials leaves entity events unchanged in the existing `blockLookup(Block, LookupOptions)` overload. To find items deposited into containers, use `containerActions(List.of(ContainerAction.ADD))`. For inventory lookups, select `InventoryAction.CONTAINER_ADD` for deposits or `InventoryAction.BLOCK_PLACE` for block placement. Container filters also cover tracked boat and minecart transactions. diff --git a/src/main/java/net/coreprotect/CoreProtectAPI.java b/src/main/java/net/coreprotect/CoreProtectAPI.java index 4f16bbae7..275844f09 100755 --- a/src/main/java/net/coreprotect/CoreProtectAPI.java +++ b/src/main/java/net/coreprotect/CoreProtectAPI.java @@ -149,7 +149,7 @@ public List blockLookup(Block block, LookupOptions options) { } /** - * Performs a typed block lookup using shared lookup options. + * Performs a typed lookup of block breaks, placements, and interactions using shared lookup options. Entity events are excluded. * * @param options * Lookup options diff --git a/src/main/java/net/coreprotect/api/BlockAPI.java b/src/main/java/net/coreprotect/api/BlockAPI.java index bb8475903..867ec456c 100644 --- a/src/main/java/net/coreprotect/api/BlockAPI.java +++ b/src/main/java/net/coreprotect/api/BlockAPI.java @@ -127,7 +127,7 @@ public static List performLookup(Block block, LookupOptions options .user(options.getUser()).users(options.getUsers()).excludeUsers(options.getExcludeUsers()) .time(options.getTime()).limit(options.getLimitOffset(), options.getLimitCount()) .includeMaterials(options.getIncludeMaterials()).excludeMaterials(options.getExcludeMaterials()) - .blockActions(options.getBlockActions()).build()); + .blockActions(options.getBlockActions()).build(), false); } /** @@ -138,6 +138,10 @@ public static List performLookup(Block block, LookupOptions options * @return List of results in a BlockResult format */ public static List performLookup(LookupOptions options) { + return performLookup(options, true); + } + + private static List performLookup(LookupOptions options, boolean blocksOnly) { List result = new ArrayList<>(); if (!Config.getGlobal().API_ENABLED) { @@ -165,7 +169,10 @@ public static List performLookup(LookupOptions options) { } filter.appendWhere(query); filter.appendBlockMaterialWhere(query); - LookupFilter.appendActionWhere(query, "", options.getBlockActions().stream().mapToInt(BlockAction::id).toArray()); + int[] actions = blocksOnly && options.getBlockActions().isEmpty() + ? new int[] { BlockAction.BREAK.id(), BlockAction.PLACE.id(), BlockAction.INTERACTION.id() } + : options.getBlockActions().stream().mapToInt(BlockAction::id).toArray(); + LookupFilter.appendActionWhere(query, "", actions); query.append(" ORDER BY ").append(ConfigHandler.getDescendingEventOrder()); filter.appendLimit(query); diff --git a/src/main/java/net/coreprotect/api/EntityAPI.java b/src/main/java/net/coreprotect/api/EntityAPI.java index 8b1f61894..5a089e236 100644 --- a/src/main/java/net/coreprotect/api/EntityAPI.java +++ b/src/main/java/net/coreprotect/api/EntityAPI.java @@ -57,9 +57,8 @@ public static List performLookup(LookupOptions options) { try { StringBuilder query = new StringBuilder("SELECT entity_rows.time,entity_rows." + ConfigHandler.databaseType.getUserColumn() + ",entity_rows.wid,entity_rows.x,entity_rows.y,entity_rows.z,entity_rows.type,entity_rows.action,entity_rows.rolled_back FROM "); query.append(filter.entityTable(connection, "entity_rows")); - query.append(" LEFT JOIN ").append(ConfigHandler.prefix).append("entity_spawn spawn_rows ON entity_rows.action=").append(LookupActions.ENTITY_SPAWN) - .append(" AND spawn_rows.rowid=entity_rows.data AND spawn_rows.block_rowid=entity_rows.rowid "); - filter.appendTrackedEntityWhere(query, "entity_rows", "spawn_rows", true); + query.append(' '); + filter.appendEntityWhere(connection, query, "entity_rows"); int[] actions = options.getEntityActions().isEmpty() ? new int[] { LookupActions.ENTITY_KILL, LookupActions.ENTITY_SPAWN } : options.getEntityActions().stream().mapToInt(EntityAction::id).toArray(); diff --git a/src/main/java/net/coreprotect/api/LookupFilter.java b/src/main/java/net/coreprotect/api/LookupFilter.java index c819fd328..dc3a2ac99 100644 --- a/src/main/java/net/coreprotect/api/LookupFilter.java +++ b/src/main/java/net/coreprotect/api/LookupFilter.java @@ -144,13 +144,16 @@ void appendWhere(StringBuilder query, String alias) { } void appendEntityContainerWhere(StringBuilder query, String transactionAlias, String entityAlias) { - appendTrackedEntityWhere(query, transactionAlias, entityAlias, false); + appendTrackedEntityWhere(query, transactionAlias, entityAlias, null); } - void appendTrackedEntityWhere(StringBuilder query, String transactionAlias, String entityAlias, boolean requireEntityMatch) { + void appendEntityWhere(Connection connection, StringBuilder query, String alias) { + appendTrackedEntityWhere(query, alias, "spawn_rows", table(connection, "block", "original_rows")); + } + + private void appendTrackedEntityWhere(StringBuilder query, String transactionAlias, String entityAlias, String originalTable) { String transaction = transactionAlias + "."; String entity = entityAlias + "."; - String entityMatch = requireEntityMatch ? entity + "rowid > 0 AND " : ""; query.append("WHERE ").append(transaction).append("time > ?"); if (userId != null) { query.append(" AND ").append(transaction).append(ConfigHandler.databaseType.getUserColumn()).append(" = ?"); @@ -160,31 +163,52 @@ void appendTrackedEntityWhere(StringBuilder query, String transactionAlias, Stri return; } + String original = originalTable == null ? transaction : "original_rows."; + String tracked = ") OR ("; + String ending = "))"; + if (originalTable != null) { + String trackedRows = "SELECT " + entity + "block_rowid FROM " + ConfigHandler.prefix + "entity_spawn " + entityAlias + + " INNER JOIN " + ConfigHandler.prefix + "block linked_rows ON linked_rows.rowid=" + entity + "block_rowid AND linked_rows.data=" + entity + "rowid" + + " AND linked_rows.action=" + LookupActions.ENTITY_SPAWN + " WHERE ("; + if (location == null) { + query.append(" AND (").append(LocationQuery.predicate(transaction + "wid", " = ?")) + .append(" OR ").append(transaction).append("rowid IN (").append(trackedRows).append(entity).append("current_wid = ?)))"); + return; + } + // Separate location candidates keep both spatial indexes usable, including on MySQL. + query.append(" AND ").append(transaction).append("rowid IN (SELECT rowid FROM (SELECT original_rows.rowid FROM ").append(originalTable).append(" WHERE ("); + tracked = ") UNION ALL " + trackedRows; + ending = ")) entity_locations)"; + } + else { + query.append(" AND (("); + } + + query.append(LocationQuery.predicate(original + "wid", " = ?")); if (location == null) { - query.append(" AND ((").append(LocationQuery.predicate(transaction + "wid", " = ?")).append(") OR (").append(entityMatch).append(entity).append("current_wid = ?))"); + query.append(tracked).append(entity).append("current_wid = ?").append(ending); return; } - query.append(" AND ((").append(LocationQuery.predicate(transaction + "wid", " = ?")); if (radius > 0) { - query.append(" AND ").append(LocationQuery.predicate(transaction + "x", " >= ?")) - .append(" AND ").append(LocationQuery.predicate(transaction + "x", " <= ?")) - .append(" AND ").append(LocationQuery.predicate(transaction + "z", " >= ?")) - .append(" AND ").append(LocationQuery.predicate(transaction + "z", " <= ?")); + query.append(" AND ").append(LocationQuery.predicate(original + "x", " >= ?")) + .append(" AND ").append(LocationQuery.predicate(original + "x", " <= ?")) + .append(" AND ").append(LocationQuery.predicate(original + "z", " >= ?")) + .append(" AND ").append(LocationQuery.predicate(original + "z", " <= ?")); } else { - query.append(" AND ").append(LocationQuery.predicate(transaction + "x", " = ?")) - .append(" AND ").append(transaction).append("y = ? AND ").append(LocationQuery.predicate(transaction + "z", " = ?")); + query.append(" AND ").append(LocationQuery.predicate(original + "x", " = ?")) + .append(" AND ").append(original).append("y = ? AND ").append(LocationQuery.predicate(original + "z", " = ?")); } - query.append(") OR (").append(entityMatch).append(entity).append("current_wid = ?"); + query.append(tracked).append(entity).append("current_wid = ?"); if (radius > 0) { query.append(" AND ").append(entity).append("x >= ? AND ").append(entity).append("x < ? AND ").append(entity).append("z >= ? AND ").append(entity).append("z < ?"); } else { query.append(" AND ").append(entity).append("x >= ? AND ").append(entity).append("x < ? AND ").append(entity).append("y >= ? AND ").append(entity).append("y < ? AND ").append(entity).append("z >= ? AND ").append(entity).append("z < ?"); } - query.append("))"); + query.append(ending); } static void appendActionWhere(StringBuilder query, String alias, int[] actions) { From bfebbda168e230acc5a36a4535d1bb1558266c78 Mon Sep 17 00:00:00 2001 From: Mykhailo Alipa <6442572+strobil@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:05:23 +0200 Subject: [PATCH 6/6] fix(api): cast ClickHouse entity tracking IDs safely --- src/main/java/net/coreprotect/api/LookupFilter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/coreprotect/api/LookupFilter.java b/src/main/java/net/coreprotect/api/LookupFilter.java index dc3a2ac99..ee19ee30d 100644 --- a/src/main/java/net/coreprotect/api/LookupFilter.java +++ b/src/main/java/net/coreprotect/api/LookupFilter.java @@ -167,8 +167,9 @@ private void appendTrackedEntityWhere(StringBuilder query, String transactionAli String tracked = ") OR ("; String ending = "))"; if (originalTable != null) { + String trackingId = ConfigHandler.databaseType.isClickHouse() ? "accurateCastOrNull(linked_rows.data, 'UInt64')" : "linked_rows.data"; String trackedRows = "SELECT " + entity + "block_rowid FROM " + ConfigHandler.prefix + "entity_spawn " + entityAlias - + " INNER JOIN " + ConfigHandler.prefix + "block linked_rows ON linked_rows.rowid=" + entity + "block_rowid AND linked_rows.data=" + entity + "rowid" + + " INNER JOIN " + ConfigHandler.prefix + "block linked_rows ON linked_rows.rowid=" + entity + "block_rowid AND " + trackingId + "=" + entity + "rowid" + " AND linked_rows.action=" + LookupActions.ENTITY_SPAWN + " WHERE ("; if (location == null) { query.append(" AND (").append(LocationQuery.predicate(transaction + "wid", " = ?"))