Skip to content

Additive + prefix for key selection on map/json/frame #133

Description

@jtnelson

Summary

After GH-130 / commit 169bd95 made @Computed properties lazy by
default, bare Record#map(), Record#json(), and Audience#frame()
exclude computed properties unless the caller either positively names
them or opts in via
SerializationOptions.builder().includeComputedValuesByDefault(true).

The positive-naming escape hatch is awkward when a Record has many
intrinsic/derived properties: to layer a single computed property
onto the default payload, the caller must enumerate every other key
they want. The serialization options flag is the opposite problem
— it opts in to all computed properties at once, defeating
the laziness guarantee for any expensive ones.

This issue proposes a third selection mode that closes the gap: a
+ prefix that means "include this on top of the defaults."

record.map("+foo");              // defaults + foo (additive mode)
record.map("+foo", "-baz");      // defaults + foo - baz (additive mode)
record.map("bar", "baz");        // legacy: explicit whitelist, unchanged
record.map("+foo", "bar");       // whitelist: {foo, bar} - any bare key
                                 // forces whitelist; defaults are dropped
record.map();                    // legacy: bare defaults, unchanged

Semantics

Record#map and friends already recognize - for negative
filtering. We add + as a default-augmenting counterpart, governed
by a clean three-mode dispatch on the shape of the key list.

The trigger is the presence of a bare positive key, not +. A
bare positive key signals whitelist intent — "I want only what
I'm listing, defaults do not apply." A + prefix is the escape
hatch: it ensures a key is included regardless of whether defaults
would have it.

Three modes

Positive keys in the call Mode Result
None (empty, or only - keys) Defaults defaults − exclude
Only +-prefixed positives Additive (defaults ∪ additive) − exclude
At least one bare positive Whitelist (bare ∪ additive) − exclude

Where:

  • defaults = intrinsic + derived (+ computed iff
    includeComputedValuesByDefault is true)
  • additive = every +-prefixed key (with the + stripped)
  • bare = every positive key without a prefix
  • exclude = every --prefixed key (with the - stripped)

Confirmed behaviors

  • Bare keys are never mode-dependent. A bare key always means
    "include only this." map("name") returns {name} whether or
    not +other is also in the call. The bare key forces whitelist
    mode and defaults drop out.
  • + in whitelist mode is a redundant annotation. When at
    least one bare positive is present, +foo and foo resolve to
    the same thing — both are members of the whitelist. The
    + does no harm; it just doesn't unlock defaults once a bare
    key has already locked them out.
  • + on a key already in defaults is a no-op in additive mode.
    map("+name") returns the defaults exactly — same keys,
    same values, no duplication for collection-typed entries.
  • Exclusion wins over addition. When the same key appears with
    both + and -, the - excludes it. In additive mode this
    means defaults minus the key; in whitelist mode it drops the
    key from the whitelist.

Backward compatibility

Every legacy call shape is preserved by the trigger rule, because
no legacy call ever uses +.

