diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/ArrayDistinctValue.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/ArrayDistinctValue.java new file mode 100644 index 0000000000..cbb47238c6 --- /dev/null +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/ArrayDistinctValue.java @@ -0,0 +1,171 @@ +/* + * ArrayDistinctValue.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 Apple Inc. and the FoundationDB project authors + * + * 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. + */ + +package com.apple.foundationdb.record.query.plan.cascades.values; + +import com.apple.foundationdb.annotation.API; +import com.apple.foundationdb.annotation.SpotBugsSuppressWarnings; +import com.apple.foundationdb.record.EvaluationContext; +import com.apple.foundationdb.record.ObjectPlanHash; +import com.apple.foundationdb.record.PlanDeserializer; +import com.apple.foundationdb.record.PlanHashable; +import com.apple.foundationdb.record.PlanSerializationContext; +import com.apple.foundationdb.record.planprotos.PArrayDistinctValue; +import com.apple.foundationdb.record.planprotos.PValue; +import com.apple.foundationdb.record.provider.foundationdb.FDBRecordStoreBase; +import com.apple.foundationdb.record.query.plan.cascades.AliasMap; +import com.apple.foundationdb.record.query.plan.cascades.typing.Type; +import com.apple.foundationdb.record.query.plan.explain.ExplainTokens; +import com.apple.foundationdb.record.query.plan.explain.ExplainTokensWithPrecedence; +import com.google.auto.service.AutoService; +import com.google.common.base.Verify; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Message; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + + +/** + * A value that returns a copy of its child array {@link Value} with all duplicate elements removed. + */ +@API(API.Status.EXPERIMENTAL) +public class ArrayDistinctValue extends AbstractValue implements ValueWithChild { + private static final ObjectPlanHash BASE_HASH = new ObjectPlanHash("Array-Distinct-Value"); + + @Nonnull + private final Value childValue; + @Nonnull + private final Type resultType; + + public ArrayDistinctValue(@Nonnull final Value childValue) { + final var innerResultType = Objects.requireNonNull(childValue.getResultType()); + Verify.verify(innerResultType.isArray()); + this.childValue = childValue; + this.resultType = innerResultType; + } + + @Nonnull + @Override + public List computeChildren() { + return ImmutableList.of(childValue); + } + + @Nonnull + @Override + public Value getChild() { + return childValue; + } + + @Nonnull + @Override + public ValueWithChild withNewChild(@Nonnull final Value rebasedChild) { + return new ArrayDistinctValue(rebasedChild); + } + + + @Nonnull + @Override + public Type getResultType() { + return resultType; + } + + @Override + public Object eval(@Nullable final FDBRecordStoreBase store, @Nonnull final EvaluationContext context) { + final var childResult = childValue.eval(store, context); + if (childResult == null) { + return null; + } + return ((List)childResult).stream().distinct().collect(ImmutableList.toImmutableList()); + } + + @Override + public int hashCodeWithoutChildren() { + return PlanHashable.objectsPlanHash(PlanHashable.CURRENT_FOR_CONTINUATION, BASE_HASH); + } + + @Override + public int planHash(@Nonnull final PlanHashMode mode) { + return PlanHashable.objectsPlanHash(mode, BASE_HASH, childValue); + } + + @Nonnull + @Override + public ExplainTokensWithPrecedence explain(@Nonnull final Iterable> explainSuppliers) { + return ExplainTokensWithPrecedence.of(new ExplainTokens().addFunctionCall("arrayDistinct", + Value.explainFunctionArguments(explainSuppliers))); + } + + @Override + public int hashCode() { + return semanticHashCode(); + } + + @SuppressWarnings("EqualsWhichDoesntCheckParameterClass") + @SpotBugsSuppressWarnings("EQ_UNUSUAL") + @Override + public boolean equals(final Object other) { + return semanticEquals(other, AliasMap.emptyMap()); + } + + @Nonnull + @Override + public PArrayDistinctValue toProto(@Nonnull final PlanSerializationContext serializationContext) { + return PArrayDistinctValue.newBuilder() + .setChildValue(childValue.toValueProto(serializationContext)) + .build(); + } + + @Nonnull + @Override + public PValue toValueProto(@Nonnull PlanSerializationContext serializationContext) { + return PValue.newBuilder().setArrayDistinctValue(toProto(serializationContext)).build(); + } + + @Nonnull + public static ArrayDistinctValue fromProto(@Nonnull final PlanSerializationContext serializationContext, + @Nonnull final PArrayDistinctValue arrayDistinctValueProto) { + return new ArrayDistinctValue( + Value.fromValueProto(serializationContext, Objects.requireNonNull(arrayDistinctValueProto.getChildValue())) + ); + } + + /** + * Deserializer. + */ + @AutoService(PlanDeserializer.class) + public static class Deserializer implements PlanDeserializer { + @Nonnull + @Override + public Class getProtoMessageClass() { + return PArrayDistinctValue.class; + } + + @Nonnull + @Override + public ArrayDistinctValue fromProto(@Nonnull final PlanSerializationContext serializationContext, + @Nonnull final PArrayDistinctValue arrayDistinctValueProto) { + return ArrayDistinctValue.fromProto(serializationContext, arrayDistinctValueProto); + } + } +} diff --git a/fdb-record-layer-core/src/main/proto/record_query_plan.proto b/fdb-record-layer-core/src/main/proto/record_query_plan.proto index ed3dce7683..4374e904b4 100644 --- a/fdb-record-layer-core/src/main/proto/record_query_plan.proto +++ b/fdb-record-layer-core/src/main/proto/record_query_plan.proto @@ -254,6 +254,7 @@ message PValue { PRangeValue range_value = 48; PFirstOrDefaultStreamingValue first_or_default_streaming_value = 49; PEvaluatesToValue evaluates_to_value = 50; + PArrayDistinctValue array_distinct_value = 51; } } @@ -1259,6 +1260,10 @@ message PRangeValue { optional PValue step_child = 3; } +message PArrayDistinctValue { + optional PValue child_value = 1; +} + // // Comparisons // diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/ArrayDistinctValueTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/ArrayDistinctValueTest.java new file mode 100644 index 0000000000..c2e741fe1d --- /dev/null +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/ArrayDistinctValueTest.java @@ -0,0 +1,201 @@ +/* + * ArrayDistinctValueTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 Apple Inc. and the FoundationDB project authors + * + * 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. + */ + +package com.apple.foundationdb.record.query.plan.cascades.values; + +import com.apple.foundationdb.record.Bindings; +import com.apple.foundationdb.record.EvaluationContext; +import com.apple.foundationdb.record.PlanHashable; +import com.apple.foundationdb.record.PlanSerializationContext; +import com.apple.foundationdb.record.TestRecords1Proto; +import com.apple.foundationdb.record.TestRecords6Proto; +import com.apple.foundationdb.record.query.plan.cascades.CorrelationIdentifier; +import com.apple.foundationdb.record.query.plan.cascades.Quantifier; +import com.apple.foundationdb.record.query.plan.cascades.typing.Type; +import com.apple.foundationdb.record.query.plan.cascades.typing.TypeRepository; +import com.apple.foundationdb.record.query.plan.explain.DefaultExplainFormatter; +import com.apple.foundationdb.record.query.plan.explain.DefaultExplainSymbolMap; +import com.apple.foundationdb.record.query.plan.plans.QueryResult; +import com.google.common.base.VerifyException; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class ArrayDistinctValueTest { + + @Test + void rejectsNonArrayValues() { + Assertions.assertThrowsExactly(VerifyException.class, () -> { + new ArrayDistinctValue(LiteralValue.ofScalar(42)); + }); + } + + static Stream literalArraySources() { + return Stream.of( + arguments( + LiteralValue.ofList(ImmutableList.of(1, 2, 1, 2, 1, 2, 3)), + ImmutableList.of(1, 2, 3), + EvaluationContext.EMPTY + ), + arguments( + LiteralValue.ofList(ImmutableList.of(1, 2, 3, 4, 5)), + ImmutableList.of(1, 2, 3, 4, 5), + EvaluationContext.EMPTY + ), + arguments( + LiteralValue.ofList(ImmutableList.of("val2", "val1", "val3", "val1", "val2")), + ImmutableList.of("val2", "val1", "val3"), + EvaluationContext.EMPTY + ) + ); + } + + static Stream boundArraySources() { + return Stream.of( + arguments( + ConstantObjectValue.of( + Quantifier.constant(), + "c0", + new Type.Array(Type.primitiveType(Type.TypeCode.INT)) + ), + List.of(1, 2, 3), + EvaluationContext.newBuilder() + .setConstant( + Quantifier.constant(), + ImmutableMap.of("c0", ImmutableList.of(1, 2, 2, 3, 3, 3)) + ) + .build(TypeRepository.empty()) + ), + arguments( + FieldValue.ofFieldName( + QuantifiedObjectValue.of( + CorrelationIdentifier.of("id1"), + Type.Record.fromDescriptor(TestRecords1Proto.MySimpleRecord.getDescriptor()) + ), + "repeater" + ), + ImmutableList.of(1, 2, 5, 4, 3), + EvaluationContext.forBinding( + Bindings.Internal.CORRELATION.bindingName("id1"), + QueryResult.ofComputed( + TestRecords1Proto.MySimpleRecord + .newBuilder() + .addAllRepeater(List.of(1, 2, 2, 5, 4, 3, 3)) + .build() + ) + ) + ), + arguments( + FieldValue.ofFieldName( + QuantifiedObjectValue.of( + CorrelationIdentifier.of("id2"), + Type.Record.fromDescriptor(TestRecords6Proto.MyRepeatedRecord.getDescriptor()) + ), + "s1" + ), + ImmutableList.of("val2", "val1", "val3"), + EvaluationContext.forBinding( + Bindings.Internal.CORRELATION.bindingName("id2"), + QueryResult.ofComputed( + TestRecords6Proto.MyRepeatedRecord + .newBuilder() + .setRecNo(1L) + .addAllS1(List.of("val2", "val1", "val3", "val1", "val2")) + .build() + ) + ) + ) + ); + } + + @ParameterizedTest(name = "returnsArrayWithoutDuplicates[input={0}, expected={1}])") + @MethodSource({"literalArraySources", "boundArraySources"}) + void returnsArrayWithoutDuplicates(Value inputArray, List expectedArray, EvaluationContext evaluationContext) { + final var constantArrayDistinctValue = new ArrayDistinctValue(inputArray); + final var actualArray = constantArrayDistinctValue.evalWithoutStore(evaluationContext); + + Assertions.assertEquals(expectedArray, actualArray); + } + + @Test + void withNewChildReplacesUnderlyingArray() { + final var expectedArray = ImmutableList.of(1, 2, 3); + final ArrayDistinctValue value = new ArrayDistinctValue(LiteralValue.ofList(ImmutableList.of(4, 5, 6))); + + final var newValue = value.withNewChild(LiteralValue.ofList(expectedArray)); + + Assertions.assertEquals(expectedArray, newValue.evalWithoutStore(EvaluationContext.EMPTY)); + } + + @Test + void equalsComparesUnderlyingValues() { + final var val1 = new ArrayDistinctValue(LiteralValue.ofList(ImmutableList.of(5, 6, 7))); + final var val2 = new ArrayDistinctValue(LiteralValue.ofList(ImmutableList.of(5, 6, 7))); + final var val3 = new ArrayDistinctValue( + ConstantObjectValue.of(Quantifier.constant(), "c0", new Type.Array()) + ); + + Assertions.assertEquals(val1, val2); + Assertions.assertNotEquals(val1, val3); + Assertions.assertNotEquals(val2, val3); + } + + @ParameterizedTest(name = "testSerialization[childValue={0}])") + @MethodSource("boundArraySources") // Don't include literalArraySources as these can't be serialized + void testSerialization(Value childValue) { + final var val1 = new ArrayDistinctValue(childValue); + final var context = PlanSerializationContext.newForCurrentMode(); + + final var serializedValue = val1.toValueProto(context); + final var deserializedValue = Value.fromValueProto(context, serializedValue); + + Assertions.assertInstanceOf(ArrayDistinctValue.class, deserializedValue); + Assertions.assertEquals(deserializedValue, val1); + } + + @Test + void testExplain() { + final ArrayDistinctValue value = new ArrayDistinctValue(LiteralValue.ofList(ImmutableList.of(4, 5, 6))); + + Assertions.assertEquals( + "arrayDistinct([4, 5, 6])", + value.explain().getExplainTokens().render(new DefaultExplainFormatter(DefaultExplainSymbolMap::new)).toString()); + } + + @Test + void testPlanHash() { + final ArrayDistinctValue val1 = new ArrayDistinctValue(LiteralValue.ofList(ImmutableList.of(4, 5, 6))); + final ArrayDistinctValue val2 = new ArrayDistinctValue(LiteralValue.ofList(ImmutableList.of(1, 2, 3))); + final ArrayDistinctValue val3 = new ArrayDistinctValue(LiteralValue.ofList(ImmutableList.of(4, 5, 6))); + + Assertions.assertEquals(1978183775, val1.planHash(PlanHashable.CURRENT_FOR_CONTINUATION)); + Assertions.assertEquals(1978180796, val2.planHash(PlanHashable.CURRENT_FOR_CONTINUATION)); + Assertions.assertEquals(1978183775, val3.planHash(PlanHashable.CURRENT_FOR_CONTINUATION)); + } +}