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:
@@ -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>.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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: ");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}";
|
||||
|
||||
Reference in New Issue
Block a user