From 10446a0534c56eec9e16d567d5c7bcd295cae142 Mon Sep 17 00:00:00 2001 From: Reed von Redwitz Date: Mon, 15 Jun 2026 22:00:32 +0200 Subject: [PATCH] feat(base-query): @Indexable(each = true) with contains/doesNotContain --- .../base/query/AbstractHeapBasedIndex.java | 229 ++++++++++- .../main/java/build/base/query/Condition.java | 26 ++ .../main/java/build/base/query/Indexable.java | 10 + .../base/query/IndexCompatibilityTests.java | 368 ++++++++++++++++++ 4 files changed, 629 insertions(+), 4 deletions(-) diff --git a/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java b/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java index ec12d7f..ac5eb28 100644 --- a/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java +++ b/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java @@ -27,6 +27,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; +import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Iterator; @@ -56,6 +57,13 @@ public abstract class AbstractHeapBasedIndex implements Index { */ private static final Object NULL_OBJECT = new Object(); + /** + * Wraps the set of element-keys stored in the reverse index for a multi-valued {@link Indexable} function, + * distinguishing it from a plain scalar value stored in the same map. + */ + private record MultiValuedKeys(Set keys) { + } + /** * The {@link Object}s known to the index by {@link Class}. */ @@ -103,6 +111,18 @@ public abstract class AbstractHeapBasedIndex implements Index { */ private final Memoizer, Streamable>> dynamicUniqueFunctionsByClass; + /** + * The resolved {@link Indexable#each() each} (non-{@link Unique}) {@link Function}s per {@link Class}, memoized + * so that reflection is performed at most once per class. + */ + private final Memoizer, Streamable>> eachFunctionsByClass; + + /** + * The resolved {@link Indexable#each() each} {@link Dynamic} (non-{@link Unique}) {@link Function}s per + * {@link Class}, memoized so that reflection is performed at most once per class. + */ + private final Memoizer, Streamable>> dynamicEachFunctionsByClass; + /** * Constructs an empty {@link AbstractHeapBasedIndex}. */ @@ -114,18 +134,22 @@ protected AbstractHeapBasedIndex() { this.uniqueIndexableFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveUniqueIndexableFunctions); this.dynamicNonUniqueFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveDynamicNonUniqueFunctions); this.dynamicUniqueFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveDynamicUniqueFunctions); + this.eachFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveEachFunctions); + this.dynamicEachFunctionsByClass = new Memoizer<>(AbstractHeapBasedIndex::resolveDynamicEachFunctions); } @Override public void index(final Object object) { final var objectClass = object.getClass(); final var nonUniqueFunctions = this.indexableFunctionsByClass.compute(objectClass); + final var eachFunctions = this.eachFunctionsByClass.compute(objectClass); final var uniqueFunctions = this.uniqueIndexableFunctionsByClass.compute(objectClass); nonUniqueFunctions.forEach(function -> indexNonUnique(objectClass, function, object)); + eachFunctions.forEach(function -> indexEach(objectClass, function, object)); uniqueFunctions.forEach(function -> indexUnique(objectClass, function, object)); - if (isIndexParticipant(objectClass, nonUniqueFunctions, uniqueFunctions)) { + if (isIndexParticipant(objectClass, nonUniqueFunctions, eachFunctions, uniqueFunctions)) { this.objectByClass.compute(objectClass, (_, existing) -> { final var objects = existing == null ? ConcurrentHashMap.newKeySet() : existing; objects.add(object); @@ -138,12 +162,14 @@ public void index(final Object object) { public void unindex(final Object object) { final var objectClass = object.getClass(); final var nonUniqueFunctions = this.indexableFunctionsByClass.compute(objectClass); + final var eachFunctions = this.eachFunctionsByClass.compute(objectClass); final var uniqueFunctions = this.uniqueIndexableFunctionsByClass.compute(objectClass); nonUniqueFunctions.forEach(function -> unindexNonUnique(objectClass, function, object)); + eachFunctions.forEach(function -> unindexNonUnique(objectClass, function, object)); uniqueFunctions.forEach(function -> unindexUnique(objectClass, function, object)); - if (isIndexParticipant(objectClass, nonUniqueFunctions, uniqueFunctions)) { + if (isIndexParticipant(objectClass, nonUniqueFunctions, eachFunctions, uniqueFunctions)) { this.objectByClass.compute(objectClass, (_, existing) -> { if (existing == null) { return null; @@ -158,6 +184,7 @@ public void unindex(final Object object) { public void reindexDynamic(final Object object) { final var objectClass = object.getClass(); this.dynamicNonUniqueFunctionsByClass.compute(objectClass).forEach(function -> reindexNonUnique(objectClass, function, object)); + this.dynamicEachFunctionsByClass.compute(objectClass).forEach(function -> reindexEach(objectClass, function, object)); this.dynamicUniqueFunctionsByClass.compute(objectClass).forEach(function -> reindexUnique(objectClass, function, object)); } @@ -225,6 +252,18 @@ private void indexNonUnique(final Class objectClass, final Function objectClass, final Function function, final Object object) { + onNonUniquePair(objectClass, function, pair -> { + try { + indexMultiValued(object, toElementStream(function.apply(object), objectClass), pair); + } catch (final UnsupportedOperationException e) { + throw e; + } catch (final Throwable e) { + throw new UnsupportedOperationException("Failed to index [" + objectClass.getName() + "] as an each function failed to extract elements from the object", e); + } + }); + } + private void indexUnique(final Class objectClass, final Function function, final Object object) { onUniquePair(objectClass, function, pair -> { try { @@ -269,6 +308,19 @@ private void reindexNonUnique(final Class objectClass, final Function objectClass, final Function function, final Object object) { + onNonUniquePair(objectClass, function, pair -> { + removeNonUnique(pair, object); + try { + indexMultiValued(object, toElementStream(function.apply(object), objectClass), pair); + } catch (final UnsupportedOperationException e) { + throw e; + } catch (final Throwable e) { + throw new UnsupportedOperationException("Failed to reindex [" + objectClass.getName() + "] as a dynamic each function failed to extract elements from the object", e); + } + }); + } + private void reindexUnique(final Class objectClass, final Function function, final Object object) { onUniquePair(objectClass, function, pair -> { removeUnique(pair, object); @@ -363,6 +415,21 @@ private void onExistingUniquePair(final Class objectClass, // ---- pair-level helpers + private static Stream toElementStream(final Object value, final Class objectClass) { + if (value instanceof Stream s) { + return s.map(e -> e == null ? NULL_OBJECT : e); + } else if (value instanceof Collection c) { + return c.stream().map(e -> e == null ? NULL_OBJECT : e); + } else if (value instanceof Iterable it) { + return StreamSupport.stream(it.spliterator(), false).map(e -> e == null ? NULL_OBJECT : e); + } else { + throw new UnsupportedOperationException( + "@Indexable(each = true) function on [" + objectClass.getName() + + "] must return a Stream, Collection, or Iterable, but returned [" + + (value == null ? "null" : value.getClass().getName()) + "]"); + } + } + private static void putNonUnique(final Pair, ConcurrentHashMap>> pair, final Object object, final Object indexableValue) { @@ -377,7 +444,17 @@ private static void putNonUnique(final Pair, C private static void removeNonUnique(final Pair, ConcurrentHashMap>> pair, final Object object) { final var old = pair.first().remove(object); - if (old != null) { + if (old instanceof MultiValuedKeys(Set keys)) { + for (final var key : keys) { + pair.second().compute(key, (_, existing) -> { + if (existing == null) { + return null; + } + existing.remove(object); + return existing.isEmpty() ? null : existing; + }); + } + } else if (old != null) { pair.second().compute(old, (_, existing) -> { if (existing == null) { return null; @@ -417,9 +494,11 @@ private static void removeUnique(final Pair, C */ private boolean isIndexParticipant(final Class objectClass, final Streamable> nonUnique, + final Streamable> each, final Streamable> unique) { return Introspection.hasDeclaredAnnotation(objectClass, Indexable.class) || !nonUnique.isEmpty() + || !each.isEmpty() || !unique.isEmpty(); } @@ -454,6 +533,118 @@ private static boolean isIndexableFunctionField(final Field field) { && Function.class.isAssignableFrom(field.getType()); } + /** + * Indexes each element produced by a multi-valued {@link Indexable} function into the forward and reverse maps. + * + * @param object the object being indexed + * @param elements the stream of non-{@code null} element keys (nulls already replaced with {@link #NULL_OBJECT}) + * @param pair the pair of reverse and forward maps for the function + */ + private static void indexMultiValued(final Object object, + final Stream elements, + final Pair, ConcurrentHashMap>> pair) { + + final var keys = ConcurrentHashMap.newKeySet(); + + elements.forEach(key -> { + keys.add(key); + pair.second().compute(key, (_, existing) -> { + final var set = existing == null ? ConcurrentHashMap.newKeySet() : existing; + set.add(object); + return set; + }); + }); + + pair.first().put(object, new MultiValuedKeys(keys)); + } + + /** + * A {@link Terminal} implementation for checking membership in a multi-valued extracted value. + *

+ * When the underlying {@link Indexable} function has been indexed, performs an O(1) reverse-map lookup because + * each element was stored as an individual key during {@link #index(Object)}. Falls back to a linear scan that + * re-invokes the function and tests containment for unindexed objects. + * + * @param the type of {@link Object} being queried + * @param the type of value extracted by the {@link Where} clause + */ + private class Contains + extends AbstractTerminal> { + + private final Object element; + + Contains(final Where where, + final Object element) { + + super(where); + this.element = element; + } + + @Override + public Stream findAll() { + final var key = this.element == null ? NULL_OBJECT : this.element; + + final var indexPairs = this.where.matchingIndexPairs(); + if (!indexPairs.isEmpty()) { + return indexPairs.stream() + .map(pair -> pair.second().get(key)) + .filter(objects -> objects != null && !objects.isEmpty()) + .flatMap(Set::stream) + .map(this.where.select.objectClass::cast); + } + + // fallback: linear scan with containment check + return this.where.select.stream(this.scope) + .filter(q -> containsElement(this.where.function.apply(q), this.element)); + } + + static boolean containsElement(final Object container, final Object element) { + if (container instanceof Collection c) { + return c.contains(element); + } + if (container instanceof Stream s) { + return s.anyMatch(e -> Objects.equals(e, element)); + } + if (container instanceof Iterable it) { + for (final var e : it) { + if (Objects.equals(e, element)) { + return true; + } + } + return false; + } + return Objects.equals(container, element); + } + } + + /** + * A {@link Terminal} implementation for checking that an element is absent from a multi-valued extracted value. + *

+ * Always performs a linear scan. The reverse index only supports positive membership lookups; negation would + * require enumerating all objects and subtracting those in the membership set, which is no better than scanning. + * + * @param the type of {@link Object} being queried + * @param the type of value extracted by the {@link Where} clause + */ + private class DoesNotContain + extends AbstractTerminal> { + + private final Object element; + + DoesNotContain(final Where where, + final Object element) { + + super(where); + this.element = element; + } + + @Override + public Stream findAll() { + return this.where.select.stream(this.scope) + .filter(q -> !Contains.containsElement(this.where.function.apply(q), this.element)); + } + } + /** * Obtains the resolved {@link Indexable} (non-{@link Unique}) {@link Function}s for the specified {@link Class}. * @@ -462,7 +653,9 @@ private static boolean isIndexableFunctionField(final Field field) { */ protected static Streamable> resolveIndexableFunctions(final Class indexableClass) { return Streamable.of(Introspection.getAllDeclaredFields(indexableClass) - .filter(field -> isIndexableFunctionField(field) && field.getAnnotation(Unique.class) == null) + .filter(field -> isIndexableFunctionField(field) + && !field.getAnnotation(Indexable.class).each() + && field.getAnnotation(Unique.class) == null) .map(AbstractHeapBasedIndex::resolveFunction)); } @@ -488,6 +681,24 @@ protected static Streamable> resolveUniqueIndexableFunc protected static Streamable> resolveDynamicNonUniqueFunctions(final Class indexableClass) { return Streamable.of(Introspection.getAllDeclaredFields(indexableClass) .filter(field -> isIndexableFunctionField(field) + && !field.getAnnotation(Indexable.class).each() + && field.getAnnotation(Dynamic.class) != null + && field.getAnnotation(Unique.class) == null) + .map(AbstractHeapBasedIndex::resolveFunction)); + } + + protected static Streamable> resolveEachFunctions(final Class indexableClass) { + return Streamable.of(Introspection.getAllDeclaredFields(indexableClass) + .filter(field -> isIndexableFunctionField(field) + && field.getAnnotation(Indexable.class).each() + && field.getAnnotation(Unique.class) == null) + .map(AbstractHeapBasedIndex::resolveFunction)); + } + + protected static Streamable> resolveDynamicEachFunctions(final Class indexableClass) { + return Streamable.of(Introspection.getAllDeclaredFields(indexableClass) + .filter(field -> isIndexableFunctionField(field) + && field.getAnnotation(Indexable.class).each() && field.getAnnotation(Dynamic.class) != null && field.getAnnotation(Unique.class) == null) .map(AbstractHeapBasedIndex::resolveFunction)); @@ -685,6 +896,16 @@ public IsNotEqualTo isNotEqualTo(final V value) { public Terminal> matches(final Predicate predicate) { return new Matches<>(this, predicate); } + + @Override + public Terminal contains(final Object element) { + return new Contains(this, element); + } + + @Override + public Terminal doesNotContain(final Object element) { + return new DoesNotContain(this, element); + } } diff --git a/base-query/src/main/java/build/base/query/Condition.java b/base-query/src/main/java/build/base/query/Condition.java index b732717..2828409 100644 --- a/base-query/src/main/java/build/base/query/Condition.java +++ b/base-query/src/main/java/build/base/query/Condition.java @@ -20,8 +20,10 @@ * #L% */ +import java.util.Collection; import java.util.Objects; import java.util.function.Predicate; +import java.util.stream.Stream; /** * Provides a mechanism to specify a condition for filtering {@link Object}s of a specific {@link Class} being queried @@ -67,4 +69,28 @@ public interface Condition { default Terminal doesNotMatch(final Predicate predicate) { return matches(predicate.negate()); } + + /** + * Specifies an element that must be contained within the extracted value, which is expected to be a + * {@link Collection}, {@link Stream}, or {@link Iterable}. + *

+ * When the underlying {@link Indexable} {@link java.util.function.Function} has been indexed, this performs an O(1) + * lookup; otherwise it falls back to a linear scan. + * + * @param element the element that must be present + * @return a {@link Terminal} that can be used to obtain the results + */ + Terminal contains(Object element); + + /** + * Specifies an element that must not be contained within the extracted value, which is expected to be a + * {@link java.util.Collection}, {@link java.util.stream.Stream}, or {@link Iterable}. + *

+ * This always performs a linear scan because the reverse index only supports positive membership lookups; negation + * cannot be answered from the index alone without enumerating all objects. + * + * @param element the element that must be absent + * @return a {@link Terminal} that can be used to obtain the results + */ + Terminal doesNotContain(Object element); } diff --git a/base-query/src/main/java/build/base/query/Indexable.java b/base-query/src/main/java/build/base/query/Indexable.java index 15a49d7..87d0811 100644 --- a/base-query/src/main/java/build/base/query/Indexable.java +++ b/base-query/src/main/java/build/base/query/Indexable.java @@ -38,4 +38,14 @@ @Target({ElementType.FIELD, ElementType.TYPE}) public @interface Indexable { + /** + * When {@code true}, the function is expected to return a {@link java.util.stream.Stream}, + * {@link java.util.Collection}, or {@link Iterable}, and each element of that container is indexed + * individually as a separate key. This enables O(1) {@link Condition#contains} lookups. + *

+ * When {@code false} (the default), the return value of the function is treated as a single scalar key. + * + * @return {@code true} if each element should be indexed separately + */ + boolean each() default false; } diff --git a/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java b/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java index e8b6bd2..5ccd2eb 100644 --- a/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java +++ b/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java @@ -3,10 +3,12 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.function.Function; import java.util.stream.IntStream; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -801,6 +803,253 @@ default void shouldUpdateUniqueMapOnReindexDynamicForDynamicUniqueFunction() { .contains(colorful); } + /** + * Demonstrates the difference between a plain {@link Indexable} function and one annotated with + * {@code @Indexable(each = true)}. + * + *

Both fields on {@link ScalarVsEach} return a {@link List}{@code }, but they are indexed differently: + *

    + *
  • {@code COLORS_SCALAR} — the whole {@link List} is the key; {@code isEqualTo(list)} finds the object and + * {@code contains(element)} returns nothing because no individual element is a key.
  • + *
  • {@code COLORS_EACH} — each {@link Color} is an individual key; {@code contains(element)} finds the object + * and {@code isEqualTo(list)} returns nothing because the whole list is not a key.
  • + *
+ */ + @Test + default void shouldDistinguishScalarIndexingFromEachIndexing() { + final var index = createIndex(); + final var object = new ScalarVsEach(Color.RED, Color.GREEN); + index.index(object); + + // scalar: the whole list is the key + assertThat(index.match(ScalarVsEach.class) + .where(ScalarVsEach.COLORS_SCALAR) + .isEqualTo(List.of(Color.RED, Color.GREEN)) + .findAll()) + .containsExactly(object); + + assertThat(index.match(ScalarVsEach.class) + .where(ScalarVsEach.COLORS_SCALAR) + .contains(Color.RED) + .findAll()) + .isEmpty(); + + // each: individual elements are keys + assertThat(index.match(ScalarVsEach.class) + .where(ScalarVsEach.COLORS_EACH) + .contains(Color.RED) + .findAll()) + .containsExactly(object); + + assertThat(index.match(ScalarVsEach.class) + .where(ScalarVsEach.COLORS_EACH) + .isEqualTo(List.of(Color.RED, Color.GREEN)) + .findAll()) + .isEmpty(); + } + + /** + * Ensure objects with a multi-valued {@link Indexable} function can be queried with {@code contains}. + */ + @Test + default void shouldIndexAndQueryClassWithMultiValuedIndexableField() { + final var index = createIndex(); + + final var redGreen = new MultiColored(Color.RED, Color.GREEN); + final var greenBlue = new MultiColored(Color.GREEN, Color.BLUE); + index.index(redGreen); + index.index(greenBlue); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .contains(Color.RED) + .findAll()) + .containsExactly(redGreen); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .contains(Color.GREEN) + .findAll()) + .containsExactlyInAnyOrder(redGreen, greenBlue); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .contains(Color.BLUE) + .findAll()) + .containsExactly(greenBlue); + } + + /** + * Ensure objects with a multi-valued {@link Indexable} function can be unindexed cleanly. + */ + @Test + default void shouldUnindexObjectWithMultiValuedIndexableField() { + final var index = createIndex(); + + final var redGreen = new MultiColored(Color.RED, Color.GREEN); + final var greenBlue = new MultiColored(Color.GREEN, Color.BLUE); + index.index(redGreen); + index.index(greenBlue); + + index.unindex(redGreen); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .contains(Color.RED) + .findAll()) + .isEmpty(); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .contains(Color.GREEN) + .findAll()) + .containsExactly(greenBlue); + } + + /** + * Ensure a multi-valued {@link Indexable} function that returns a {@link java.util.Collection} is indexed and + * queried correctly (exercises the {@code Collection} branch of the indexing path). + */ + @Test + default void shouldIndexAndQueryClassWithCollectionMultiValuedField() { + final var index = createIndex(); + + final var redGreen = new MultiColoredWithList(Color.RED, Color.GREEN); + final var greenBlue = new MultiColoredWithList(Color.GREEN, Color.BLUE); + index.index(redGreen); + index.index(greenBlue); + + assertThat(index.match(MultiColoredWithList.class) + .where(MultiColoredWithList.COLORS) + .contains(Color.RED) + .findAll()) + .containsExactly(redGreen); + + assertThat(index.match(MultiColoredWithList.class) + .where(MultiColoredWithList.COLORS) + .contains(Color.GREEN) + .findAll()) + .containsExactlyInAnyOrder(redGreen, greenBlue); + } + + /** + * Ensure a multi-valued {@link Indexable} function that returns a plain {@link Iterable} (not a + * {@link java.util.Collection}) is indexed and queried correctly (exercises the {@code Iterable} branch). + */ + @Test + default void shouldIndexAndQueryClassWithIterableMultiValuedField() { + final var index = createIndex(); + + final var redGreen = new MultiColoredWithIterable(Color.RED, Color.GREEN); + final var greenBlue = new MultiColoredWithIterable(Color.GREEN, Color.BLUE); + index.index(redGreen); + index.index(greenBlue); + + assertThat(index.match(MultiColoredWithIterable.class) + .where(MultiColoredWithIterable.COLORS) + .contains(Color.RED) + .findAll()) + .containsExactly(redGreen); + + assertThat(index.match(MultiColoredWithIterable.class) + .where(MultiColoredWithIterable.COLORS) + .contains(Color.GREEN) + .findAll()) + .containsExactlyInAnyOrder(redGreen, greenBlue); + } + + /** + * Ensure reindexing a multi-valued object removes the old element keys and adds the new ones. + */ + @Test + default void shouldReindexObjectWithMultiValuedIndexableField() { + final var index = createIndex(); + + final var object = new MutableMultiColored(Color.RED, Color.GREEN); + index.index(object); + + object.setColors(Color.GREEN, Color.BLUE); + index.reindex(object); + + assertThat(index.match(MutableMultiColored.class) + .where(MutableMultiColored.COLORS) + .contains(Color.RED) + .findAll()) + .isEmpty(); + + assertThat(index.match(MutableMultiColored.class) + .where(MutableMultiColored.COLORS) + .contains(Color.GREEN) + .findAll()) + .containsExactly(object); + + assertThat(index.match(MutableMultiColored.class) + .where(MutableMultiColored.COLORS) + .contains(Color.BLUE) + .findAll()) + .containsExactly(object); + } + + /** + * Ensure {@code contains} falls back to a linear scan when the function is not {@link Indexable}. + */ + @Test + default void shouldContainsFallBackToLinearScanForNonIndexedFunction() { + final var index = createIndex(); + + final var redGreen = new MultiColored(Color.RED, Color.GREEN); + final var greenBlue = new MultiColored(Color.GREEN, Color.BLUE); + index.index(redGreen); + index.index(greenBlue); + + final Function> nonIndexed = c -> MultiColored.COLORS.apply(c); + + assertThat(index.match(MultiColored.class) + .where(nonIndexed) + .contains(Color.RED) + .findAll()) + .containsExactly(redGreen); + + assertThat(index.match(MultiColored.class) + .where(nonIndexed) + .contains(Color.GREEN) + .findAll()) + .containsExactlyInAnyOrder(redGreen, greenBlue); + } + + /** + * Ensure {@code doesNotContain} returns objects whose multi-valued field does not include the specified element. + */ + @Test + default void shouldQueryDoesNotContainWithMultiValuedField() { + final var index = createIndex(); + + final var redGreen = new MultiColored(Color.RED, Color.GREEN); + final var greenBlue = new MultiColored(Color.GREEN, Color.BLUE); + final var onlyBlue = new MultiColored(Color.BLUE); + index.index(redGreen); + index.index(greenBlue); + index.index(onlyBlue); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .doesNotContain(Color.RED) + .findAll()) + .containsExactlyInAnyOrder(greenBlue, onlyBlue); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .doesNotContain(Color.GREEN) + .findAll()) + .containsExactly(onlyBlue); + + assertThat(index.match(MultiColored.class) + .where(MultiColored.COLORS) + .doesNotContain(Color.BLUE) + .findAll()) + .containsExactly(redGreen); + } + /** * A simple {@link Enum} for testing. */ @@ -1009,4 +1258,123 @@ public void setId(final String id) { this.id = id; } } + + /** + * A class with two {@link Indexable} functions over the same {@link List}{@code } field, one scalar and + * one each, used to demonstrate the difference between the two indexing modes. + */ + class ScalarVsEach { + + /** The whole {@link List} is the index key. */ + @Indexable + public static final Function> COLORS_SCALAR = o -> o.colors; + + /** Each {@link Color} in the {@link List} is an individual index key. */ + @Indexable(each = true) + public static final Function> COLORS_EACH = o -> o.colors; + + private final List colors; + + ScalarVsEach(final Color... colors) { + this.colors = List.of(colors); + } + } + + /** + * A class with a multi-valued {@link Indexable} function that returns a {@link Stream}. + */ + class MultiColored { + + @Indexable(each = true) + public static final Function> COLORS = c -> c.colors.stream(); + + private final List colors; + + MultiColored(final Color... colors) { + this.colors = List.of(colors); + } + + @Override + public boolean equals(final Object other) { + return other instanceof MultiColored mc && this.colors.equals(mc.colors); + } + + @Override + public int hashCode() { + return this.colors.hashCode(); + } + } + + /** + * A class with a multi-valued {@link Indexable} function that returns a {@link List} (exercises the + * {@link java.util.Collection} indexing branch). + */ + class MultiColoredWithList { + + @Indexable(each = true) + public static final Function> COLORS = c -> c.colors; + + private final List colors; + + MultiColoredWithList(final Color... colors) { + this.colors = List.of(colors); + } + + @Override + public boolean equals(final Object other) { + return other instanceof MultiColoredWithList mc && this.colors.equals(mc.colors); + } + + @Override + public int hashCode() { + return this.colors.hashCode(); + } + } + + /** + * A class with a multi-valued {@link Indexable} function that returns a plain {@link Iterable} that is not a + * {@link java.util.Collection} (exercises the {@code Iterable} indexing branch). + */ + class MultiColoredWithIterable { + + @Indexable(each = true) + public static final Function> COLORS = + c -> () -> c.colors.iterator(); + + private final List colors; + + MultiColoredWithIterable(final Color... colors) { + this.colors = List.of(colors); + } + + @Override + public boolean equals(final Object other) { + return other instanceof MultiColoredWithIterable mc && this.colors.equals(mc.colors); + } + + @Override + public int hashCode() { + return this.colors.hashCode(); + } + } + + /** + * A mutable class with a multi-valued {@link Indexable} function, used to verify that reindexing after mutation + * removes the old element keys and adds the new ones. + */ + class MutableMultiColored { + + @Indexable(each = true) + public static final Function> COLORS = c -> c.colors.stream(); + + private List colors; + + MutableMultiColored(final Color... colors) { + this.colors = List.of(colors); + } + + void setColors(final Color... colors) { + this.colors = List.of(colors); + } + } }