From 85a01c5dc0711aee74b954cb226377ae93aeaab4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 15:25:53 +0100 Subject: [PATCH] Add typed value extraction API * Initial plan * Add getPlaceholderValue API methods to PlaceholderContext Co-authored-by: P3ridot <28200355+P3ridot@users.noreply.github.com> * Add comprehensive tests for getPlaceholderValue API Co-authored-by: P3ridot <28200355+P3ridot@users.noreply.github.com> * Address code review feedback - fix formatting consistency Co-authored-by: P3ridot <28200355+P3ridot@users.noreply.github.com> * Add test for function parameter support in getPlaceholderValue Co-authored-by: P3ridot <28200355+P3ridot@users.noreply.github.com> * Change API to return Optional instead of throwing exceptions for null values Co-authored-by: P3ridot <28200355+P3ridot@users.noreply.github.com> * Remove @NonNull annotations from Optional return types Co-authored-by: P3ridot <28200355+P3ridot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: P3ridot <28200355+P3ridot@users.noreply.github.com> --- .../placeholders/context/Placeholder.java | 61 +++++ .../context/PlaceholderContext.java | 71 ++++++ .../TestPlaceholderValueExtraction.java | 208 ++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 core/src/test/java/eu/okaeri/placeholderstest/TestPlaceholderValueExtraction.java diff --git a/core/src/main/java/eu/okaeri/placeholders/context/Placeholder.java b/core/src/main/java/eu/okaeri/placeholders/context/Placeholder.java index 5bfca56..603f588 100644 --- a/core/src/main/java/eu/okaeri/placeholders/context/Placeholder.java +++ b/core/src/main/java/eu/okaeri/placeholders/context/Placeholder.java @@ -40,6 +40,12 @@ public String render(@NonNull MessageField field) { return this.render(this.value, field); } + @Nullable + @SuppressWarnings("unchecked") + public Object resolveValue(@NonNull MessageField field) { + return this.resolveValue(this.value, field); + } + @Nullable @SuppressWarnings("unchecked") private String render(@Nullable Object object, @NonNull MessageField field) { @@ -86,6 +92,61 @@ private String render(@Nullable Object object, @NonNull MessageField field) { return ""; } + @Nullable + @SuppressWarnings("unchecked") + private Object resolveValue(@Nullable Object object, @NonNull MessageField field) { + + if (object == null) { + return null; + } + + if (this.placeholders != null) { + if (field.getSub() != null) { + MessageField fieldSub = field.getSub(); + PlaceholderResolver resolver = this.placeholders.getResolver(object, fieldSub.getName()); + if (resolver == null) { + if (object.getClass().getAnnotation(eu.okaeri.placeholders.schema.annotation.Placeholder.class) != null) { + return this.resolveValueUsingPlaceholderSchema(object, field); + } + return null; + } + object = resolver.resolve(object, fieldSub, this.context); + if (fieldSub.hasSub()) { + return this.resolveValue(object, fieldSub); + } + } + else { + PlaceholderResolver resolver = this.placeholders.getResolver(object, null); + if (resolver != null) { + object = resolver.resolve(object, field, this.context); + } + } + } + + return object; + } + + @Nullable + @SuppressWarnings("unchecked") + private Object resolveValueUsingPlaceholderSchema(@NonNull Object object, @NonNull MessageField field) { + + SchemaMeta meta = SchemaMeta.of(object.getClass()); + if (field.getSub() == null) { + return object; + } + + MessageField fieldSub = field.getSub(); + Map placeholders = meta.getPlaceholders(); + PlaceholderResolver resolver = placeholders.get(fieldSub.getName()); + + if (resolver == null) { + return null; + } + + Object resolved = resolver.resolve(object, fieldSub, this.context); + return this.resolveValue(resolved, fieldSub); + } + @SuppressWarnings("unchecked") private String renderUsingPlaceholderSchema(@NonNull Object object, @NonNull MessageField field) { diff --git a/core/src/main/java/eu/okaeri/placeholders/context/PlaceholderContext.java b/core/src/main/java/eu/okaeri/placeholders/context/PlaceholderContext.java index 9b609ba..3199753 100644 --- a/core/src/main/java/eu/okaeri/placeholders/context/PlaceholderContext.java +++ b/core/src/main/java/eu/okaeri/placeholders/context/PlaceholderContext.java @@ -13,6 +13,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; @Data public class PlaceholderContext { @@ -164,4 +165,74 @@ public String apply(@NonNull CompiledMessage message) { return builder.toString(); } + + /** + * Extracts and returns the value of a placeholder without string conversion. + * This method allows you to get the actual typed value of a placeholder by navigating through + * the chain of fields (e.g., "user.rank.points"). + * + * @param key The placeholder key, which can contain nested fields separated by dots (e.g., "user.rank.points") + * @param outputValueType The expected type of the returned value + * @param The type parameter + * @return Optional containing the value of the placeholder cast to type T, or empty if the value is null or type doesn't match + * @throws IllegalArgumentException if the placeholder is not found in context + */ + public Optional getPlaceholderValue(@NonNull String key, @NonNull Class outputValueType) { + Object value = this.resolvePlaceholderValue(key); + + if (value == null) { + return Optional.empty(); + } + + if (!outputValueType.isInstance(value)) { + return Optional.empty(); + } + + return Optional.of(outputValueType.cast(value)); + } + + /** + * Extracts and returns the value of a placeholder with custom type conversion. + * This method allows you to get the value of a placeholder and convert it to the desired type + * using a custom mapper function. + * + * @param key The placeholder key, which can contain nested fields separated by dots (e.g., "user.rank.points") + * @param valueMapper A function that converts the resolved Object to the desired type + * @param outputValueType The expected type of the returned value (used for type safety) + * @param The type parameter + * @return Optional containing the value of the placeholder converted to type T using the mapper, or empty if the value is null + * @throws IllegalArgumentException if the placeholder is not found in context + */ + public Optional getPlaceholderValue( + @NonNull String key, + @NonNull java.util.function.Function valueMapper, + @NonNull Class outputValueType + ) { + Object value = this.resolvePlaceholderValue(key); + + if (value == null) { + return Optional.empty(); + } + + return Optional.of(valueMapper.apply(value)); + } + + /** + * Internal helper method to resolve a placeholder value. + * + * @param key The placeholder key + * @return The resolved value or null if not found + */ + @Nullable + private Object resolvePlaceholderValue(@NonNull String key) { + MessageField field = MessageField.of(key); + String rootName = field.getName(); + + Placeholder placeholder = this.fields.get(rootName); + if (placeholder == null) { + throw new IllegalArgumentException("placeholder '" + rootName + "' not found in context"); + } + + return placeholder.resolveValue(field); + } } diff --git a/core/src/test/java/eu/okaeri/placeholderstest/TestPlaceholderValueExtraction.java b/core/src/test/java/eu/okaeri/placeholderstest/TestPlaceholderValueExtraction.java new file mode 100644 index 0000000..2dabecc --- /dev/null +++ b/core/src/test/java/eu/okaeri/placeholderstest/TestPlaceholderValueExtraction.java @@ -0,0 +1,208 @@ +package eu.okaeri.placeholderstest; + +import eu.okaeri.placeholders.Placeholders; +import eu.okaeri.placeholders.context.PlaceholderContext; +import eu.okaeri.placeholderstest.schema.external.ExternalItem; +import eu.okaeri.placeholderstest.schema.external.ExternalMeta; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestPlaceholderValueExtraction { + + @Test + public void test_extract_simple_value() { + PlaceholderContext context = PlaceholderContext.create() + .with("name", "John") + .with("age", 25); + + Optional name = context.getPlaceholderValue("name", String.class); + assertTrue(name.isPresent()); + assertEquals("John", name.get()); + + Optional age = context.getPlaceholderValue("age", Integer.class); + assertTrue(age.isPresent()); + assertEquals(25, age.get()); + } + + @Test + public void test_extract_nested_value() { + Placeholders placeholders = Placeholders.create() + .registerPlaceholder(ExternalItem.class, "type", (e, a, o) -> e.getType()) + .registerPlaceholder(ExternalItem.class, "amount", (e, a, o) -> e.getAmount()) + .registerPlaceholder(ExternalItem.class, "meta", (e, a, o) -> e.getMeta()) + .registerPlaceholder(ExternalMeta.class, "name", (e, a, o) -> e.getName()) + .registerPlaceholder(ExternalMeta.class, "lore", (e, a, o) -> e.getLore()); + + ExternalItem item = new ExternalItem(); + item.setAmount(123); + item.setType("Stone"); + ExternalMeta meta = new ExternalMeta(); + meta.setName("Red stone"); + meta.setLore("Really nice stone. I like it."); + item.setMeta(meta); + + PlaceholderContext context = PlaceholderContext.create() + .setPlaceholders(placeholders) + .with("item", item); + + // Extract nested value + Optional metaName = context.getPlaceholderValue("item.meta.name", String.class); + assertTrue(metaName.isPresent()); + assertEquals("Red stone", metaName.get()); + + // Extract intermediate object + Optional extractedMeta = context.getPlaceholderValue("item.meta", ExternalMeta.class); + assertTrue(extractedMeta.isPresent()); + assertEquals(meta, extractedMeta.get()); + assertEquals("Red stone", extractedMeta.get().getName()); + + // Extract primitive type + Optional amount = context.getPlaceholderValue("item.amount", Integer.class); + assertTrue(amount.isPresent()); + assertEquals(123, amount.get()); + } + + @Test + public void test_extract_with_type_mismatch() { + PlaceholderContext context = PlaceholderContext.create() + .with("age", 25); + + // Try to extract as wrong type - should return empty Optional + Optional result = context.getPlaceholderValue("age", String.class); + assertFalse(result.isPresent()); + } + + @Test + public void test_extract_missing_placeholder() { + PlaceholderContext context = PlaceholderContext.create() + .with("name", "John"); + + // Missing placeholder still throws exception + Exception exception = assertThrows(IllegalArgumentException.class, () -> { + context.getPlaceholderValue("missing", String.class); + }); + + assertTrue(exception.getMessage().contains("not found")); + } + + @Test + public void test_extract_with_mapper() { + PlaceholderContext context = PlaceholderContext.create() + .with("age", 25); + + // Convert Integer to String using mapper + Optional ageStr = context.getPlaceholderValue("age", obj -> String.valueOf(obj), String.class); + assertTrue(ageStr.isPresent()); + assertEquals("25", ageStr.get()); + + // Convert Integer to Double using mapper + Optional ageDouble = context.getPlaceholderValue("age", obj -> ((Integer) obj).doubleValue(), Double.class); + assertTrue(ageDouble.isPresent()); + assertEquals(25.0, ageDouble.get()); + } + + @Test + public void test_extract_nested_with_mapper() { + Placeholders placeholders = Placeholders.create() + .registerPlaceholder(ExternalItem.class, "amount", (e, a, o) -> e.getAmount()); + + ExternalItem item = new ExternalItem(); + item.setAmount(123); + + PlaceholderContext context = PlaceholderContext.create() + .setPlaceholders(placeholders) + .with("item", item); + + // Extract and convert amount to string + Optional amountStr = context.getPlaceholderValue("item.amount", obj -> "Amount: " + obj, String.class); + assertTrue(amountStr.isPresent()); + assertEquals("Amount: 123", amountStr.get()); + } + + @Test + public void test_extract_null_value() { + Placeholders placeholders = Placeholders.create() + .registerPlaceholder(ExternalItem.class, "meta", (e, a, o) -> e.getMeta()); + + ExternalItem item = new ExternalItem(); + item.setMeta(null); + + PlaceholderContext context = PlaceholderContext.create() + .setPlaceholders(placeholders) + .with("item", item); + + // Extracting null should return empty Optional + Optional result = context.getPlaceholderValue("item.meta", ExternalMeta.class); + assertFalse(result.isPresent()); + } + + @Test + public void test_extract_chained_values() { + Placeholders placeholders = Placeholders.create() + .registerPlaceholder(ExternalItem.class, "amount", (e, a, o) -> e.getAmount()) + .registerPlaceholder(ExternalItem.class, "meta", (e, a, o) -> e.getMeta()) + .registerPlaceholder(ExternalMeta.class, "name", (e, a, o) -> e.getName()); + + ExternalItem item = new ExternalItem(); + item.setAmount(456); + ExternalMeta meta = new ExternalMeta(); + meta.setName("Special Item"); + item.setMeta(meta); + + PlaceholderContext context = PlaceholderContext.create() + .setPlaceholders(placeholders) + .with("item", item); + + // Extract multiple values + Optional amount = context.getPlaceholderValue("item.amount", Integer.class); + assertTrue(amount.isPresent()); + assertEquals(456, amount.get()); + + Optional name = context.getPlaceholderValue("item.meta.name", String.class); + assertTrue(name.isPresent()); + assertEquals("Special Item", name.get()); + } + + @Test + public void test_extract_value_with_function_params() { + // Create a simple class to test function parameters + class Stats { + public String getStatValue(String statName) { + if ("kills".equals(statName)) return "150"; + if ("deaths".equals(statName)) return "42"; + return "unknown"; + } + } + + class Player { + private Stats stats = new Stats(); + public Stats getStats() { return stats; } + } + + Placeholders placeholders = Placeholders.create() + .registerPlaceholder(Player.class, "stats", (player, field, ctx) -> player.getStats()) + .registerPlaceholder(Stats.class, "value", (stats, field, ctx) -> { + // The field parameter contains the params via field.params() + String statName = field.params().strAt(0, "unknown"); + return stats.getStatValue(statName); + }); + + Player player = new Player(); + PlaceholderContext context = PlaceholderContext.create() + .setPlaceholders(placeholders) + .with("player", player); + + // Extract value with function parameter - player.stats.value(kills) + Optional kills = context.getPlaceholderValue("player.stats.value(kills)", String.class); + assertTrue(kills.isPresent()); + assertEquals("150", kills.get()); + + // Extract with different parameter + Optional deaths = context.getPlaceholderValue("player.stats.value(deaths)", String.class); + assertTrue(deaths.isPresent()); + assertEquals("42", deaths.get()); + } +}