Skip to content

Commit 9d31959

Browse files
committed
feat: add keepMissingVariables option to StTemplateRenderer
Signed-off-by: Akika <[email protected]>
1 parent bf5ebce commit 9d31959

File tree

2 files changed

+174
-2
lines changed

2 files changed

+174
-2
lines changed

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ public class StTemplateRenderer implements TemplateRenderer {
6767

6868
private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;
6969

70+
private static final boolean DEFAULT_KEEP_MISSING_VARIABLES = false;
71+
7072
private final char startDelimiterToken;
7173

7274
private final char endDelimiterToken;
@@ -75,6 +77,8 @@ public class StTemplateRenderer implements TemplateRenderer {
7577

7678
private final boolean validateStFunctions;
7779

80+
private final boolean keepMissingVariables;
81+
7882
/**
7983
* Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens,
8084
* validation mode, and function validation flag.
@@ -88,12 +92,13 @@ public class StTemplateRenderer implements TemplateRenderer {
8892
* template
8993
*/
9094
public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
91-
boolean validateStFunctions) {
95+
boolean validateStFunctions, boolean keepMissingVariables) {
9296
Assert.notNull(validationMode, "validationMode cannot be null");
9397
this.startDelimiterToken = startDelimiterToken;
9498
this.endDelimiterToken = endDelimiterToken;
9599
this.validationMode = validationMode;
96100
this.validateStFunctions = validateStFunctions;
101+
this.keepMissingVariables = keepMissingVariables;
97102
}
98103

99104
@Override
@@ -103,6 +108,17 @@ public String apply(String template, Map<String, Object> variables) {
103108
Assert.noNullElements(variables.keySet(), "variables keys cannot be null");
104109

105110
ST st = createST(template);
111+
// If keepMissingVariables is enabled, first fill missing variables with placeholders.
112+
if (this.keepMissingVariables) {
113+
Set<String> allVars = getInputVariables(st);
114+
Set<String> missingVars = new HashSet<>(allVars);
115+
missingVars.removeAll(variables.keySet());
116+
117+
for (String missingVar : missingVars) {
118+
st.add(missingVar, String.format("%c%s%c",
119+
this.startDelimiterToken, missingVar, this.endDelimiterToken));
120+
}
121+
}
106122
for (Map.Entry<String, Object> entry : variables.entrySet()) {
107123
st.add(entry.getKey(), entry.getValue());
108124
}
@@ -211,6 +227,8 @@ public static final class Builder {
211227

212228
private boolean validateStFunctions = DEFAULT_VALIDATE_ST_FUNCTIONS;
213229

230+
private boolean keepMissingVariables = DEFAULT_KEEP_MISSING_VARIABLES;
231+
214232
private Builder() {
215233
}
216234

@@ -266,14 +284,53 @@ public Builder validateStFunctions() {
266284
return this;
267285
}
268286

287+
/**
288+
* Configures the renderer to keep missing variables in their original placeholder
289+
* form instead of letting StringTemplate render them as empty strings.
290+
*
291+
* <p>When enabled, variables that are referenced in the template but not provided in
292+
* the input map will be rendered as their placeholder text (for example:
293+
* {@code "{username}"} remains {@code "{username}"} in the final output).</p>
294+
*
295+
* <p><b>Important:</b> {@code keepMissingVariables(true)} can only be used when
296+
* {@code validationMode} is set to {@link ValidationMode#NONE}. If validation is
297+
* enabled ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}), enabling
298+
* this flag will result in an {@link IllegalArgumentException} when building the
299+
* renderer.</p>
300+
*
301+
* <p>Example usage:</p>
302+
* <pre>{@code
303+
* TemplateRenderer renderer = MyStTemplateRenderer.builder()
304+
* .validationMode(ValidationMode.NONE)
305+
* .keepMissingVariables(true)
306+
* .build();
307+
*
308+
* String result = renderer.apply("Hello, {name}, today is {date}",
309+
* Map.of("name", "Alice"));
310+
* // result: "Hello, Alice, today is {date}"
311+
* }</pre>
312+
*
313+
* @param keepMissingVariables whether to keep missing variables as placeholders
314+
* (e.g. "{var}") in the rendered output
315+
* @return this builder instance for chaining
316+
*/
317+
public Builder keepMissingVariables(boolean keepMissingVariables) {
318+
this.keepMissingVariables = keepMissingVariables;
319+
return this;
320+
}
321+
269322
/**
270323
* Builds and returns a new {@link StTemplateRenderer} instance with the
271324
* configured settings.
272325
* @return A configured {@link StTemplateRenderer}.
273326
*/
274327
public StTemplateRenderer build() {
328+
if (this.keepMissingVariables && this.validationMode != ValidationMode.NONE) {
329+
throw new IllegalArgumentException(
330+
"keepMissingVariables can only be enabled when validationMode is NONE.");
331+
}
275332
return new StTemplateRenderer(this.startDelimiterToken, this.endDelimiterToken, this.validationMode,
276-
this.validateStFunctions);
333+
this.validateStFunctions, this.keepMissingVariables);
277334
}
278335

279336
}

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,4 +353,119 @@ void shouldValidatePropertyAccessCorrectly() {
353353
"Not all variables were replaced in the template. Missing variable names are: [user]");
354354
}
355355

