Skip to content

Commit 0e15197

Browse files
markpollacksobychacko
authored andcommitted
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 <[email protected]>
1 parent 97f90b1 commit 0e15197

File tree

5 files changed

+267
-21
lines changed

5 files changed

+267
-21
lines changed

models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
180180
* 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.
181181
*/
182182
private @JsonProperty("store") Boolean store;
183+
183184
/**
184185
* Developer-defined tags and values used for filtering completions in the <a href="https://platform.openai.com/chat-completions">dashboard</a>.
185186
*/

spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
* <p>
4343
* Use the {@link #builder()} to create and configure instances.
4444
*
45+
* <p>
46+
* <b>Thread safety:</b> This class is safe for concurrent use. Each call to
47+
* {@link #apply(String, Map)} creates a new StringTemplate instance, and no mutable state
48+
* is shared between threads.
49+
*
4550
* @author Thomas Vitale
4651
* @since 1.0.0
4752
*/
@@ -57,23 +62,35 @@ public class StTemplateRenderer implements TemplateRenderer {
5762

5863
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
5964

60-
private static final boolean DEFAULT_SUPPORT_ST_FUNCTIONS = false;
65+
private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;
6166

6267
private final char startDelimiterToken;
6368

6469
private final char endDelimiterToken;
6570

6671
private final ValidationMode validationMode;
6772

68-
private final boolean supportStFunctions;
73+
private final boolean validateStFunctions;
6974

70-
StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
71-
boolean supportStFunctions) {
75+
/**
76+
* Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens,
77+
* validation mode, and function validation flag.
78+
* @param startDelimiterToken the character used to denote the start of a template
79+
* variable (e.g., '{')
80+
* @param endDelimiterToken the character used to denote the end of a template
81+
* variable (e.g., '}')
82+
* @param validationMode the mode to use for template variable validation; must not be
83+
* null
84+
* @param validateStFunctions whether to validate StringTemplate functions in the
85+
* template
86+
*/
87+
public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
88+
boolean validateStFunctions) {
7289
Assert.notNull(validationMode, "validationMode cannot be null");
7390
this.startDelimiterToken = startDelimiterToken;
7491
this.endDelimiterToken = endDelimiterToken;
7592
this.validationMode = validationMode;
76-
this.supportStFunctions = supportStFunctions;
93+
this.validateStFunctions = validateStFunctions;
7794
}
7895

7996
@Override
@@ -101,20 +118,28 @@ private ST createST(String template) {
101118
}
102119
}
103120

104-
private void validate(ST st, Map<String, Object> templateVariables) {
121+
/**
122+
* Validates that all required template variables are provided in the model. Returns
123+
* the set of missing variables for further handling or logging.
124+
* @param st the StringTemplate instance
125+
* @param templateVariables the provided variables
126+
* @return set of missing variable names, or empty set if none are missing
127+
*/
128+
private Set<String> validate(ST st, Map<String, Object> templateVariables) {
105129
Set<String> templateTokens = getInputVariables(st);
106130
Set<String> modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>();
131+
Set<String> missingVariables = new HashSet<>(templateTokens);
132+
missingVariables.removeAll(modelKeys);
107133

108-
// Check if model provides all keys required by the template
109-
if (!modelKeys.containsAll(templateTokens)) {
110-
templateTokens.removeAll(modelKeys);
134+
if (!missingVariables.isEmpty()) {
111135
if (validationMode == ValidationMode.WARN) {
112-
logger.warn(VALIDATION_MESSAGE.formatted(templateTokens));
136+
logger.warn(VALIDATION_MESSAGE.formatted(missingVariables));
113137
}
114138
else if (validationMode == ValidationMode.THROW) {
115-
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(templateTokens));
139+
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(missingVariables));
116140
}
117141
}
142+
return missingVariables;
118143
}
119144

120145
private Set<String> getInputVariables(ST st) {
@@ -125,11 +150,12 @@ private Set<String> getInputVariables(ST st) {
125150
for (int i = 0; i < tokens.size(); i++) {
126151
Token token = tokens.get(i);
127152

153+
// Handle list variables with option (e.g., {items; separator=", "})
128154
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
129155
&& tokens.get(i + 1).getType() == STLexer.ID) {
130156
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
131157
String text = tokens.get(i + 1).getText();
132-
if (!Compiler.funcs.containsKey(text) || !supportStFunctions) {
158+
if (!Compiler.funcs.containsKey(text) || this.validateStFunctions) {
133159
inputVariables.add(text);
134160
isInsideList = true;
135161
}
@@ -138,13 +164,19 @@ private Set<String> getInputVariables(ST st) {
138164
else if (token.getType() == STLexer.RDELIM) {
139165
isInsideList = false;
140166
}
167+
// Only add IDs that are not function calls (i.e., not immediately followed by
141168
else if (!isInsideList && token.getType() == STLexer.ID) {
142-
if (!Compiler.funcs.containsKey(token.getText()) || !supportStFunctions) {
169+
boolean isFunctionCall = (i + 1 < tokens.size() && tokens.get(i + 1).getType() == STLexer.LPAREN);
170+
boolean isDotProperty = (i > 0 && tokens.get(i - 1).getType() == STLexer.DOT);
171+
// Only add as variable if:
172+
// - Not a function call
173+
// - Not a built-in function used as property (unless validateStFunctions)
174+
if (!isFunctionCall && (!Compiler.funcs.containsKey(token.getText()) || this.validateStFunctions
175+
|| !(isDotProperty && Compiler.funcs.containsKey(token.getText())))) {
143176
inputVariables.add(token.getText());
144177
}
145178
}
146179
}
147-
148180
return inputVariables;
149181
}
150182

@@ -163,7 +195,7 @@ public static class Builder {
163195

164196
private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;
165197

166-
private boolean supportStFunctions = DEFAULT_SUPPORT_ST_FUNCTIONS;
198+
private boolean validateStFunctions = DEFAULT_VALIDATE_ST_FUNCTIONS;
167199

168200
private Builder() {
169201
}
@@ -215,8 +247,8 @@ public Builder validationMode(ValidationMode validationMode) {
215247
* ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}).
216248
* @return This builder instance for chaining.
217249
*/
218-
public Builder supportStFunctions() {
219-
this.supportStFunctions = true;
250+
public Builder validateStFunctions() {
251+
this.validateStFunctions = true;
220252
return this;
221253
}
222254

@@ -226,7 +258,7 @@ public Builder supportStFunctions() {
226258
* @return A configured {@link StTemplateRenderer}.
227259
*/
228260
public StTemplateRenderer build() {
229-
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, supportStFunctions);
261+
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, validateStFunctions);
230262
}
231263

232264
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@NonNullApi
18+
@NonNullFields
19+
package org.springframework.ai.template.st;
20+
21+
import org.springframework.lang.NonNullApi;
22+
import org.springframework.lang.NonNullFields;
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
* Copyright 2023-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.template.st;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import java.util.HashMap;
22+
import java.util.Map;
23+
24+
import org.junit.jupiter.api.Disabled;
25+
import org.junit.jupiter.api.Test;
26+
import org.springframework.ai.template.ValidationMode;
27+
28+
/**
29+
* Additional edge and robustness tests for {@link StTemplateRenderer}.
30+
*/
31+
class StTemplateRendererEdgeTests {
32+
33+
/**
34+
* Built-in functions (first, last) are rendered correctly with variables.
35+
*/
36+
@Test
37+
void shouldHandleMultipleBuiltInFunctionsAndVariables() {
38+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
39+
Map<String, Object> variables = new HashMap<>();
40+
variables.put("list", java.util.Arrays.asList("a", "b", "c"));
41+
variables.put("name", "Mark");
42+
String template = "{name}: {first(list)}, {last(list)}";
43+
String result = renderer.apply(template, variables);
44+
assertThat(result).isEqualTo("Mark: a, c");
45+
}
46+
47+
/**
48+
* Nested and chained built-in functions are handled when validation is enabled.
49+
* Confirms that ST4 supports valid nested function expressions.
50+
*/
51+
@Test
52+
void shouldSupportValidNestedFunctionExpressionInST4() {
53+
Map<String, Object> variables = new HashMap<>();
54+
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
55+
String template = "{first(words)} {last(words)} {length(words)}";
56+
StTemplateRenderer defaultRenderer = StTemplateRenderer.builder().build();
57+
String defaultResult = defaultRenderer.apply(template, variables);
58+
assertThat(defaultResult).isEqualTo("hello WORLD 2");
59+
}
60+
61+
/**
62+
* Nested and chained built-in functions are handled when validation is enabled.
63+
*/
64+
@Test
65+
void shouldHandleNestedBuiltInFunctions() {
66+
Map<String, Object> variables = new HashMap<>();
67+
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
68+
String template = "{first(words)} {last(words)} {length(words)}";
69+
StTemplateRenderer renderer = StTemplateRenderer.builder().validateStFunctions().build();
70+
String result = renderer.apply(template, variables);
71+
assertThat(result).isEqualTo("hello WORLD 2");
72+
}
73+
74+
/**
75+
* Built-in functions as properties are rendered correctly if supported.
76+
*/
77+
@Test
78+
@Disabled("It is very hard to validate the template expression when using property style access of built-in functions ")
79+
void shouldSupportBuiltInFunctionsAsProperties() {
80+
Map<String, Object> variables = new HashMap<>();
81+
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
82+
String template = "{words.first} {words.last} {words.length}";
83+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
84+
String result = renderer.apply(template, variables);
85+
assertThat(result).isEqualTo("hello WORLD 2");
86+
}
87+
88+
/**
89+
* Built-in functions are not reported as missing variables in THROW mode.
90+
*/
91+
@Test
92+
void shouldNotReportBuiltInFunctionsAsMissingVariablesInThrowMode() {
93+
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.THROW).build();
94+
Map<String, Object> variables = new HashMap<>();
95+
variables.put("memory", "abc");
96+
String template = "{if(strlen(memory))}ok{endif}";
97+
String result = renderer.apply(template, variables);
98+
assertThat(result).isEqualTo("ok");
99+
}
100+
101+
/**
102+
* Built-in functions are not reported as missing variables in WARN mode.
103+
*/
104+
@Test
105+
void shouldNotReportBuiltInFunctionsAsMissingVariablesInWarnMode() {
106+
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.WARN).build();
107+
Map<String, Object> variables = new HashMap<>();
108+
variables.put("memory", "abc");
109+
String template = "{if(strlen(memory))}ok{endif}";
110+
String result = renderer.apply(template, variables);
111+
assertThat(result).isEqualTo("ok");
112+
}
113+
114+
/**
115+
* Variables with names similar to built-in functions are treated as normal variables.
116+
*/
117+
@Test
118+
void shouldHandleVariableNamesSimilarToBuiltInFunctions() {
119+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
120+
Map<String, Object> variables = new HashMap<>();
121+
variables.put("lengthy", "foo");
122+
variables.put("firstName", "bar");
123+
String template = "{lengthy} {firstName}";
124+
String result = renderer.apply(template, variables);
125+
assertThat(result).isEqualTo("foo bar");
126+
}
127+
128+
// --- Built-in Function Handling Tests END ---
129+
130+
@Test
131+
void shouldRenderEscapedDelimiters() {
132+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
133+
Map<String, Object> variables = new HashMap<>();
134+
variables.put("x", "y");
135+
String template = "{x} \\{foo\\}";
136+
String result = renderer.apply(template, variables);
137+
assertThat(result).isEqualTo("y {foo}");
138+
}
139+
140+
@Test
141+
void shouldRenderStaticTextTemplate() {
142+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
143+
Map<String, Object> variables = new HashMap<>();
144+
String template = "Just static text.";
145+
String result = renderer.apply(template, variables);
146+
assertThat(result).isEqualTo("Just static text.");
147+
}
148+
149+
// Duplicate removed: shouldHandleVariableNamesSimilarToBuiltInFunctions
150+
// (now grouped at the top of the class)
151+
152+
@Test
153+
void shouldHandleLargeNumberOfVariables() {
154+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
155+
Map<String, Object> variables = new HashMap<>();
156+
StringBuilder template = new StringBuilder();
157+
for (int i = 0; i < 100; i++) {
158+
String key = "var" + i;
159+
variables.put(key, i);
160+
template.append("{" + key + "} ");
161+
}
162+
String result = renderer.apply(template.toString().trim(), variables);
163+
StringBuilder expected = new StringBuilder();
164+
for (int i = 0; i < 100; i++) {
165+
expected.append(i).append(" ");
166+
}
167+
assertThat(result).isEqualTo(expected.toString().trim());
168+
}
169+
170+
@Test
171+
void shouldRenderUnicodeAndSpecialCharacters() {
172+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
173+
Map<String, Object> variables = new HashMap<>();
174+
variables.put("emoji", "😀");
175+
variables.put("accented", "Café");
176+
String template = "{emoji} {accented}";
177+
String result = renderer.apply(template, variables);
178+
assertThat(result).isEqualTo("😀 Café");
179+
}
180+
181+
@Test
182+
void shouldRenderNullVariableValuesAsBlank() {
183+
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
184+
Map<String, Object> variables = new HashMap<>();
185+
variables.put("foo", null);
186+
String template = "Value: {foo}";
187+
String result = renderer.apply(template, variables);
188+
assertThat(result).isEqualTo("Value: ");
189+
}
190+
191+
}

spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,11 @@ void shouldHandleObjectVariables() {
282282

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

0 commit comments

Comments
 (0)