From 45fc32d983af8c44dd0b0c9311a534e91159236d Mon Sep 17 00:00:00 2001 From: Arnaud Lacurie Date: Mon, 24 Nov 2025 22:21:50 +0000 Subject: [PATCH 1/3] Support Lower / Upper string functions --- .../plan/cascades/values/StringFnValue.java | 279 ++++++++++++++++++ .../src/main/proto/record_query_plan.proto | 10 + .../functions/SqlFunctionCatalogImpl.java | 2 + .../src/test/resources/functions.yamsql | 43 +++ 4 files changed, 334 insertions(+) create mode 100644 fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java new file mode 100644 index 0000000000..f7401ed984 --- /dev/null +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java @@ -0,0 +1,279 @@ +/* + * StringFnValue.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2024 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.PStringFnValue; +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.BuiltInFunction; +import com.apple.foundationdb.record.query.plan.cascades.SemanticException; +import com.apple.foundationdb.record.query.plan.cascades.typing.Type; +import com.apple.foundationdb.record.query.plan.cascades.typing.Type.TypeCode; +import com.apple.foundationdb.record.query.plan.cascades.typing.Typed; +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.common.collect.Iterables; +import com.google.protobuf.Message; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.List; +import java.util.Locale; +import java.util.function.Supplier; + +/** + * A {@link Value} that applies a string function on its child expression. + */ +@API(API.Status.EXPERIMENTAL) +public class StringFnValue extends AbstractValue { + private static final ObjectPlanHash BASE_HASH = new ObjectPlanHash("StringFn-Value"); + + @Nonnull + private final StringFn function; + @Nonnull + private final Value child; + + /** + * Constructs a new instance of {@link StringFnValue}. + * @param function The string function to apply. + * @param child The child value. + */ + public StringFnValue(@Nonnull final StringFn function, @Nonnull final Value child) { + this.function = function; + this.child = child; + } + + @Nonnull + public StringFn getFunction() { + return function; + } + + @Nonnull + public Value getChild() { + return child; + } + + @Nullable + @Override + public Object eval(@Nullable final FDBRecordStoreBase store, @Nonnull final EvaluationContext context) { + final Object childValue = child.eval(store, context); + if (childValue == null) { + return null; + } + if (!(childValue instanceof String)) { + SemanticException.fail(SemanticException.ErrorCode.INCOMPATIBLE_TYPE, + "String function requires string argument"); + } + return function.apply((String) childValue); + } + + @Nonnull + @Override + public ExplainTokensWithPrecedence explain(@Nonnull final Iterable> explainSupplier) { + return ExplainTokensWithPrecedence.of(new ExplainTokens() + .addFunctionCall(function.name().toLowerCase(Locale.ROOT), + Value.explainFunctionArguments(explainSupplier))); + } + + @Nonnull + @Override + public Type getResultType() { + return Type.primitiveType(TypeCode.STRING); + } + + @Nonnull + @Override + protected Iterable computeChildren() { + return ImmutableList.of(child); + } + + @Nonnull + @Override + public StringFnValue withChildren(final Iterable newChildren) { + Verify.verify(Iterables.size(newChildren) == 1); + return new StringFnValue(this.function, Iterables.get(newChildren, 0)); + } + + @Override + public int hashCodeWithoutChildren() { + return PlanHashable.objectsPlanHash(PlanHashable.CURRENT_FOR_CONTINUATION, BASE_HASH, function); + } + + @Override + public int planHash(@Nonnull final PlanHashMode mode) { + return PlanHashable.objectsPlanHash(mode, BASE_HASH, function, child); + } + + @Override + public String toString() { + return function.name() + "(" + child + ")"; + } + + @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 PStringFnValue toProto(@Nonnull final PlanSerializationContext serializationContext) { + return PStringFnValue.newBuilder() + .setFunction(function.toProto(serializationContext)) + .setChild(child.toValueProto(serializationContext)) + .build(); + } + + @Nonnull + @Override + public PValue toValueProto(@Nonnull final PlanSerializationContext serializationContext) { + return PValue.newBuilder().setStringFnValue(toProto(serializationContext)).build(); + } + + @Nonnull + public static StringFnValue fromProto(@Nonnull final PlanSerializationContext serializationContext, + @Nonnull final PStringFnValue stringFnValueProto) { + return new StringFnValue( + StringFn.fromProto(serializationContext, stringFnValueProto.getFunction()), + Value.fromValueProto(serializationContext, stringFnValueProto.getChild())); + } + + @Nonnull + static Value encapsulate(@Nonnull final List arguments, @Nonnull final StringFn function) { + Verify.verify(arguments.size() == 1); + final Typed arg = arguments.get(0); + Verify.verify(arg instanceof Value); + final Value argValue = (Value) arg; + + // Validate that the argument is a string type + final Type argType = argValue.getResultType(); + if (!argType.isUnresolved() && argType.getTypeCode() != TypeCode.STRING) { + SemanticException.fail(SemanticException.ErrorCode.FUNCTION_UNDEFINED_FOR_GIVEN_ARGUMENT_TYPES, + "String function requires string argument, got " + argType); + } + + return new StringFnValue(function, argValue); + } + + /** + * String function enum. + */ + public enum StringFn implements PlanHashable { + LOWER("lower") { + @Override + public String apply(@Nonnull final String input) { + return input.toLowerCase(Locale.ROOT); + } + }, + UPPER("upper") { + @Override + public String apply(@Nonnull final String input) { + return input.toUpperCase(Locale.ROOT); + } + }; + + @Nonnull + private final String functionName; + + StringFn(@Nonnull final String functionName) { + this.functionName = functionName; + } + + @Nonnull + public abstract String apply(@Nonnull String input); + + @Override + public int planHash(@Nonnull final PlanHashMode mode) { + return PlanHashable.objectsPlanHash(mode, BASE_HASH, functionName); + } + + @Nonnull + public PStringFnValue.PStringFn toProto(@Nonnull final PlanSerializationContext serializationContext) { + return PStringFnValue.PStringFn.valueOf(this.name()); + } + + @Nonnull + public static StringFn fromProto(@Nonnull final PlanSerializationContext serializationContext, + @Nonnull final PStringFnValue.PStringFn stringFnProto) { + return StringFn.valueOf(stringFnProto.name()); + } + } + + /** + * The {@code lower} function. + */ + @AutoService(BuiltInFunction.class) + public static class LowerFn extends BuiltInFunction { + public LowerFn() { + super("lower", + ImmutableList.of(Type.primitiveType(TypeCode.STRING)), + (ignored, args) -> StringFnValue.encapsulate(args, StringFn.LOWER)); + } + } + + /** + * The {@code upper} function. + */ + @AutoService(BuiltInFunction.class) + public static class UpperFn extends BuiltInFunction { + public UpperFn() { + super("upper", + ImmutableList.of(Type.primitiveType(TypeCode.STRING)), + (ignored, args) -> StringFnValue.encapsulate(args, StringFn.UPPER)); + } + } + + /** + * Deserializer. + */ + @AutoService(PlanDeserializer.class) + public static class Deserializer implements PlanDeserializer { + @Nonnull + @Override + public Class getProtoMessageClass() { + return PStringFnValue.class; + } + + @Nonnull + @Override + public StringFnValue fromProto(@Nonnull final PlanSerializationContext serializationContext, + @Nonnull final PStringFnValue stringFnValueProto) { + return StringFnValue.fromProto(serializationContext, stringFnValueProto); + } + } +} 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 49dc07faf9..843a172428 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 @@ -265,6 +265,7 @@ message PValue { PSubscriptValue subscript_value = 52; PParameterObjectValue parameter_object_value = 53; PCastValue cast_value = 54; + PStringFnValue string_fn_value = 55; } } @@ -1293,6 +1294,15 @@ message PCollateValue { optional PValue strength_child = 4; } +message PStringFnValue { + enum PStringFn { + LOWER = 1; + UPPER = 2; + } + optional PStringFn function = 1; + optional PValue child = 2; +} + message PRangeValue { optional PValue end_exclusive_child = 1; optional PValue begin_inclusive_child = 2; diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/functions/SqlFunctionCatalogImpl.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/functions/SqlFunctionCatalogImpl.java index 405de7a682..e66e7f5073 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/functions/SqlFunctionCatalogImpl.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/functions/SqlFunctionCatalogImpl.java @@ -147,6 +147,8 @@ private static ImmutableMap BuiltInFunctionCatalog.resolve("isDistinctFrom", argumentsCount)) .put("isnotdistinctfrom", argumentsCount -> BuiltInFunctionCatalog.resolve("notDistinctFrom", argumentsCount)) .put("range", argumentsCount -> BuiltInFunctionCatalog.resolve("range", argumentsCount)) + .put("lower", argumentsCount -> BuiltInFunctionCatalog.resolve("lower", argumentsCount)) + .put("upper", argumentsCount -> BuiltInFunctionCatalog.resolve("upper", argumentsCount)) .put("__pattern_for_like", argumentsCount -> BuiltInFunctionCatalog.resolve("patternForLike", argumentsCount)) .put("__internal_array", argumentsCount -> BuiltInFunctionCatalog.resolve("array", argumentsCount)) .put("__pick_value", argumentsCount -> BuiltInFunctionCatalog.resolve("pick", argumentsCount)) diff --git a/yaml-tests/src/test/resources/functions.yamsql b/yaml-tests/src/test/resources/functions.yamsql index 20834dc437..648606b338 100644 --- a/yaml-tests/src/test/resources/functions.yamsql +++ b/yaml-tests/src/test/resources/functions.yamsql @@ -148,6 +148,49 @@ test_block: - query: update C set st = coalesce(st, (5, 'e', 5.0)) where c1 = 4 returning "new".st - unorderedResult: [ {{ T1: 5, A: 'e', B: 5.0}}] +--- +test_block: + name: string-case-functions + options: + supported_version: !current_version + tests: + - + - query: select lower('HELLO WORLD') from A + - result: [{'hello world'}] + - + - query: select upper('hello world') from A + - result: [{'HELLO WORLD'}] + - + - query: select lower('MiXeD CaSe') from A + - result: [{'mixed case'}] + - + - query: select upper('MiXeD CaSe') from A + - result: [{'MIXED CASE'}] + - + - query: select lower(a3) from A + - result: [{'1'}] + - + - query: select upper(a3) from A + - result: [{'1'}] + - + # Test NULL handling + - query: select lower(null) from A + - result: [{!null _}] + - + - query: select upper(null) from A + - result: [{!null _}] + - + # Test with empty string + - query: select lower(''), upper('') from A + - result: [{_0: '', _1: ''}] + - + # Test with special characters and numbers + - query: select lower('ABC123!@#'), upper('abc123!@#') from A + - result: [{_0: 'abc123!@#', _1: 'ABC123!@#'}] + - + # Test with unicode characters + - query: select lower('CAFÉ'), upper('café') from A + - result: [{_0: 'café', _1: 'CAFÉ'}] ... From b91cf4cf0d6b009872b892ffa3067154a90d3102 Mon Sep 17 00:00:00 2001 From: Arnaud Lacurie Date: Wed, 26 Nov 2025 08:57:34 +0000 Subject: [PATCH 2/3] Add tests --- .../plan/cascades/values/StringFnValue.java | 24 +- .../cascades/values/StringFnValueTest.java | 421 ++++++++++++++++++ 2 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java index f7401ed984..0593e6514f 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValue.java @@ -32,6 +32,7 @@ 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.BuiltInFunction; +import com.apple.foundationdb.record.query.plan.cascades.ConstrainedBoolean; import com.apple.foundationdb.record.query.plan.cascades.SemanticException; import com.apple.foundationdb.record.query.plan.cascades.typing.Type; import com.apple.foundationdb.record.query.plan.cascades.typing.Type.TypeCode; @@ -100,7 +101,7 @@ public Object eval(@Nullable final FDBRecordStoreBase sto @Override public ExplainTokensWithPrecedence explain(@Nonnull final Iterable> explainSupplier) { return ExplainTokensWithPrecedence.of(new ExplainTokens() - .addFunctionCall(function.name().toLowerCase(Locale.ROOT), + .addFunctionCall(function.getFunctionName(), Value.explainFunctionArguments(explainSupplier))); } @@ -133,9 +134,23 @@ public int planHash(@Nonnull final PlanHashMode mode) { return PlanHashable.objectsPlanHash(mode, BASE_HASH, function, child); } + @Nonnull + @Override + @SuppressWarnings("PMD.CompareObjectsWithEquals") + public ConstrainedBoolean equalsWithoutChildren(@Nonnull final Value other) { + if (this == other) { + return ConstrainedBoolean.alwaysTrue(); + } + if (!(other instanceof StringFnValue)) { + return ConstrainedBoolean.falseValue(); + } + final StringFnValue that = (StringFnValue) other; + return this.function == that.function ? ConstrainedBoolean.alwaysTrue() : ConstrainedBoolean.falseValue(); + } + @Override public String toString() { - return function.name() + "(" + child + ")"; + return function.getFunctionName() + "(" + child + ")"; } @Override @@ -214,6 +229,11 @@ public String apply(@Nonnull final String input) { this.functionName = functionName; } + @Nonnull + public String getFunctionName() { + return functionName; + } + @Nonnull public abstract String apply(@Nonnull String input); diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java new file mode 100644 index 0000000000..3cc64dbe50 --- /dev/null +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java @@ -0,0 +1,421 @@ +/* + * StringFnValueTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2024 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.EvaluationContext; +import com.apple.foundationdb.record.PlanHashable; +import com.apple.foundationdb.record.PlanSerializationContext; +import com.apple.foundationdb.record.planprotos.PValue; +import com.apple.foundationdb.record.query.plan.cascades.AliasMap; +import com.apple.foundationdb.record.query.plan.cascades.SemanticException; +import com.apple.foundationdb.record.query.plan.cascades.typing.Type; +import com.apple.foundationdb.record.query.plan.serialization.DefaultPlanSerializationRegistry; +import com.google.protobuf.InvalidProtocolBufferException; +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 javax.annotation.Nonnull; +import java.util.List; +import java.util.stream.Stream; + +/** + * Tests for {@link StringFnValue} covering various aspects of string function functionality. + *
    + *
  • LOWER and UPPER function evaluation
  • + *
  • NULL handling
  • + *
  • toString() method
  • + *
  • equals() and hashCode() consistency
  • + *
  • planHash() method
  • + *
  • Serialization and deserialization
  • + *
+ */ +class StringFnValueTest { + private static final LiteralValue HELLO_UPPER = new LiteralValue<>(Type.primitiveType(Type.TypeCode.STRING), "HELLO"); + private static final LiteralValue HELLO_LOWER = new LiteralValue<>(Type.primitiveType(Type.TypeCode.STRING), "hello"); + private static final LiteralValue WORLD_UPPER = new LiteralValue<>(Type.primitiveType(Type.TypeCode.STRING), "WORLD"); + private static final LiteralValue MIXED_CASE = new LiteralValue<>(Type.primitiveType(Type.TypeCode.STRING), "HeLLo WoRLd"); + private static final LiteralValue EMPTY_STRING = new LiteralValue<>(Type.primitiveType(Type.TypeCode.STRING), ""); + private static final NullValue NULL_VALUE = new NullValue(Type.primitiveType(Type.TypeCode.STRING)); + + /** + * Tests basic LOWER function evaluation. + */ + @Test + void testLowerEvaluation() { + final StringFnValue lowerValue = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final Object result = lowerValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals("hello", result); + } + + /** + * Tests basic UPPER function evaluation. + */ + @Test + void testUpperEvaluation() { + final StringFnValue upperValue = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_LOWER); + final Object result = upperValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals("HELLO", result); + } + + /** + * Tests LOWER with mixed case input. + */ + @Test + void testLowerMixedCase() { + final StringFnValue lowerValue = new StringFnValue(StringFnValue.StringFn.LOWER, MIXED_CASE); + final Object result = lowerValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals("hello world", result); + } + + /** + * Tests UPPER with mixed case input. + */ + @Test + void testUpperMixedCase() { + final StringFnValue upperValue = new StringFnValue(StringFnValue.StringFn.UPPER, MIXED_CASE); + final Object result = upperValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals("HELLO WORLD", result); + } + + /** + * Tests that LOWER with empty string returns empty string. + */ + @Test + void testLowerEmptyString() { + final StringFnValue lowerValue = new StringFnValue(StringFnValue.StringFn.LOWER, EMPTY_STRING); + final Object result = lowerValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals("", result); + } + + /** + * Tests that UPPER with empty string returns empty string. + */ + @Test + void testUpperEmptyString() { + final StringFnValue upperValue = new StringFnValue(StringFnValue.StringFn.UPPER, EMPTY_STRING); + final Object result = upperValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals("", result); + } + + /** + * Tests NULL handling - LOWER(NULL) should return NULL. + */ + @Test + void testLowerNull() { + final StringFnValue lowerValue = new StringFnValue(StringFnValue.StringFn.LOWER, NULL_VALUE); + final Object result = lowerValue.eval(null, EvaluationContext.empty()); + Assertions.assertNull(result); + } + + /** + * Tests NULL handling - UPPER(NULL) should return NULL. + */ + @Test + void testUpperNull() { + final StringFnValue upperValue = new StringFnValue(StringFnValue.StringFn.UPPER, NULL_VALUE); + final Object result = upperValue.eval(null, EvaluationContext.empty()); + Assertions.assertNull(result); + } + + /** + * Tests toString() method for LOWER function. + */ + @Test + void testToStringLower() { + final StringFnValue lowerValue = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final String expected = "lower(" + HELLO_UPPER + ")"; + Assertions.assertEquals(expected, lowerValue.toString()); + } + + /** + * Tests toString() method for UPPER function. + */ + @Test + void testToStringUpper() { + final StringFnValue upperValue = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_LOWER); + final String expected = "upper(" + HELLO_LOWER + ")"; + Assertions.assertEquals(expected, upperValue.toString()); + } + + /** + * Tests equals() and hashCode() - same function and same argument should be equal. + */ + @Test + void testEqualsAndHashCodeSame() { + final StringFnValue lower1 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue lower2 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + + Assertions.assertEquals(lower1, lower2); + Assertions.assertEquals(lower1.hashCode(), lower2.hashCode()); + } + + /** + * Tests equals() - different functions should not be equal. + */ + @Test + void testNotEqualsDifferentFunction() { + final StringFnValue lower = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue upper = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_UPPER); + + Assertions.assertNotEquals(lower, upper); + } + + /** + * Tests equals() - same function but different arguments should not be equal. + */ + @Test + void testNotEqualsDifferentArgument() { + final StringFnValue lower1 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue lower2 = new StringFnValue(StringFnValue.StringFn.LOWER, WORLD_UPPER); + + Assertions.assertNotEquals(lower1, lower2); + } + + /** + * Tests semanticEquals() with empty AliasMap. + */ + @Test + void testSemanticEquals() { + final StringFnValue lower1 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue lower2 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue upper = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_UPPER); + + Assertions.assertTrue(lower1.semanticEquals(lower2, AliasMap.emptyMap())); + Assertions.assertFalse(lower1.semanticEquals(upper, AliasMap.emptyMap())); + } + + /** + * Tests semanticHashCode() consistency. + */ + @Test + void testSemanticHashCode() { + final StringFnValue lower1 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue lower2 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + + Assertions.assertEquals(lower1.semanticHashCode(), lower2.semanticHashCode()); + } + + /** + * Tests planHash() for LOWER function. + */ + @Test + void testPlanHashLower() { + final StringFnValue lower1 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue lower2 = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + + Assertions.assertEquals( + lower1.planHash(PlanHashable.CURRENT_FOR_CONTINUATION), + lower2.planHash(PlanHashable.CURRENT_FOR_CONTINUATION)); + } + + /** + * Tests planHash() for UPPER function. + */ + @Test + void testPlanHashUpper() { + final StringFnValue upper1 = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_LOWER); + final StringFnValue upper2 = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_LOWER); + + Assertions.assertEquals( + upper1.planHash(PlanHashable.CURRENT_FOR_CONTINUATION), + upper2.planHash(PlanHashable.CURRENT_FOR_CONTINUATION)); + } + + /** + * Tests that different functions have different plan hashes. + */ + @Test + void testPlanHashDifferentFunctions() { + final StringFnValue lower = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final StringFnValue upper = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_UPPER); + + Assertions.assertNotEquals( + lower.planHash(PlanHashable.CURRENT_FOR_CONTINUATION), + upper.planHash(PlanHashable.CURRENT_FOR_CONTINUATION)); + } + + /** + * Tests serialization and deserialization for LOWER function. + */ + @Test + void testSerializationLower() { + final StringFnValue originalLower = new StringFnValue(StringFnValue.StringFn.LOWER, HELLO_UPPER); + final Value deserializedLower = verifySerialization(originalLower); + + Assertions.assertEquals(originalLower.getResultType(), deserializedLower.getResultType()); + Assertions.assertEquals(originalLower.toString(), deserializedLower.toString()); + } + + /** + * Tests serialization and deserialization for UPPER function. + */ + @Test + void testSerializationUpper() { + final StringFnValue originalUpper = new StringFnValue(StringFnValue.StringFn.UPPER, HELLO_LOWER); + final Value deserializedUpper = verifySerialization(originalUpper); + + Assertions.assertEquals(originalUpper.getResultType(), deserializedUpper.getResultType()); + Assertions.assertEquals(originalUpper.toString(), deserializedUpper.toString()); + } + + /** + * Tests that encapsulate() creates correct StringFnValue for LOWER. + */ + @Test + void testEncapsulateLower() { + final List arguments = List.of(HELLO_UPPER); + final Value result = StringFnValue.encapsulate(arguments, StringFnValue.StringFn.LOWER); + + Assertions.assertTrue(result instanceof StringFnValue); + final StringFnValue stringFnValue = (StringFnValue) result; + Assertions.assertEquals(StringFnValue.StringFn.LOWER, stringFnValue.getFunction()); + } + + /** + * Tests that encapsulate() creates correct StringFnValue for UPPER. + */ + @Test + void testEncapsulateUpper() { + final List arguments = List.of(HELLO_LOWER); + final Value result = StringFnValue.encapsulate(arguments, StringFnValue.StringFn.UPPER); + + Assertions.assertTrue(result instanceof StringFnValue); + final StringFnValue stringFnValue = (StringFnValue) result; + Assertions.assertEquals(StringFnValue.StringFn.UPPER, stringFnValue.getFunction()); + } + + /** + * Tests that encapsulate() with non-string type throws SemanticException. + */ + @Test + void testEncapsulateNonStringType() { + final LiteralValue intValue = new LiteralValue<>(Type.primitiveType(Type.TypeCode.LONG), 42L); + final List arguments = List.of(intValue); + + Assertions.assertThrows(SemanticException.class, () -> + StringFnValue.encapsulate(arguments, StringFnValue.StringFn.LOWER)); + } + + /** + * Tests BuiltInFunction resolution for LOWER. + */ + @Test + void testBuiltInFunctionLower() { + final var lowerFn = new StringFnValue.LowerFn(); + Assertions.assertEquals("lower", lowerFn.getFunctionName()); + } + + /** + * Tests BuiltInFunction resolution for UPPER. + */ + @Test + void testBuiltInFunctionUpper() { + final var upperFn = new StringFnValue.UpperFn(); + Assertions.assertEquals("upper", upperFn.getFunctionName()); + } + + /** + * Parametrized test for multiple string inputs with LOWER. + */ + @ParameterizedTest + @MethodSource("lowerTestCases") + void testLowerParametrized(String input, String expected) { + final LiteralValue inputValue = new LiteralValue<>(Type.primitiveType(Type.TypeCode.STRING), input); + final StringFnValue lowerValue = new StringFnValue(StringFnValue.StringFn.LOWER, inputValue); + final Object result = lowerValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals(expected, result); + } + + /** + * Parametrized test for multiple string inputs with UPPER. + */ + @ParameterizedTest + @MethodSource("upperTestCases") + void testUpperParametrized(String input, String expected) { + final LiteralValue inputValue = new LiteralValue<>(Type.primitiveType(Type.TypeCode.STRING), input); + final StringFnValue upperValue = new StringFnValue(StringFnValue.StringFn.UPPER, inputValue); + final Object result = upperValue.eval(null, EvaluationContext.empty()); + Assertions.assertEquals(expected, result); + } + + static Stream lowerTestCases() { + return Stream.of( + Arguments.of("HELLO", "hello"), + Arguments.of("hello", "hello"), + Arguments.of("HeLLo", "hello"), + Arguments.of("ABC123", "abc123"), + Arguments.of("Hello World!", "hello world!"), + Arguments.of("", ""), + Arguments.of("CAFÉ", "café") + ); + } + + static Stream upperTestCases() { + return Stream.of( + Arguments.of("hello", "HELLO"), + Arguments.of("HELLO", "HELLO"), + Arguments.of("HeLLo", "HELLO"), + Arguments.of("abc123", "ABC123"), + Arguments.of("Hello World!", "HELLO WORLD!"), + Arguments.of("", ""), + Arguments.of("café", "CAFÉ") + ); + } + + /** + * Helper method to verify serialization, deserialization, planHash, and equals. + * This is the key method that tests planHash and equals after serialization round-trip. + */ + @Nonnull + private static Value verifySerialization(@Nonnull final Value value) { + PlanSerializationContext serializationContext = new PlanSerializationContext( + DefaultPlanSerializationRegistry.INSTANCE, + PlanHashable.CURRENT_FOR_CONTINUATION); + final PValue planProto = value.toValueProto(serializationContext); + final byte[] serializedValue = planProto.toByteArray(); + final PValue parsedValueProto; + try { + parsedValueProto = PValue.parseFrom(serializedValue); + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException(e); + } + + serializationContext = new PlanSerializationContext( + DefaultPlanSerializationRegistry.INSTANCE, + PlanHashable.CURRENT_FOR_CONTINUATION); + final Value deserializedValue = Value.fromValueProto(serializationContext, parsedValueProto); + + // Test planHash equality after serialization + Assertions.assertEquals( + value.planHash(PlanHashable.CURRENT_FOR_CONTINUATION), + deserializedValue.planHash(PlanHashable.CURRENT_FOR_CONTINUATION), + "planHash should be equal after serialization/deserialization"); + + // Test equals() after serialization + Assertions.assertEquals(value, deserializedValue, + "Value should be equal to itself after serialization/deserialization"); + + return deserializedValue; + } +} From b904d5cb8a312a0676909498c1772b86c4df6fb9 Mon Sep 17 00:00:00 2001 From: Arnaud Lacurie Date: Fri, 28 Nov 2025 10:42:57 +0000 Subject: [PATCH 3/3] Test hash --- .../cascades/values/StringFnValueTest.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java index 3cc64dbe50..d7610c0f09 100644 --- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/values/StringFnValueTest.java @@ -255,6 +255,39 @@ void testPlanHashDifferentFunctions() { upper.planHash(PlanHashable.CURRENT_FOR_CONTINUATION)); } + /** + * Tests planHash() directly on StringFn enum for LOWER. + */ + @Test + void testStringFnPlanHashLower() { + final int hash1 = StringFnValue.StringFn.LOWER.planHash(PlanHashable.CURRENT_FOR_CONTINUATION); + final int hash2 = StringFnValue.StringFn.LOWER.planHash(PlanHashable.CURRENT_FOR_CONTINUATION); + + Assertions.assertEquals(hash1, hash2, "Same enum value should produce same planHash"); + } + + /** + * Tests planHash() directly on StringFn enum for UPPER. + */ + @Test + void testStringFnPlanHashUpper() { + final int hash1 = StringFnValue.StringFn.UPPER.planHash(PlanHashable.CURRENT_FOR_CONTINUATION); + final int hash2 = StringFnValue.StringFn.UPPER.planHash(PlanHashable.CURRENT_FOR_CONTINUATION); + + Assertions.assertEquals(hash1, hash2, "Same enum value should produce same planHash"); + } + + /** + * Tests that different StringFn enum values have different plan hashes. + */ + @Test + void testStringFnPlanHashDifferent() { + final int lowerHash = StringFnValue.StringFn.LOWER.planHash(PlanHashable.CURRENT_FOR_CONTINUATION); + final int upperHash = StringFnValue.StringFn.UPPER.planHash(PlanHashable.CURRENT_FOR_CONTINUATION); + + Assertions.assertNotEquals(lowerHash, upperHash, "Different enum values should produce different planHashes"); + } + /** * Tests serialization and deserialization for LOWER function. */