From 2f9285919fbfbc5d682e026dbecd90b2a401461a Mon Sep 17 00:00:00 2001 From: Akika Date: Mon, 15 Dec 2025 18:23:18 +0800 Subject: [PATCH 1/2] feat: add keepMissingVariables option to StTemplateRenderer Signed-off-by: Akika --- .../ai/template/st/StTemplateRenderer.java | 61 +++++++++- .../template/st/StTemplateRendererTests.java | 115 ++++++++++++++++++ 2 files changed, 174 insertions(+), 2 deletions(-) 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 f03d2eea737..0e1372e2e30 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 @@ -67,6 +67,8 @@ public class StTemplateRenderer implements TemplateRenderer { private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false; + private static final boolean DEFAULT_KEEP_MISSING_VARIABLES = false; + private final char startDelimiterToken; private final char endDelimiterToken; @@ -75,6 +77,8 @@ public class StTemplateRenderer implements TemplateRenderer { private final boolean validateStFunctions; + private final boolean keepMissingVariables; + /** * Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens, * validation mode, and function validation flag. @@ -88,12 +92,13 @@ public class StTemplateRenderer implements TemplateRenderer { * template */ public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode, - boolean validateStFunctions) { + boolean validateStFunctions, boolean keepMissingVariables) { Assert.notNull(validationMode, "validationMode cannot be null"); this.startDelimiterToken = startDelimiterToken; this.endDelimiterToken = endDelimiterToken; this.validationMode = validationMode; this.validateStFunctions = validateStFunctions; + this.keepMissingVariables = keepMissingVariables; } @Override @@ -103,6 +108,17 @@ public String apply(String template, Map variables) { Assert.noNullElements(variables.keySet(), "variables keys cannot be null"); ST st = createST(template); + // If keepMissingVariables is enabled, first fill missing variables with placeholders. + if (this.keepMissingVariables) { + Set allVars = getInputVariables(st); + Set missingVars = new HashSet<>(allVars); + missingVars.removeAll(variables.keySet()); + + for (String missingVar : missingVars) { + st.add(missingVar, String.format("%c%s%c", + this.startDelimiterToken, missingVar, this.endDelimiterToken)); + } + } for (Map.Entry entry : variables.entrySet()) { st.add(entry.getKey(), entry.getValue()); } @@ -211,6 +227,8 @@ public static final class Builder { private boolean validateStFunctions = DEFAULT_VALIDATE_ST_FUNCTIONS; + private boolean keepMissingVariables = DEFAULT_KEEP_MISSING_VARIABLES; + private Builder() { } @@ -266,14 +284,53 @@ public Builder validateStFunctions() { return this; } + /** + * Configures the renderer to keep missing variables in their original placeholder + * form instead of letting StringTemplate render them as empty strings. + * + *

When enabled, variables that are referenced in the template but not provided in + * the input map will be rendered as their placeholder text (for example: + * {@code "{username}"} remains {@code "{username}"} in the final output).

+ * + *

Important: {@code keepMissingVariables(true)} can only be used when + * {@code validationMode} is set to {@link ValidationMode#NONE}. If validation is + * enabled ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}), enabling + * this flag will result in an {@link IllegalArgumentException} when building the + * renderer.

+ * + *

Example usage:

+ *
{@code
+		 * TemplateRenderer renderer = MyStTemplateRenderer.builder()
+		 *         .validationMode(ValidationMode.NONE)
+		 *         .keepMissingVariables(true)
+		 *         .build();
+		 *
+		 * String result = renderer.apply("Hello, {name}, today is {date}",
+		 *         Map.of("name", "Alice"));
+		 * // result: "Hello, Alice, today is {date}"
+		 * }
+ * + * @param keepMissingVariables whether to keep missing variables as placeholders + * (e.g. "{var}") in the rendered output + * @return this builder instance for chaining + */ + public Builder keepMissingVariables(boolean keepMissingVariables) { + this.keepMissingVariables = keepMissingVariables; + return this; + } + /** * Builds and returns a new {@link StTemplateRenderer} instance with the * configured settings. * @return A configured {@link StTemplateRenderer}. */ public StTemplateRenderer build() { + if (this.keepMissingVariables && this.validationMode != ValidationMode.NONE) { + throw new IllegalArgumentException( + "keepMissingVariables can only be enabled when validationMode is NONE."); + } return new StTemplateRenderer(this.startDelimiterToken, this.endDelimiterToken, this.validationMode, - this.validateStFunctions); + this.validateStFunctions, this.keepMissingVariables); } } 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 c05ecd4ece4..107a53613d6 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 @@ -353,4 +353,119 @@ void shouldValidatePropertyAccessCorrectly() { "Not all variables were replaced in the template. Missing variable names are: [user]"); } + /** + * Tests that keepMissingVariables is false by default, so missing variables are rendered as empty strings. + */ + @Test + void shouldDefaultToNotKeepMissingVariables() { + StTemplateRenderer renderer = StTemplateRenderer.builder() + .validationMode(ValidationMode.NONE) + .build(); + + String result = renderer.apply("{name}", Map.of()); + assertThat(result).isEmpty(); + } + + /** + * Tests basic functionality: missing variables are preserved as placeholders + * while provided variables are rendered normally. + */ + @Test + void shouldPreservePlaceholderForMissingVariables() { + StTemplateRenderer renderer = StTemplateRenderer.builder() + .validationMode(ValidationMode.NONE) + .keepMissingVariables(true) + .build(); + + Map variables = new HashMap<>(); + variables.put("name", "Alice"); + + String template = "Hello {name}, today is {date}"; + String result = renderer.apply(template, variables); + + assertThat(result).isEqualTo("Hello Alice, today is {date}"); + } + + /** + * Tests that multiple missing variables are all preserved in their original placeholder form. + */ + @Test + void shouldKeepPlaceholdersForMultipleMissingVariables() { + StTemplateRenderer renderer = StTemplateRenderer.builder() + .validationMode(ValidationMode.NONE) + .keepMissingVariables(true) + .build(); + + Map variables = Map.of("a", 1); + + String template = "{a} {b} {c}"; + String result = renderer.apply(template, variables); + + assertThat(result).isEqualTo("1 {b} {c}"); + } + + /** + * Tests that keepMissingVariables cannot be enabled with THROW validation mode, + * as they are semantically incompatible. + */ + @Test + void shouldNotAllowThrowValidationModeWhenKeepMissingVariables() { + assertThatThrownBy(() -> StTemplateRenderer.builder() + .validationMode(ValidationMode.THROW) + .keepMissingVariables(true) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("keepMissingVariables can only be enabled when validationMode is NONE"); + } + + /** + * Tests that keepMissingVariables cannot be enabled with WARN validation mode, + * as they are semantically incompatible. + */ + @Test + void shouldNotAllowWarnValidationModeWhenKeepMissingVariables() { + assertThatThrownBy(() -> StTemplateRenderer.builder() + .validationMode(ValidationMode.WARN) + .keepMissingVariables(true) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("keepMissingVariables can only be enabled when validationMode is NONE"); + } + + /** + * Tests that keepMissingVariables respects custom delimiter tokens when preserving placeholders. + */ + @Test + void shouldRespectCustomDelimitersWhenKeepMissingVariables() { + StTemplateRenderer renderer = StTemplateRenderer.builder() + .startDelimiterToken('<') + .endDelimiterToken('>') + .validationMode(ValidationMode.NONE) + .keepMissingVariables(true) + .build(); + + Map variables = Map.of("name", "Spring AI"); + String result = renderer.apply("Hello , today is ", variables); + + assertThat(result).isEqualTo("Hello Spring AI, today is "); + } + + /** + * Tests that StringTemplate built-in functions work correctly and missing variables are still preserved. + */ + @Test + void shouldNotInterfereWithBuiltInFunctionsWhenKeepMissingVariables() { + StTemplateRenderer renderer = StTemplateRenderer.builder() + .validationMode(ValidationMode.NONE) + .keepMissingVariables(true) + .build(); + + Map variables = Map.of("items", new String[] {"a", "b"}); + String template = "{first(items)} {last(items)} {missing}"; + String result = renderer.apply(template, variables); + + assertThat(result).isEqualTo("a b {missing}"); + } + + } From a153485af9febf7ff9ad57b6de4e3f88bcb4da08 Mon Sep 17 00:00:00 2001 From: Akika Date: Mon, 15 Dec 2025 23:04:24 +0800 Subject: [PATCH 2/2] fix: error example usage Signed-off-by: Akika --- .../org/springframework/ai/template/st/StTemplateRenderer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0e1372e2e30..15853366e95 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 @@ -300,7 +300,7 @@ public Builder validateStFunctions() { * *

Example usage:

*
{@code
-		 * TemplateRenderer renderer = MyStTemplateRenderer.builder()
+		 * TemplateRenderer renderer = StTemplateRenderer.builder()
 		 *         .validationMode(ValidationMode.NONE)
 		 *         .keepMissingVariables(true)
 		 *         .build();