Skip to content

Commit 2ea60ed

Browse files
committed
fix(config): read a legacy numeric-keyed object as a list in getList
An old storage layout persisted a list as an object keyed by throwaway numeric indexes ('0','1','2',...) instead of a real sequence. getList returned an empty list for that shape, which silently dropped the data on the next save. readList now derives its elements via listElements: array items, or - read-only tolerance - the values of an all-numeric-keyed object in index order. The all-numeric gate keeps a genuine Map/POJO/mixed-key object from being mistaken for a list; @KeyIndex lists short-circuit earlier. Writing is unchanged, so a loaded legacy list is migrated to the modern array on the next save. Covered across all four codecs in AbstractConfigTest (read, the non-numeric/mixed-key guard, and migrate-on-save).
1 parent 08b6cbc commit 2ea60ed

2 files changed

Lines changed: 60 additions & 5 deletions

File tree

src/main/java/br/com/finalcraft/everyconfig/config/Config.java

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import java.util.List;
5151
import java.util.Map;
5252
import java.util.Set;
53+
import java.util.TreeMap;
5354
import java.util.UUID;
5455
import java.util.concurrent.CompletableFuture;
5556
import java.util.concurrent.locks.ReentrantLock;
@@ -712,9 +713,10 @@ public <T> BindResult<List<T>> getListResult(final String path, final Class<T> e
712713
/**
713714
* Read the list at {@code path}. When {@code elementType} carries {@code @KeyIndex} AND the stored node is
714715
* an object, it is read as a key-major section (the section key is the id authority); otherwise it is read
715-
* as a plain array. Lenient: an unbindable array element is skipped, and an indexed element whose body id
716-
* disagrees with its section key is reconciled to the key. {@code issues}, when non-null, collects the
717-
* indexed-read reconciliations.
716+
* as an array, with a read-only tolerance for the legacy numeric-keyed object shape (see
717+
* {@link #listElements}). Lenient: an unbindable array element is skipped, and an indexed element whose
718+
* body id disagrees with its section key is reconciled to the key. {@code issues}, when non-null, collects
719+
* the indexed-read reconciliations.
718720
*/
719721
private <T> List<T> readList(final String path, final Class<T> elementType, final Codec codec,
720722
final List<LoadIssue> issues) {
@@ -736,10 +738,11 @@ private <T> List<T> readList(final String path, final Class<T> elementType, fina
736738
return ElementStringList.fromArray(node, elementType, mapper, compact);
737739
}
738740
final List<T> out = new ArrayList<>();
739-
if (!(node instanceof ArrayNode)) {
741+
final List<JsonNode> elements = listElements(node);
742+
if (elements == null) {
740743
return out;
741744
}
742-
for (final JsonNode element : node) {
745+
for (final JsonNode element : elements) {
743746
try {
744747
out.add(mapper.convertValue(element, elementType));
745748
} catch (final IllegalArgumentException badElement) {
@@ -750,6 +753,58 @@ private <T> List<T> readList(final String path, final Class<T> elementType, fina
750753
return out;
751754
}
752755

756+
/**
757+
* The elements to read as a list from {@code node}: the items of an array, or — for backward
758+
* compatibility with the old FinalConfig storage, which persisted a list as an object keyed by
759+
* throwaway numeric indexes ('0','1','2',...) rather than a real sequence — that object's values in
760+
* index order. Read-only tolerance: {@code save()} always re-emits the modern array form, so a loaded
761+
* legacy list is migrated on the next write. Returns null when {@code node} is neither shape, so the
762+
* caller yields an empty list. The gate is all-numeric keys, which a genuine {@code Map}/POJO never has,
763+
* so nothing but the legacy layout is reinterpreted; {@code @KeyIndex} lists are handled earlier and
764+
* never reach here.
765+
*/
766+
private static List<JsonNode> listElements(final JsonNode node) {
767+
if (node instanceof ArrayNode) {
768+
final List<JsonNode> items = new ArrayList<>();
769+
for (final JsonNode element : node) {
770+
items.add(element);
771+
}
772+
return items;
773+
}
774+
if (node instanceof ObjectNode && node.size() > 0) {
775+
final TreeMap<Integer, JsonNode> byIndex = new TreeMap<>();
776+
final Iterator<Map.Entry<String, JsonNode>> it = ((ObjectNode) node).fields();
777+
while (it.hasNext()) {
778+
final Map.Entry<String, JsonNode> field = it.next();
779+
final Integer index = asIndexKey(field.getKey());
780+
if (index == null) {
781+
return null; // a non-numeric key means this is not a legacy indexed list
782+
}
783+
byIndex.put(index, field.getValue());
784+
}
785+
return new ArrayList<>(byIndex.values());
786+
}
787+
return null;
788+
}
789+
790+
/** A non-negative decimal index key ('0','1',...) as an int, or null when the key is not one. */
791+
private static Integer asIndexKey(final String key) {
792+
if (key.isEmpty()) {
793+
return null;
794+
}
795+
for (int i = 0; i < key.length(); i++) {
796+
final char ch = key.charAt(i);
797+
if (ch < '0' || ch > '9') {
798+
return null;
799+
}
800+
}
801+
try {
802+
return Integer.valueOf(key);
803+
} catch (final NumberFormatException tooManyDigits) {
804+
return null; // an absurdly long digit run is not a real list index
805+
}
806+
}
807+
753808
/**
754809
* Fire nested {@code @PostLoad} for the elements of a list just read (each at {@code path[i]} for a plain
755810
* list, or {@code path.<idValue>} for a {@code @KeyIndex} list), plus each element's descendants — the
Binary file not shown.

0 commit comments

Comments
 (0)