Skip to content

KeyIndex Collections

Pétrus Pradella edited this page Oct 23, 2026 · 6 revisions

@KeyIndex Collections

A collection of entities, each carrying a @KeyIndex field, is stored as a section keyed by the index value instead of a positional array. The index value becomes the key and is omitted from each entry's body; on read the section key is the sole authority for the index.

This is automatic: setValue detects that the element type carries @KeyIndex and writes the keyed layout, and getList detects it (plus the stored node's shape) and reads it back. There are no dedicated methods.

Write & read

class Account {
    @KeyIndex public String name;
    public int balance;

    public Account() {}
    public Account(String name, int balance) { this.name = name; this.balance = balance; }
}

cfg.setValue("accounts", Arrays.asList(
        new Account("alice", 100),
        new Account("bob", 50)));   // auto key-major: Account carries @KeyIndex

cfg.save();
accounts:
  alice:
    balance: 100
  bob:
    balance: 50
List<Account> back = cfg.getList("accounts", Account.class);
// back = [Account(alice, 100), Account(bob, 50)] — ids restored from the section keys

A collection whose element type does not carry @KeyIndex is stored as an ordinary array. On read, getList discriminates by the stored node's shape: an object section is read key-major, a plain array is read positionally — so a @KeyIndex type stored (legacy) as an array still binds, ids coming from the body.

As a field of a bean

A List, Set or array field whose element type carries @KeyIndex gets the same layout when the bean is bound, at the root or nested, so a block of named entries lives in the settings bean and takes a @Comment:

class Settings {
    @Comment("Each section is an account, named by its key.")
    public List<Account> accounts = new ArrayList<>(Arrays.asList(
            new Account("alice", 100), new Account("bob", 50)));
}

Settings settings = cfg.getOrMergeValue("", new Settings());
# Each section is an account, named by its key.
accounts:
  alice:
    balance: 100
  bob:
    balance: 50
  • The file wins. Once the file has the section, its entries are the collection; a default element the file does not hold is not brought back.
  • The collection owns the membership. A binding write (mergeValue, setValue, getOrMergeValue) merges each element into its own section - a key the entity does not declare survives inside it - and removes the section of an element that left the collection.
  • An empty collection is written as [].
  • A section that does not bind (a stale accounts.enabled: [...], a key the id type cannot take) is one LoadIssue at accounts.<key> and is skipped; one bad value inside an entry is an issue at accounts.<key>.<field> and only that field keeps its default. Coercion.STRICT throws instead. getOrMergeValue writes neither back: the unbindable section and the bad value stay in the file as typed.
  • Element hooks receive a section at owner.accounts.<id>.

A file written before this layout still reads. A field stored as a plain list of objects binds as it always did, ids coming from the body. The first binding write after that turns it into the keyed section, so the next save() changes the file's layout. A section the read skipped is dropped by that write as well: the bean could not hold it, and the collection owns the membership.

The layout follows the field's declared element type. A List<Object> or a raw List that happens to hold @KeyIndex entities stays a plain list, and so does a list nested inside a Map handed to setValue.

Why a keyed section

  • Stable identity. Entries are addressed by their index value, not position — reordering the file or hand-editing one entry doesn't disturb the others.
  • Readable & diffable. accounts.alice.balance is a real path you can read with the dynamic API.
  • The key wins. On read, the section key is the index value; a stray name inside a body is ignored.

@KeyIndex types

@KeyIndex may be String, a boxed/primitive numeric (int, long, …), boolean, or UUID. The value is rendered as the key token and cast back on read.

class World { @KeyIndex public UUID id; public String name; }
cfg.setValue("worlds", Arrays.asList(new World(uuid, "overworld")));
List<World> worlds = cfg.getList("worlds", World.class);   // uuid restored from the key

Issues & validation

  • A duplicate index value in the collection (two entries with the same key), or a type with two @KeyIndex fields or an unsupported @KeyIndex type, is rejected with a BindException on setValue.
  • On a lenient read, a per-entry problem (e.g. a body index value disagreeing with its section key, which the key wins) is recorded as a LoadIssue. The plain getList discards those issues; getListResult returns them with the list.
BindResult<List<Account>> result = cfg.getListResult("accounts", Account.class);
List<Account> back = result.value();
if (result.hasIssues()) {
    for (LoadIssue issue : result.issues()) { /* report or log */ }
}

result.issues() is an unmodifiable snapshot taken for that read.

A compact element form

@KeyIndex gives a collection a keyed layout. A sibling feature gives a type a compact form inside a list while its solo form stays rich — so a long List<Pos> is a tidy string-list, not a wall of {x, y, z} objects.

Declare the two annotations (package br.com.finalcraft.everyconfig.annotation): @EveryConfigCompactValue on the instance method that returns the compact one-line String, and @EveryConfigCompactCreator on a public static factory (or a constructor) that takes a String. Jackson ignores both, so the solo/field form stays rich. Then — exactly like @KeyIndex — setValue and getList auto-detect the type for a collection; there are no dedicated methods.

class Pos {
    public int x, y, z;
    public Pos() {}
    public Pos(int x, int y, int z) { this.x = x; this.y = y; this.z = z; }

    @EveryConfigCompactValue
    public String toLine() { return x + " " + y + " " + z; }

    @EveryConfigCompactCreator
    public static Pos fromLine(String s) {
        String[] p = s.trim().split("\\s+");
        return new Pos(Integer.parseInt(p[0]), Integer.parseInt(p[1]), Integer.parseInt(p[2]));
    }
}

cfg.setValue("home", new Pos(1, 2, 3));            // solo -> a rich object { x, y, z }
cfg.setValue("spots", spots);                      // a List<Pos> -> ["4 5 6", "7 8 9"] (auto-compact)
List<Pos> back = cfg.getList("spots", Pos.class);

getList is tolerant for such a type: a textual element is rebuilt via the @EveryConfigCompactCreator, an object element via the normal rich bind (so a list mixing an old rich object and a new compact string both read), and a malformed element is skipped leniently.

Auto-detection is type-driven, like @KeyIndex: declaring the annotations makes every List<Pos> compact via the dynamic API (there is no per-call opt-out). A @KeyIndex type keeps its keyed layout — that check wins.

The compact form is the dynamic API only — a nested List<T> field inside a bound POJO stays rich.

A type you cannot annotate — a resolver

For a type you can't add the annotations to, attach a CompactElementResolver to the codec (every jackson codec has the constructor):

CompactElementResolver resolver = type ->
        type == Pos.class ? new PosCompactCodec() : null;   // CompactElementCodec<Pos>: encode(Pos)->String, decode(String)->Pos
Config cfg = Config.open(path, new YamlCodec(mapper, resolver));

Both types live in br.com.finalcraft.everyconfig.selfdescribe. This is a per-codec seam — there is no global registry. EveryConfig ships AnnotationCompactElementResolver (it reads the two annotations); every jackson codec uses it by default and composes your resolver ahead of it.

→ See also Entity Binding · Annotations · Self-Describing Types

Clone this wiki locally