From 87020e2b3cea8fc7d7690769c900abcdca055f34 Mon Sep 17 00:00:00 2001 From: Reed von Redwitz Date: Fri, 3 Jul 2026 06:01:28 +0200 Subject: [PATCH] feat(base-query): add isIn/isNotIn, isNull/isNotNull, and Terminal.count() --- .../base/query/AbstractHeapBasedIndex.java | 56 +++++++++ .../main/java/build/base/query/Condition.java | 49 ++++++++ .../main/java/build/base/query/Terminal.java | 9 ++ .../base/query/IndexCompatibilityTests.java | 117 ++++++++++++++++++ 4 files changed, 231 insertions(+) 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 31e81d1..b492ac1 100644 --- a/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java +++ b/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java @@ -31,6 +31,7 @@ import java.util.Collections; import java.util.IdentityHashMap; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -40,6 +41,7 @@ import java.util.function.Function; import java.util.function.Predicate; import java.util.function.UnaryOperator; +import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -906,6 +908,11 @@ public Terminal> matches(final Predicate predicate) public Terminal doesNotContain(final Object element) { return new DoesNotContain(this, element); } + + @Override + public IsIn isIn(final Collection values) { + return new IsIn<>(this, values); + } } @@ -1025,6 +1032,55 @@ public Stream findAll() { } + /** + * A {@link Terminal} implementation for checking if a value is one of a specified set of values. + *

+ * When the underlying {@link Indexable} function has been indexed, performs one reverse-map lookup per value and + * unions the results. Falls back to a linear scan testing membership for unindexed objects. + * + * @param the type of {@link Object} + * @param the type of value + */ + private class IsIn + extends AbstractTerminal> { + + /** + * The non-{@code null} values to compare against. + */ + private final Set values; + + IsIn(final Where where, final Collection values) { + super(where); + this.values = values.stream() + .map(where::nonNull) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @Override + public Stream findAll() { + final var uniquePairs = this.where.matchingUniquePairs(); + if (!uniquePairs.isEmpty()) { + return uniquePairs.stream() + .flatMap(pair -> this.values.stream().map(pair.second()::get)) + .filter(Objects::nonNull) + .map(this.where.select.objectClass::cast); + } + + final var indexPairs = this.where.matchingIndexPairs(); + if (!indexPairs.isEmpty()) { + return indexPairs.stream() + .flatMap(pair -> this.values.stream().map(pair.second()::get)) + .filter(objects -> objects != null && !objects.isEmpty()) + .flatMap(Set::stream) + .map(this.where.select.objectClass::cast); + } + + return this.where.select.stream(this.scope) + .filter(queryable -> this.values.contains(this.where.nonNull(this.where.function.apply(queryable)))); + } + } + + /** * A {@link Terminal} implementation for checking if an extracted value matches the specified {@link Predicate}. * 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 2828409..06fefdd 100644 --- a/base-query/src/main/java/build/base/query/Condition.java +++ b/base-query/src/main/java/build/base/query/Condition.java @@ -52,6 +52,55 @@ public interface Condition { */ Terminal isNotEqualTo(V value); + /** + * Specifies that the extracted value must be {@code null}. + *

+ * The index normalizes {@code null} to an internal sentinel, so this is a thin wrapper around + * {@link #isEqualTo(Object)} and benefits from the same indexed lookup. + * + * @return a {@link Terminal} that can be used to obtain the results + */ + default Terminal isNull() { + return isEqualTo(null); + } + + /** + * Specifies that the extracted value must not be {@code null}. + *

+ * A thin wrapper around {@link #isNotEqualTo(Object)}. + * + * @return a {@link Terminal} that can be used to obtain the results + */ + default Terminal isNotNull() { + return isNotEqualTo(null); + } + + /** + * Specifies a {@link Collection} of values, one of which must successfully compare with the extracted value + * using {@link Objects#equals(Object, Object)}. + *

+ * When the underlying {@link Indexable} {@link java.util.function.Function} has been indexed, this performs one + * reverse-map lookup per value and unions the results; otherwise it falls back to a linear scan. + * + * @param values the values, one of which must successfully compare + * @return a {@link Terminal} that can be used to obtain the results + */ + Terminal isIn(Collection values); + + /** + * Specifies a {@link Collection} of values, none of which may successfully compare with the extracted value + * using {@link Objects#equals(Object, Object)}. + *

+ * 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 values the values, none of which may successfully compare + * @return a {@link Terminal} that can be used to obtain the results + */ + default Terminal isNotIn(final Collection values) { + return doesNotMatch(values::contains); + } + /** * Specifies a {@link Predicate} that must successfully match the extracted value. * diff --git a/base-query/src/main/java/build/base/query/Terminal.java b/base-query/src/main/java/build/base/query/Terminal.java index b61efcb..c67cff0 100644 --- a/base-query/src/main/java/build/base/query/Terminal.java +++ b/base-query/src/main/java/build/base/query/Terminal.java @@ -128,4 +128,13 @@ default Q getOrThrow() { throw new NoSuchElementException("No single matching value available satisfying the query"); } } + + /** + * Obtains the number of {@link Object}s that match the query. + * + * @return the number of matching {@link Object}s + */ + default long count() { + return findAll().count(); + } } 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 5ccd2eb..8bd9205 100644 --- a/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java +++ b/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import java.lang.reflect.Field; +import java.util.Arrays; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; @@ -579,6 +580,122 @@ default void shouldReturnObjectsNotEqualToNull() { ).isEmpty(); } + /** + * Ensure {@link Condition#isNull()} finds objects whose extracted value is {@code null}, delegating to + * {@link Condition#isEqualTo(Object) isEqualTo(null)}. + */ + @Test + default void shouldFindObjectsWithNullValue() { + final var index = createIndex(); + final var withNull = new NullColorful(); + index.index(withNull); + + assertThat(index.match(NullColorful.class) + .where(NullColorful.COLOR) + .isNull() + .findFirst() + ).contains(withNull); + } + + /** + * Ensure {@link Condition#isNotNull()} excludes objects whose extracted value is {@code null}, delegating to + * {@link Condition#isNotEqualTo(Object) isNotEqualTo(null)}. + */ + @Test + default void shouldExcludeObjectsWithNullValueWhenIsNotNull() { + final var index = createIndex(); + final var withNull = new NullColorful(); + index.index(withNull); + + assertThat(index.match(NullColorful.class) + .where(NullColorful.COLOR) + .isNotNull() + .findFirst() + ).isEmpty(); + } + + /** + * Ensure {@link Terminal#count()} returns the number of matching objects without requiring callers to write + * {@code findAll().count()}. + */ + @Test + default void shouldCountMatchingObjects() { + final var index = createIndex(); + index.index(Color.RED); + index.index(Color.GREEN); + index.index(Color.BLUE); + + assertThat(index.match(Color.class).findAll().count()).isEqualTo(3); + assertThat(index.match(Color.class).count()).isEqualTo(3); + + assertThat(index.match(Color.class) + .where(Color.NAME) + .isNotEqualTo("RED") + .count() + ).isEqualTo(2); + } + + /** + * Ensure {@link Condition#isIn(java.util.Collection)} returns objects whose indexed value matches any of the + * specified values, using the indexed path. + */ + @Test + default void shouldReturnObjectsInValueSet() { + final var index = createIndex(); + index.index(Color.RED); + index.index(Color.GREEN); + index.index(Color.BLUE); + + assertThat(index.match(Color.class) + .where(Color.NAME) + .isIn(List.of("RED", "BLUE")) + .findAll() + .toList() + ).containsExactlyInAnyOrder(Color.RED, Color.BLUE); + + assertThat(index.match(Color.class) + .where(Color.NAME) + .isIn(List.of()) + .findAll() + ).isEmpty(); + } + + /** + * Ensure {@link Condition#isIn(java.util.Collection)} falls back to a linear scan on the non-{@link Indexable} + * path, and correctly matches a {@code null} member of the value set. + */ + @Test + default void shouldReturnObjectsInValueSetOnFallbackPath() { + final var index = createIndex(); + final var withNull = new NullFallbackColorful(); + index.add(NullFallbackColorful.class, withNull); + + assertThat(index.match(NullFallbackColorful.class) + .where(NullFallbackColorful.COLOR) + .isIn(Arrays.asList(Color.RED, null)) + .findFirst() + ).contains(withNull); + } + + /** + * Ensure {@link Condition#isNotIn(java.util.Collection)} excludes objects whose indexed value matches any of the + * specified values. + */ + @Test + default void shouldReturnObjectsNotInValueSet() { + final var index = createIndex(); + index.index(Color.RED); + index.index(Color.GREEN); + index.index(Color.BLUE); + + assertThat(index.match(Color.class) + .where(Color.NAME) + .isNotIn(List.of("RED", "BLUE")) + .findAll() + .toList() + ).containsExactly(Color.GREEN); + } + /** * Ensure concurrent {@link Index#index} and {@link Index#unindex} calls do not throw or corrupt state. */