Skip to content

KeyIndex Collections

Petrus Pradella edited this page Jul 6, 2026 · 4 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.

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 @KeyIndexsetValue 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.

This is the dynamic API only — a nested List<T> field inside a bound POJO stays rich. Both this and @KeyIndex intercept the same dynamic collection path; a nested-field form is not covered yet.

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