356+
/**
357+
* Tests that keepMissingVariables is false by default, so missing variables are rendered as empty strings.
358+
*/
359+
@Test
360+
void shouldDefaultToNotKeepMissingVariables() {
361+
StTemplateRenderer renderer = StTemplateRenderer.builder()
362+
.validationMode(ValidationMode.NONE)
363+
.build();
364+
365+
String result = renderer.apply("{name}", Map.of());
366+
assertThat(result).isEmpty();
367+
}
368+
369+
/**
370+
* Tests basic functionality: missing variables are preserved as placeholders
371+
* while provided variables are rendered normally.
372+
*/
373+
@Test
374+
void shouldPreservePlaceholderForMissingVariables() {
375+
StTemplateRenderer renderer = StTemplateRenderer.builder()
376+
.validationMode(ValidationMode.NONE)
377+
.keepMissingVariables(true)
378+
.build();
379+
380+
Map<String, Object> variables = new HashMap<>();
381+
variables.put("name", "Alice");
382+
383+
String template = "Hello {name}, today is {date}";
384+
String result = renderer.apply(template, variables);
385+
386+
assertThat(result).isEqualTo("Hello Alice, today is {date}");
387+
}
388+
389+
/**
390+
* Tests that multiple missing variables are all preserved in their original placeholder form.
391+
*/
392+
@Test
393+
void shouldKeepPlaceholdersForMultipleMissingVariables() {
394+
StTemplateRenderer renderer = StTemplateRenderer.builder()
395+
.validationMode(ValidationMode.NONE)
396+
.keepMissingVariables(true)
397+
.build();
398+
399+
Map<String, Object> variables = Map.of("a", 1);
400+
401+
String template = "{a} {b} {c}";
402+
String result = renderer.apply(template, variables);
403+
404+
assertThat(result).isEqualTo("1 {b} {c}");
405+
}
406+
407+
/**
408+
* Tests that keepMissingVariables cannot be enabled with THROW validation mode,
409+
* as they are semantically incompatible.
410+
*/
411+
@Test
412+
void shouldNotAllowThrowValidationModeWhenKeepMissingVariables() {
413+
assertThatThrownBy(() -> StTemplateRenderer.builder()
414+
.validationMode(ValidationMode.THROW)
415+
.keepMissingVariables(true)
416+
.build())
417+
.isInstanceOf(IllegalArgumentException.class)
418+
.hasMessageContaining("keepMissingVariables can only be enabled when validationMode is NONE");
419+
}
420+
421+
/**
422+
* Tests that keepMissingVariables cannot be enabled with WARN validation mode,
423+
* as they are semantically incompatible.
424+
*/
425+
@Test
426+
void shouldNotAllowWarnValidationModeWhenKeepMissingVariables() {
427+
assertThatThrownBy(() -> StTemplateRenderer.builder()
428+
.validationMode(ValidationMode.WARN)
429+
.keepMissingVariables(true)
430+
.build())
431+
.isInstanceOf(IllegalArgumentException.class)
432+
.hasMessageContaining("keepMissingVariables can only be enabled when validationMode is NONE");
433+
}
434+
435+
/**
436+
* Tests that keepMissingVariables respects custom delimiter tokens when preserving placeholders.
437+
*/
438+
@Test
439+
void shouldRespectCustomDelimitersWhenKeepMissingVariables() {
440+
StTemplateRenderer renderer = StTemplateRenderer.builder()
441+
.startDelimiterToken('<')
442+
.endDelimiterToken('>')
443+
.validationMode(ValidationMode.NONE)
444+
.keepMissingVariables(true)
445+
.build();
446+
447+
Map<String, Object> variables = Map.of("name", "Spring AI");
448+
String result = renderer.apply("Hello <name>, today is <date>", variables);
449+
450+
assertThat(result).isEqualTo("Hello Spring AI, today is <date>");
451+
}
452+
453+
/**
454+
* Tests that StringTemplate built-in functions work correctly and missing variables are still preserved.
455+
*/
456+
@Test
457+
void shouldNotInterfereWithBuiltInFunctionsWhenKeepMissingVariables() {
458+
StTemplateRenderer renderer = StTemplateRenderer.builder()
459+
.validationMode(ValidationMode.NONE)
460+
.keepMissingVariables(true)
461+
.build();
462+
463+
Map<String, Object> variables = Map.of("items", new String[] {"a", "b"});
464+
String template = "{first(items)} {last(items)} {missing}";
465+
String result = renderer.apply(template, variables);
466+
467+
assertThat(result).isEqualTo("a b {missing}");
468+
}
469+
470+
356471
}

0 commit comments

Comments
 (0)