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 0ea054f..f57d726 100644 --- a/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java +++ b/base-query/src/main/java/build/base/query/AbstractHeapBasedIndex.java @@ -102,6 +102,12 @@ public abstract class AbstractHeapBasedIndex implements Index { */ private final Memoizer, Streamable> uniqueIndexableFunctionFieldsByClass; + /** + * The {@link Indexable} {@link Dynamic} {@code public} {@code static} {@code final} {@link Function}s defined by + * {@link Field}s per known {@link Class} (includes both {@link Unique} and non-{@link Unique} fields). + */ + private final Memoizer, Streamable> dynamicIndexableFunctionFieldsByClass; + /** * Constructs an empty {@link AbstractHeapBasedIndex}. */ @@ -111,6 +117,7 @@ protected AbstractHeapBasedIndex() { this.indexableFunctionFieldsByClass = new Memoizer<>(AbstractHeapBasedIndex::getIndexableFunctionFields); this.uniqueObjectsByClassFunctionAndKey = new ConcurrentHashMap<>(); this.uniqueIndexableFunctionFieldsByClass = new Memoizer<>(AbstractHeapBasedIndex::getUniqueIndexableFunctionFields); + this.dynamicIndexableFunctionFieldsByClass = new Memoizer<>(AbstractHeapBasedIndex::getDynamicIndexableFunctionFields); } @@ -328,6 +335,118 @@ public void unindex(final Object object) { } + @Override + public void reindexDynamic(final Object object) { + final var objectClass = object.getClass(); + + this.dynamicIndexableFunctionFieldsByClass.compute(objectClass).forEach(field -> { + final boolean isUnique = field.getAnnotation(Unique.class) != null; + + if (isUnique) { + this.uniqueObjectsByClassFunctionAndKey.compute(objectClass, (_, existingFunctions) -> { + final var functions = existingFunctions == null + ? new ConcurrentHashMap, Pair, ConcurrentHashMap>>() + : existingFunctions; + + try { + field.setAccessible(true); + @SuppressWarnings("unchecked") final var function = (Function) field.get(null); + + functions.compute(function, (_, existingPair) -> { + final var pair = existingPair == null + ? Pair.of(new ConcurrentHashMap<>(), new ConcurrentHashMap()) + : existingPair; + + // unindex old key using the reverse map — no function invocation needed + final var oldKey = pair.first().remove(object); + if (oldKey != null) { + pair.second().remove(oldKey); + } + + // re-index with the current value + try { + final var value = function.apply(object); + final var newKey = value == null ? NULL_OBJECT : value; + + final var displaced = pair.second().putIfAbsent(newKey, object); + if (displaced != null && displaced != object) { + throw new IllegalStateException( + "Unique key violation: key [" + value + "] produced by [" + field.getName() + + "] on [" + objectClass.getName() + "] is already held by [" + displaced + "]"); + } + + pair.first().put(object, newKey); + } catch (final IllegalStateException e) { + throw e; + } catch (final Throwable e) { + throw new UnsupportedOperationException("Failed to reindex [" + objectClass.getName() + "] as the unique function [" + field.getName() + "] failed to extract a value from the object", e); + } + + return pair; + }); + } catch (final IllegalAccessException e) { + throw new RuntimeException("Failed to reindex [" + objectClass.getName() + "] as the unique field [" + field.getName() + "] could not be accessed", e); + } + + return functions; + }); + } else { + this.objectsByClassIndexableFunctionAndValue.compute(objectClass, (_, existingFunctions) -> { + final var functions = existingFunctions == null + ? new ConcurrentHashMap, Pair, ConcurrentHashMap>>>() + : existingFunctions; + + try { + field.setAccessible(true); + @SuppressWarnings("unchecked") final var function = (Function) field.get(null); + + functions.compute(function, (_, existingPair) -> { + final var pair = existingPair == null + ? Pair.of(new ConcurrentHashMap<>(), new ConcurrentHashMap>()) + : existingPair; + + // unindex old value using the reverse map — no function invocation needed + final var oldValue = pair.first().remove(object); + if (oldValue != null) { + pair.second().compute(oldValue, (_, existingObjects) -> { + if (existingObjects == null) { + return null; + } + existingObjects.remove(object); + return existingObjects.isEmpty() ? null : existingObjects; + }); + } + + // re-index with the current value + try { + final var value = function.apply(object); + final var newValue = value == null ? NULL_OBJECT : value; + + pair.first().put(object, newValue); + pair.second().compute(newValue, (_, existingObjects) -> { + final var objects = existingObjects == null + ? ConcurrentHashMap.newKeySet() + : existingObjects; + objects.add(object); + return objects; + }); + } catch (final Throwable e) { + throw new UnsupportedOperationException("Failed to reindex [" + objectClass.getName() + "] as the function [" + field.getName() + "] failed to extract a value from the object", e); + } + + return pair; + }); + } catch (final IllegalAccessException e) { + throw new RuntimeException("Failed to reindex [" + objectClass.getName() + "] as the field [" + field.getName() + "] could not be accessed", e); + } + + return functions; + }); + } + }); + } + + @Override public void add(final Class valueClass, final T value) { Objects.requireNonNull(valueClass, "The value class must not be null"); @@ -420,6 +539,23 @@ protected static Streamable getUniqueIndexableFunctionFields(final Class< && Function.class.isAssignableFrom(field.getType()))); } + /** + * Obtains the {@code public static final} {@link Function} {@link Field}s that are annotated as both + * {@link Indexable} and {@link Dynamic} for the specified {@link Class} (includes {@link Unique} fields). + * + * @param indexableClass the {@link Class} of queryable + * @return the {@link Streamable} of {@link Field}s annotated with both {@link Indexable} and {@link Dynamic} + */ + protected static Streamable getDynamicIndexableFunctionFields(final Class indexableClass) { + return Streamable.of(Introspection.getAllDeclaredFields(indexableClass) + .filter(field -> field.getAnnotation(Indexable.class) != null + && field.getAnnotation(Dynamic.class) != null + && Modifier.isPublic(field.getModifiers()) + && Modifier.isStatic(field.getModifiers()) + && Modifier.isFinal(field.getModifiers()) + && Function.class.isAssignableFrom(field.getType()))); + } + /** * An internal {@link Match} implementation. * diff --git a/base-query/src/main/java/build/base/query/Dynamic.java b/base-query/src/main/java/build/base/query/Dynamic.java new file mode 100644 index 0000000..593ede6 --- /dev/null +++ b/base-query/src/main/java/build/base/query/Dynamic.java @@ -0,0 +1,47 @@ +package build.base.query; + +/*- + * #%L + * base.build Query + * %% + * Copyright (C) 2025 Workday Inc + * %% + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * #L% + */ + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.util.function.Function; + +/** + * Marks an {@link Indexable} {@code public} {@code static} {@code final} {@link Function} {@link Field} as + * dynamic — its value for a given object may change after the object is constructed. + * + *

When present alongside {@link Indexable} on a {@link Function} field, calls to + * {@link Index#reindexDynamic(Object)} will update only the index entries for {@link Dynamic} fields, leaving + * stable (non-{@link Dynamic}) entries untouched. + * + * @author brian.oliver + * @since Jun-2025 + * @see Indexable + * @see Index#reindexDynamic(Object) + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Dynamic { + +} diff --git a/base-query/src/main/java/build/base/query/Index.java b/base-query/src/main/java/build/base/query/Index.java index e3c7ce0..4e27d90 100644 --- a/base-query/src/main/java/build/base/query/Index.java +++ b/base-query/src/main/java/build/base/query/Index.java @@ -70,6 +70,24 @@ default void reindex(final Object object) { index(object); } + /** + * Partially re-indexes the specified {@link Object} by updating only the index entries for + * {@link Indexable} {@link Dynamic} {@link java.util.function.Function} fields. + *

+ * Use this after mutating state that is reflected by {@link Dynamic}-annotated functions, when stable + * (non-{@link Dynamic}) indexed values are known not to have changed. Stable index entries and + * class-level {@link Indexable} entries are left untouched. + *

+ * Implementations that do not distinguish dynamic from stable fields fall back to a full {@link #reindex}. + * + * @param object the {@link Object} to partially re-index + * @see #reindex(Object) + * @see Dynamic + */ + default void reindexDynamic(final Object object) { + reindex(object); + } + /** * Adds the specified value {@link Object} to the index so that is may be returned when the specified {@link Class} * is queried or matched. 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 7176ebe..3f03ce8 100644 --- a/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java +++ b/base-query/src/test/java/build/base/query/IndexCompatibilityTests.java @@ -647,6 +647,101 @@ default void shouldNotIndexNonIndexableClass() { () -> index.index(new NotIndexable(Color.RED))); } + // ---- reindexDynamic + + /** + * Ensure {@link Index#reindexDynamic} is a no-op for objects that have only stable (non-{@link Dynamic}) + * {@link Indexable} functions — their index entries must be unchanged. + */ + @Test + default void shouldLeaveStableEntriesUnchangedOnReindexDynamic() { + final var index = createIndex(); + + final var colorful = new Colorful(Color.RED); + index.index(colorful); + + assertThat(index.match(Colorful.class).where(Colorful.COLOR).isEqualTo(Color.RED).findFirst()) + .contains(colorful); + + index.reindexDynamic(colorful); + + assertThat(index.match(Colorful.class).where(Colorful.COLOR).isEqualTo(Color.RED).findFirst()) + .contains(colorful); + } + + /** + * Ensure {@link Index#reindexDynamic} updates the forward map for a {@link Dynamic} {@link Indexable} function + * after the object's state changes. + */ + @Test + default void shouldReflectMutatedValueAfterReindexDynamic() { + final var index = createIndex(); + + final var colorful = new DynamicColorful(Color.RED); + index.index(colorful); + + assertThat(index.match(DynamicColorful.class).where(DynamicColorful.COLOR).isEqualTo(Color.RED).findFirst()) + .contains(colorful); + + colorful.setColor(Color.GREEN); + index.reindexDynamic(colorful); + + assertThat(index.match(DynamicColorful.class).where(DynamicColorful.COLOR).isEqualTo(Color.RED).findFirst()) + .isEmpty(); + assertThat(index.match(DynamicColorful.class).where(DynamicColorful.COLOR).isEqualTo(Color.GREEN).findFirst()) + .contains(colorful); + } + + /** + * Ensure {@link Index#reindexDynamic} updates only the {@link Dynamic} entry and leaves the stable entry + * untouched when an object has both stable and {@link Dynamic} {@link Indexable} functions. + */ + @Test + default void shouldUpdateOnlyDynamicEntryAndLeaveStableEntryUnchangedOnReindexDynamic() { + final var index = createIndex(); + + final var colorful = new StableAndDynamicColorful("alpha", Color.RED); + index.index(colorful); + + assertThat(index.match(StableAndDynamicColorful.class).where(StableAndDynamicColorful.NAME).isEqualTo("alpha").findFirst()) + .contains(colorful); + assertThat(index.match(StableAndDynamicColorful.class).where(StableAndDynamicColorful.COLOR).isEqualTo(Color.RED).findFirst()) + .contains(colorful); + + colorful.setColor(Color.BLUE); + index.reindexDynamic(colorful); + + assertThat(index.match(StableAndDynamicColorful.class).where(StableAndDynamicColorful.NAME).isEqualTo("alpha").findFirst()) + .contains(colorful); + assertThat(index.match(StableAndDynamicColorful.class).where(StableAndDynamicColorful.COLOR).isEqualTo(Color.RED).findFirst()) + .isEmpty(); + assertThat(index.match(StableAndDynamicColorful.class).where(StableAndDynamicColorful.COLOR).isEqualTo(Color.BLUE).findFirst()) + .contains(colorful); + } + + /** + * Ensure {@link Index#reindexDynamic} correctly updates the unique map when a {@link Dynamic} {@link Unique} + * {@link Indexable} function's value changes. + */ + @Test + default void shouldUpdateUniqueMapOnReindexDynamicForDynamicUniqueFunction() { + final var index = createIndex(); + + final var colorful = new DynamicUniqueColorful("key-A"); + index.index(colorful); + + assertThat(index.match(DynamicUniqueColorful.class).where(DynamicUniqueColorful.ID).isEqualTo("key-A").findFirst()) + .contains(colorful); + + colorful.setId("key-B"); + index.reindexDynamic(colorful); + + assertThat(index.match(DynamicUniqueColorful.class).where(DynamicUniqueColorful.ID).isEqualTo("key-A").findFirst()) + .isEmpty(); + assertThat(index.match(DynamicUniqueColorful.class).where(DynamicUniqueColorful.ID).isEqualTo("key-B").findFirst()) + .contains(colorful); + } + /** * A simple {@link Enum} for testing. */ @@ -773,4 +868,79 @@ record UniqueColorful(Color color) { @Unique public static final Function COLOR = UniqueColorful::color; } + + /** + * A mutable class with a {@link Dynamic} {@link Indexable} function, used to verify that + * {@link Index#reindexDynamic} updates the index after mutation. + */ + class DynamicColorful { + + @Indexable + @Dynamic + public static final Function COLOR = DynamicColorful::getColor; + + private Color color; + + DynamicColorful(final Color color) { + this.color = color; + } + + public Color getColor() { + return this.color; + } + + public void setColor(final Color color) { + this.color = color; + } + } + + /** + * A mutable class with one stable and one {@link Dynamic} {@link Indexable} function. + */ + class StableAndDynamicColorful { + + @Indexable + public static final Function NAME = o -> o.name; + + @Indexable + @Dynamic + public static final Function COLOR = o -> o.color; + + private final String name; + private Color color; + + StableAndDynamicColorful(final String name, final Color color) { + this.name = name; + this.color = color; + } + + public void setColor(final Color color) { + this.color = color; + } + } + + /** + * A mutable class with a {@link Dynamic} {@link Unique} {@link Indexable} function. + */ + class DynamicUniqueColorful { + + @Indexable + @Unique + @Dynamic + public static final Function ID = DynamicUniqueColorful::getId; + + private String id; + + DynamicUniqueColorful(final String id) { + this.id = id; + } + + public String getId() { + return this.id; + } + + public void setId(final String id) { + this.id = id; + } + } }