Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -906,6 +908,11 @@ public Terminal<Q, Matches<Q, V>> matches(final Predicate<? super V> predicate)
public Terminal<Q, ?> doesNotContain(final Object element) {
return new DoesNotContain<Q, V>(this, element);
}

@Override
public IsIn<Q, V> isIn(final Collection<? extends V> values) {
return new IsIn<>(this, values);
}
}


Expand Down Expand Up @@ -1025,6 +1032,55 @@ public Stream<Q> findAll() {
}


/**
* A {@link Terminal} implementation for checking if a value is one of a specified set of values.
* <p>
* 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 <Q> the type of {@link Object}
* @param <V> the type of value
*/
private class IsIn<Q, V>
extends AbstractTerminal<Q, V, IsIn<Q, V>> {

/**
* The non-{@code null} values to compare against.
*/
private final Set<Object> values;

IsIn(final Where<Q, V> where, final Collection<? extends V> values) {
super(where);
this.values = values.stream()
.map(where::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
}

@Override
public Stream<Q> 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}.
*
Expand Down
49 changes: 49 additions & 0 deletions base-query/src/main/java/build/base/query/Condition.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,55 @@ public interface Condition<Q, V> {
*/
Terminal<Q, ?> isNotEqualTo(V value);

/**
* Specifies that the extracted value must be {@code null}.
* <p>
* 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<Q, ?> isNull() {
return isEqualTo(null);
}

/**
* Specifies that the extracted value must not be {@code null}.
* <p>
* A thin wrapper around {@link #isNotEqualTo(Object)}.
*
* @return a {@link Terminal} that can be used to obtain the results
*/
default Terminal<Q, ?> 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)}.
* <p>
* 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<Q, ?> isIn(Collection<? extends V> values);

/**
* Specifies a {@link Collection} of values, none of which may successfully compare with the extracted value
* using {@link Objects#equals(Object, Object)}.
* <p>
* 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<Q, ?> isNotIn(final Collection<? extends V> values) {
return doesNotMatch(values::contains);
}

/**
* Specifies a {@link Predicate} that must successfully match the extracted value.
*
Expand Down
9 changes: 9 additions & 0 deletions base-query/src/main/java/build/base/query/Terminal.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
117 changes: 117 additions & 0 deletions base-query/src/test/java/build/base/query/IndexCompatibilityTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down
Loading