diff --git a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java index 11deffc0252..b97b29611d8 100644 --- a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java +++ b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java @@ -49,6 +49,7 @@ * is shared between threads. * * @author Thomas Vitale + * @author Sun Yuhan * @since 1.0.0 */ public class StTemplateRenderer implements TemplateRenderer { @@ -165,16 +166,25 @@ private Set getInputVariables(ST st) { else if (token.getType() == STLexer.RDELIM) { isInsideList = false; } - // Only add IDs that are not function calls (i.e., not immediately followed by + // Handle regular variables - only add IDs that are at the start of an + // expression else if (!isInsideList && token.getType() == STLexer.ID) { + // Check if this ID is a function call boolean isFunctionCall = (i + 1 < tokens.size() && tokens.get(i + 1).getType() == STLexer.LPAREN); - boolean isDotProperty = (i > 0 && tokens.get(i - 1).getType() == STLexer.DOT); - // Only add as variable if: - // - Not a function call - // - Not a built-in function used as property (unless validateStFunctions) - if (!isFunctionCall && (!Compiler.funcs.containsKey(token.getText()) || this.validateStFunctions - || !(isDotProperty && Compiler.funcs.containsKey(token.getText())))) { - inputVariables.add(token.getText()); + + // Check if this ID is at the beginning of an expression (not a property + // access) + boolean isAfterDot = (i > 0 && tokens.get(i - 1).getType() == STLexer.DOT); + + // Only add IDs that are: + // 1. Not function calls + // 2. Not property values (not preceded by a dot) + // 3. Either not built-in functions or we're validating functions + if (!isFunctionCall && !isAfterDot) { + String varName = token.getText(); + if (!Compiler.funcs.containsKey(varName) || this.validateStFunctions) { + inputVariables.add(varName); + } } } } diff --git a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java index 1dd548c5c0e..c05ecd4ece4 100644 --- a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java +++ b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java @@ -297,4 +297,60 @@ void shouldRenderTemplateWithBuiltInFunctions() { assertThat(result).isEqualTo("Hello!"); } + /** + * Tests that property access syntax like {test.name} is correctly handled. The + * top-level variable 'test' should be identified as required, but 'name' should not. + */ + @Test + void shouldHandlePropertyAccessSyntax() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("test", Map.of("name", "Spring AI")); + + String result = renderer.apply("Hello {test.name}!", variables); + + assertThat(result).isEqualTo("Hello Spring AI!"); + } + + /** + * Tests that deep property access syntax like {test.tom.name} is correctly handled. + * Only the top-level variable 'test' should be identified as required. + */ + @Test + void shouldHandleDeepPropertyAccessSyntax() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + variables.put("test", Map.of("tom", Map.of("name", "Spring AI"))); + + String result = renderer.apply("Hello {test.tom.name}!", variables); + + assertThat(result).isEqualTo("Hello Spring AI!"); + } + + /** + * Tests validation behavior with property access syntax. Should only require the + * top-level variable, not the property names. + */ + @Test + void shouldValidatePropertyAccessCorrectly() { + StTemplateRenderer renderer = StTemplateRenderer.builder().build(); + Map variables = new HashMap<>(); + // Only provide the top-level variable, not the properties + variables.put("user", Map.of("profile", Map.of("name", "John"))); + + // This should work fine since we provide the required top-level variable + String result = renderer.apply("Hello {user.profile.name}!", variables); + assertThat(result).isEqualTo("Hello John!"); + + // Test with missing top-level variable - should throw exception + Map missingVariables = new HashMap<>(); + // Wrong: providing nested variable instead of top-level + missingVariables.put("profile", Map.of("name", "John")); + + assertThatThrownBy(() -> renderer.apply("Hello {user.profile.name}!", missingVariables)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining( + "Not all variables were replaced in the template. Missing variable names are: [user]"); + } + }