Call Today After
map() defaults defaults (defaults mode)
map("a", "b") only a, b only a, b (whitelist)
map("-a", "-b") defaults minus a, b defaults minus a, b (defaults)
map("-a", "b", "-c") only b (existing oddity tested at RecordDataAccessTest#testGetNegativeAndPositiveFiltering) only b (whitelist; bare b triggers, -a/-c are no-ops since they were never in the whitelist) — unchanged

Implementation outline

Record.javamap(SerializationOptions, String...)

Current location: src/main/java/com/cinchapi/runway/Record.java,
the map(SerializationOptions, String...) overload (around line
1455).

  1. Parsing block fans into three buckets and resolves the mode
    from whether any bare positive was supplied.

    List<String> bare     = Lists.newArrayList();
    List<String> additive = Lists.newArrayList();
    List<String> exclude  = Lists.newArrayList();
    for (String key : keys) {
        if(key.startsWith("-")) {
            exclude.add(key.substring(1));
        }
        else if(key.startsWith("+")) {
            additive.add(key.substring(1));
        }
        else {
            bare.add(key);
        }
    }
    Mode mode;
    if(!bare.isEmpty()) {
        mode = Mode.WHITELIST;
    }
    else if(!additive.isEmpty()) {
        mode = Mode.ADDITIVE;
    }
    else {
        mode = Mode.DEFAULTS;
    }
  2. Pool selection grows a third branch. Extract the per-key
    resolver (currently inline at lines 1501–1532) as a
    private helper resolveEntry(String, SerializationOptions)
    so all three branches can share it.

    if(mode == Mode.DEFAULTS) {
        // existing bare-defaults branch
        pool = data().entrySet().stream();
        if(!options.includeComputedValuesByDefault()) {
            pool = pool.filter(e -> !(e instanceof ComputedEntry));
        }
    }
    else if(mode == Mode.ADDITIVE) {
        // Skip baseline entries only when an additive key is a
        // bare root (no nav stops). For navigation additives
        // like "+company.name", let MergeStrategies::upsert
        // deep-merge the slice into the baseline's nested map.
        Set<String> bareAdditiveRoots = additive.stream()
                .filter(k -> !k.contains("."))
                .collect(Collectors.toSet());
        Stream<Entry<String, Object>> baseline = data().entrySet()
                .stream();
        if(!options.includeComputedValuesByDefault()) {
            baseline = baseline
                    .filter(e -> !(e instanceof ComputedEntry));
        }
        baseline = baseline
                .filter(e -> !bareAdditiveRoots.contains(e.getKey()));
        Stream<Entry<String, Object>> added = additive.stream()
                .map(key -> resolveEntry(key, options));
        pool = Stream.concat(baseline, added);
    }
    else {
        // WHITELIST: resolve bare ∪ additive (treat + identically
        // to bare for resolution purposes). Exclude filter runs
        // downstream as it does today.
        List<String> whitelist = Lists.newArrayList(bare);
        whitelist.addAll(additive);
        pool = whitelist.stream()
                .map(key -> resolveEntry(key, options));
    }

    The narrower skip predicate (bare-root additives only)
    resolves a bug in an earlier draft of this plan: a broader
    skip would have caused map("+company.name") to lose every
    non-name field of the company baseline. With the narrower
    predicate, the navigation slice merges into the full
    company baseline via upsert, preserving the other fields.

  3. Javadoc on both map overloads (and the matching json
    overloads, which delegate) gains an "Additive mode" paragraph
    and a "Whitelist mode" paragraph describing the trigger rule.
    The existing "negative filtering" paragraph is preserved
    verbatim.

Audience.javaframe(SerializationOptions, Collection<String>, T)

Current location: src/main/java/com/cinchapi/runway/access/Audience.java,
around line 324.

Today the parser splits each user-provided key on . and uses
toks[0] (the root) as the access-control matching key. A +-
or --prefixed root will not match the audience's readable set
(which contains bare field names), so the prefixed key would be
silently dropped during the intersection check around line 400.

Fix: strip the + or - prefix before matching against
readable, then re-attach it when building the visible array
that is passed to subject.map(options, visible). The existing
handling of - entries inside the readable set (line 393,
where - in the access matrix means "denied") is unrelated and
unchanged.

This fix also closes a latent bug: today
audience.frame(ImmutableSet.of("-name"), record) only works when
the audience's readable set is ALL_KEYS; with any non-trivial
access control, -name is dropped at the intersection and the
caller silently gets an empty result. After the fix, both + and
- survive the access check.

Tests

New file: src/test/java/com/cinchapi/runway/RecordAdditiveKeysTest.java

Modeled on RecordIncludeComputedValuesTest.java. The fixture
includes an intrinsic field, an intrinsic Set<String> field (to
exercise the no-duplicate guarantee), a counter-instrumented
@Computed property, and a linked-record field (to exercise
navigation merging).

  1. testAdditivePrefixIncludesComputedOnTopOfDefaults
    map("+label") returns defaults plus label; supplier fires
    exactly once.
  2. testAdditiveAndNegativeMixmap("+label", "-name")
    returns defaults minus name plus label; supplier fires once.
  3. testAdditiveOnIntrinsicIsNoOpmap("+name") equals
    the bare-defaults result; computed supplier does not fire.
  4. testAdditiveOnIntrinsicCollectionDoesNotDuplicate
    map("+tags") returns the tags set with its original size,
    not concatenated. Locks in the no-double-merge guarantee
    that motivates the bare-root skip in the implementation.
  5. testNegativeWinsOverAdditiveForSameKey
    map("+label", "-label") excludes label; supplier does
    not fire.
  6. testBareKeyWithPlusKeyIsWhitelistOnly
    map("+label", "name") returns only {name, label}; other
    default keys (e.g., tags) are not in the result. This is
    the explicit refutation of the earlier "modifier mode
    promotes bare keys to additive" reading.
  7. testLegacyOddityPreservedInWhitelistMode
    map("-name", "tagsAlt", "-other") still returns only the
    bare positive (whitelist mode; - keys are no-ops since
    they were never on the whitelist). Mirrors
    RecordDataAccessTest#testGetNegativeAndPositiveFiltering.
  8. testAdditiveNavigationKeyMergesIntoBaseline
    map("+linkedGadget.name") keeps the full linkedGadget
    defaults and ensures the navigation slice resolves
    correctly. Locks in the narrower-skip-predicate fix.
  9. testAdditiveWithJsonjson("+label") produces JSON
    that contains the computed value alongside defaults.
  10. testNoPositiveKeysIsDefaultsBranch — sanity check
    that map() and map("-name") are unchanged.

New file: src/test/java/com/cinchapi/runway/access/AudienceFrameAdditiveKeyAccessControlTest.java

Modeled on AudienceFrameOptionsTest.java. Uses a fixture with
restricted readable rules to exercise the access-control
intersection.

  1. testFrameWithAdditiveKeyUnderAllKeysReadable
    audience.frame(ImmutableSet.of("+computedKey"), subject)
    under ALL_KEYS readable returns defaults + computed.
  2. testFrameWithAdditiveKeyOnReadableField
    restricted readable that includes the additive's bare
    root; the + survives the intersection (prefix strip) and
    reaches subject.map.
  3. testFrameDropsAdditiveKeyOnUnreadableField
    restricted readable that excludes the additive's bare
    root; the +key is dropped at the access check so the
    caller cannot use + to bypass access control.

Existing regression test (already in tree)

src/test/java/com/cinchapi/runway/access/AudienceFrameNegativeKeyAccessControlTest.java
demonstrates the latent --prefix bug in frame against a
restricted readable set. Currently failing; expected to pass
once the prefix-strip-before-intersection fix lands.

Tests that must remain green

src/test/java/com/cinchapi/runway/RecordDataAccessTest.java and
src/test/java/com/cinchapi/runway/RecordIncludeComputedValuesTest.java
must continue to pass unmodified.

Out of scope

  • No new SerializationOptions field. + is purely a parsing
    concern at the call site.
  • No changes to @Computed, $computed(), or the lazy-supplier
    discovery added in GH-128: SerializationOptions.includeComputedValuesByDefault #130 / commit 169bd95.
  • No CLI / shell integration; this is a library-level API
    change that propagates through map, json, and frame.
  • The + prefix does not introduce a new sort/order semantic
    elsewhere; existing < / > sort prefixes on compareTo
    keys are unrelated and untouched.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions