Refactor StTemplateRenderer: rename supportStFunctions to validateStFunctions and improve variable extraction

- Renamed all occurrences of supportStFunctions to validateStFunctions for clarity.
- Updated default field, constructor, and builder method to use new naming.
- Improved getInputVariables logic to better distinguish variables, function calls, and property access.
- Ensured built-in functions accessed as properties are only skipped when validateStFunctions is true.
- Enhanced builder usage to reflect new flag and naming.
- More tests added

Signed-off-by: Mark Pollack <mark.pollack@broadcom.com>
This commit is contained in:
Mark Pollack
2025-05-07 14:54:57 -04:00
parent 10ff11da14
commit 958f2a4e3c
5 changed files with 272 additions and 21 deletions

View File

@@ -180,6 +180,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
* Whether to store the output of this chat completion request for use in our model <a href="https://platform.openai.com/docs/guides/distillation">distillation</a> or <a href="https://platform.openai.com/docs/guides/evals">evals</a> products.
*/
private @JsonProperty("store") Boolean store;
/**
* Developer-defined tags and values used for filtering completions in the <a href="https://platform.openai.com/chat-completions">dashboard</a>.
*/

View File

@@ -42,6 +42,11 @@ import java.util.Set;
* <p>
* Use the {@link #builder()} to create and configure instances.
*
* <p>
* <b>Thread safety:</b> This class is safe for concurrent use. Each call to
* {@link #apply(String, Map)} creates a new StringTemplate instance, and no mutable state
* is shared between threads.
*
* @author Thomas Vitale
* @since 1.0.0
*/
@@ -57,7 +62,7 @@ public class StTemplateRenderer implements TemplateRenderer {
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
private static final boolean DEFAULT_SUPPORT_ST_FUNCTIONS = false;
private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;
private final char startDelimiterToken;
@@ -65,15 +70,27 @@ public class StTemplateRenderer implements TemplateRenderer {
private final ValidationMode validationMode;
private final boolean supportStFunctions;
private final boolean validateStFunctions;
StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
boolean supportStFunctions) {
/**
* Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens,
* validation mode, and function validation flag.
* @param startDelimiterToken the character used to denote the start of a template
* variable (e.g., '{')
* @param endDelimiterToken the character used to denote the end of a template
* variable (e.g., '}')
* @param validationMode the mode to use for template variable validation; must not be
* null
* @param validateStFunctions whether to validate StringTemplate functions in the
* template
*/
public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
boolean validateStFunctions) {
Assert.notNull(validationMode, "validationMode cannot be null");
this.startDelimiterToken = startDelimiterToken;
this.endDelimiterToken = endDelimiterToken;
this.validationMode = validationMode;
this.supportStFunctions = supportStFunctions;
this.validateStFunctions = validateStFunctions;
}
@Override
@@ -101,20 +118,28 @@ public class StTemplateRenderer implements TemplateRenderer {
}
}
private void validate(ST st, Map<String, Object> templateVariables) {
/**
* Validates that all required template variables are provided in the model. Returns
* the set of missing variables for further handling or logging.
* @param st the StringTemplate instance
* @param templateVariables the provided variables
* @return set of missing variable names, or empty set if none are missing
*/
private Set<String> validate(ST st, Map<String, Object> templateVariables) {
Set<String> templateTokens = getInputVariables(st);
Set<String> modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>();
Set<String> missingVariables = new HashSet<>(templateTokens);
missingVariables.removeAll(modelKeys);
// Check if model provides all keys required by the template
if (!modelKeys.containsAll(templateTokens)) {
templateTokens.removeAll(modelKeys);
if (!missingVariables.isEmpty()) {
if (validationMode == ValidationMode.WARN) {
logger.warn(VALIDATION_MESSAGE.formatted(templateTokens));
logger.warn(VALIDATION_MESSAGE.formatted(missingVariables));
}
else if (validationMode == ValidationMode.THROW) {
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(templateTokens));
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(missingVariables));
}
}
return missingVariables;
}
private Set<String> getInputVariables(ST st) {
@@ -125,11 +150,12 @@ public class StTemplateRenderer implements TemplateRenderer {
for (int i = 0; i < tokens.size(); i++) {
Token token = tokens.get(i);
// Handle list variables with option (e.g., {items; separator=", "})
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
&& tokens.get(i + 1).getType() == STLexer.ID) {
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
String text = tokens.get(i + 1).getText();
if (!Compiler.funcs.containsKey(text) || !supportStFunctions) {
if (!Compiler.funcs.containsKey(text) || this.validateStFunctions) {
inputVariables.add(text);
isInsideList = true;
}
@@ -138,13 +164,19 @@ public class StTemplateRenderer implements TemplateRenderer {
else if (token.getType() == STLexer.RDELIM) {
isInsideList = false;
}
// Only add IDs that are not function calls (i.e., not immediately followed by
else if (!isInsideList && token.getType() == STLexer.ID) {
if (!Compiler.funcs.containsKey(token.getText()) || !supportStFunctions) {
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());
}
}
}
return inputVariables;
}
@@ -163,7 +195,7 @@ public class StTemplateRenderer implements TemplateRenderer {
private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
private boolean supportStFunctions = DEFAULT_SUPPORT_ST_FUNCTIONS;
private boolean validateStFunctions = DEFAULT_VALIDATE_ST_FUNCTIONS;
private Builder() {
}
@@ -215,8 +247,8 @@ public class StTemplateRenderer implements TemplateRenderer {
* ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}).
* @return This builder instance for chaining.
*/
public Builder supportStFunctions() {
this.supportStFunctions = true;
public Builder validateStFunctions() {
this.validateStFunctions = true;
return this;
}
@@ -226,7 +258,7 @@ public class StTemplateRenderer implements TemplateRenderer {
* @return A configured {@link StTemplateRenderer}.
*/
public StTemplateRenderer build() {
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, supportStFunctions);
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, validateStFunctions);
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2023-2025 the original author or 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
*
* https://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.
*/
@NonNullApi
@NonNullFields
package org.springframework.ai.template.st;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,196 @@
/*
* Copyright 2023-2025 the original author or 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
*
* https://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 org.springframework.ai.template.st;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.ai.template.ValidationMode;
/**
* Additional edge and robustness tests for {@link StTemplateRenderer}.
*/
class StTemplateRendererEdgeTests {
// --- Built-in Function Handling Tests START ---
/**
* Built-in functions (first, last) are rendered correctly with variables.
*/
@Test
void shouldHandleMultipleBuiltInFunctionsAndVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("list", java.util.Arrays.asList("a", "b", "c"));
variables.put("name", "Mark");
String template = "{name}: {first(list)}, {last(list)}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("Mark: a, c");
}
/**
* Nested and chained built-in functions are handled when validation is enabled.
*/
/**
* Confirms that ST4 supports valid nested function expressions.
*/
@Test
void shouldSupportValidNestedFunctionExpressionInST4() {
Map<String, Object> variables = new HashMap<>();
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
String template = "{first(words)} {last(words)} {length(words)}";
StTemplateRenderer defaultRenderer = StTemplateRenderer.builder().build();
String defaultResult = defaultRenderer.apply(template, variables);
assertThat(defaultResult).isEqualTo("hello WORLD 2");
}
/**
* Nested and chained built-in functions are handled when validation is enabled.
*/
@Test
void shouldHandleNestedBuiltInFunctions() {
Map<String, Object> variables = new HashMap<>();
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
String template = "{first(words)} {last(words)} {length(words)}";
StTemplateRenderer renderer = StTemplateRenderer.builder().validateStFunctions().build();
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("hello WORLD 2");
}
/**
* Built-in functions as properties are rendered correctly if supported.
*/
@Test
@Disabled("It is very hard to validate the template expression when using property style access of built-in functions ")
void shouldSupportBuiltInFunctionsAsProperties() {
Map<String, Object> variables = new HashMap<>();
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
String template = "{words.first} {words.last} {words.length}";
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("hello WORLD 2");
}
/**
* Built-in functions are not reported as missing variables in THROW mode.
*/
@Test
void shouldNotReportBuiltInFunctionsAsMissingVariablesInThrowMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.THROW).build();
Map<String, Object> variables = new HashMap<>();
variables.put("memory", "abc");
String template = "{if(strlen(memory))}ok{endif}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("ok");
}
/**
* Built-in functions are not reported as missing variables in WARN mode.
*/
@Test
void shouldNotReportBuiltInFunctionsAsMissingVariablesInWarnMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.WARN).build();
Map<String, Object> variables = new HashMap<>();
variables.put("memory", "abc");
String template = "{if(strlen(memory))}ok{endif}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("ok");
}
/**
* Variables with names similar to built-in functions are treated as normal variables.
*/
@Test
void shouldHandleVariableNamesSimilarToBuiltInFunctions() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("lengthy", "foo");
variables.put("firstName", "bar");
String template = "{lengthy} {firstName}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("foo bar");
}
// --- Built-in Function Handling Tests END ---
@Test
void shouldRenderEscapedDelimiters() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("x", "y");
String template = "{x} \\{foo\\}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("y {foo}");
}
@Test
void shouldRenderStaticTextTemplate() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
String template = "Just static text.";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("Just static text.");
}
// Duplicate removed: shouldHandleVariableNamesSimilarToBuiltInFunctions
// (now grouped at the top of the class)
@Test
void shouldHandleLargeNumberOfVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
StringBuilder template = new StringBuilder();
for (int i = 0; i < 100; i++) {
String key = "var" + i;
variables.put(key, i);
template.append("{" + key + "} ");
}
String result = renderer.apply(template.toString().trim(), variables);
StringBuilder expected = new StringBuilder();
for (int i = 0; i < 100; i++) {
expected.append(i).append(" ");
}
assertThat(result).isEqualTo(expected.toString().trim());
}
@Test
void shouldRenderUnicodeAndSpecialCharacters() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("emoji", "😀");
variables.put("accented", "Café");
String template = "{emoji} {accented}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("😀 Café");
}
@Test
void shouldRenderNullVariableValuesAsBlank() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("foo", null);
String template = "Value: {foo}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("Value: ");
}
}

View File

@@ -282,11 +282,11 @@ class StTemplateRendererTests {
/**
* Test whether StringTemplate can correctly render a template containing built-in
* functions when {@code supportStFunctions()} is enabled. It should render properly.
* functions. It should render properly.
*/
@Test
void shouldRenderTemplateWithSupportStFunctions() {
StTemplateRenderer renderer = StTemplateRenderer.builder().supportStFunctions().build();
void shouldRenderTemplateWithBuiltInFunctions() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("memory", "you are a helpful assistant");
String template = "{if(strlen(memory))}Hello!{endif}";