From 60bafcef853fe7466101454ae42e9c5b4e820531 Mon Sep 17 00:00:00 2001 From: Libo Song Date: Thu, 5 Feb 2026 16:26:26 -0500 Subject: [PATCH 1/5] Fall back to un-optimized filter chain for unknown filters When AstFilterChain optimization is enabled and a filter chain contains an unknown filter (e.g. local_dt), fall back to the standard nested AstMethod evaluation at parse time instead of failing with an "Unknown filter" error and returning null. Co-Authored-By: Claude Opus 4.6 --- .../jinjava/el/ext/AstFilterChain.java | 12 ++++++ .../jinjava/el/ext/ExtendedParser.java | 41 +++++++++++++++++++ .../jinjava/el/ext/AstFilterChainTest.java | 39 ++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java index 30dfc4435..34cae1689 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java @@ -88,6 +88,18 @@ public Object eval(Bindings bindings, ELContext context) { return null; } if (filter == null) { + interpreter.addError( + new TemplateError( + ErrorType.WARNING, + ErrorReason.UNKNOWN, + ErrorItem.FILTER, + String.format("Unknown filter: %s", spec.getName()), + spec.getName(), + interpreter.getLineNumber(), + -1, + null + ) + ); return null; } diff --git a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java index 543726a7b..164a7f331 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java @@ -24,6 +24,7 @@ import com.google.common.collect.Sets; import com.hubspot.jinjava.JinjavaConfig; import com.hubspot.jinjava.LegacyOverrides; +import com.hubspot.jinjava.interpret.DisabledException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import de.odysseus.el.tree.impl.Builder; import de.odysseus.el.tree.impl.Builder.Feature; @@ -581,9 +582,49 @@ private AstNode parseFiltersAsChain(AstNode left) throws ScanException, ParseExc filterSpecs.add(new FilterSpec(filterName, filterParams)); } while ("|".equals(getToken().getImage())); + if (hasUnknownFilter(filterSpecs)) { + return buildUnoptimizedFromSpecs(left, filterSpecs); + } return createAstFilterChain(left, filterSpecs); } + private boolean hasUnknownFilter(List filterSpecs) { + return JinjavaInterpreter + .getCurrentMaybe() + .map(interp -> { + for (FilterSpec spec : filterSpecs) { + try { + if (interp.getContext().getFilter(spec.getName()) == null) { + return true; + } + } catch (DisabledException e) { + return false; + } + } + return false; + }) + .orElse(false); + } + + private AstNode buildUnoptimizedFromSpecs(AstNode input, List filterSpecs) { + AstNode v = input; + for (FilterSpec spec : filterSpecs) { + List filterParams = Lists.newArrayList(v, interpreter()); + if (spec.hasParams()) { + for (int i = 0; i < spec.getParams().getCardinality(); i++) { + filterParams.add(spec.getParams().getChild(i)); + } + } + AstProperty filterProperty = createAstDot( + identifier(FILTER_PREFIX + spec.getName()), + "filter", + true + ); + v = createAstMethod(filterProperty, createAstParameters(filterParams)); + } + return v; + } + protected AstNode parseFiltersAsNestedMethods(AstNode left) throws ScanException, ParseException { AstNode v = left; diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java index f0a0931a3..ae05cec6f 100644 --- a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java @@ -4,6 +4,9 @@ import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.objects.date.PyishDate; +import java.time.ZonedDateTime; import java.util.HashMap; import java.util.Map; import org.junit.Before; @@ -67,4 +70,40 @@ public void itHandlesFilterWithStringConversion() { String result = jinjava.render("{{ number|string|length }}", context); assertThat(result).isEqualTo("5"); } + + @Test + public void itFallsBackToUnoptimizedForUnknownFilterInChain() { + context.put("module", new PyishDate(ZonedDateTime.parse("2024-01-15T10:30:00Z"))); + RenderResult renderResult = jinjava.renderForResult( + "{% set mid = module | local_dt|unixtimestamp | pprint | md5 %}{{ mid }}", + context + ); + assertThat(renderResult.getOutput()) + .as("Should produce MD5 output since chain continues past unknown filter") + .hasSize(32); + assertThat( + renderResult + .getErrors() + .stream() + .noneMatch(e -> e.getMessage().contains("Unknown filter")) + ) + .as("Should not report 'Unknown filter' error when falling back") + .isTrue(); + } + + @Test + public void itFallsBackToUnoptimizedForUnknownFilterParity() { + String template = "{{ name | unknown_filter | lower | md5 }}"; + Jinjava jinjavaUnoptimized = new Jinjava( + JinjavaConfig.newBuilder().withEnableFilterChainOptimization(false).build() + ); + RenderResult optimizedResult = jinjava.renderForResult(template, context); + RenderResult unoptimizedResult = jinjavaUnoptimized.renderForResult( + template, + context + ); + assertThat(optimizedResult.getOutput()) + .as("Optimized should match un-optimized for unknown filter in chain") + .isEqualTo(unoptimizedResult.getOutput()); + } } From b9e58caaf3c5c43ae648463066abce47d00908bc Mon Sep 17 00:00:00 2001 From: Libo Song Date: Wed, 18 Feb 2026 14:02:57 -0500 Subject: [PATCH 2/5] Fix disabled filter handling in AstFilterChain to match non-chained behavior The non-chained path in JinjavaInterpreterResolver skips disabled filters and passes the value through to the next filter. The chained path was incorrectly returning null for the entire expression, discarding the input and aborting remaining filters. Co-Authored-By: Claude Opus 4.6 --- src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java index 34cae1689..ef5322af1 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java @@ -85,7 +85,7 @@ public Object eval(Bindings bindings, ELContext context) { e ) ); - return null; + continue; } if (filter == null) { interpreter.addError( From 42825eeba905720b0c4097433960532dd74f396f Mon Sep 17 00:00:00 2001 From: Libo Song Date: Wed, 18 Feb 2026 17:07:23 -0500 Subject: [PATCH 3/5] Fix disabled filter handling: set value to null and continue chain The previous fix used `continue` alone, but the non-chained path propagates null through subsequent filters rather than skipping. Setting value = null before continuing matches that behavior exactly. Adds parity test confirming optimized and non-chained paths produce identical output for disabled filters in a chain. Co-Authored-By: Claude Opus 4.6 --- .../jinjava/el/ext/AstFilterChain.java | 1 + .../jinjava/el/ext/AstFilterChainTest.java | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java index ef5322af1..44f46ecc6 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java @@ -85,6 +85,7 @@ public Object eval(Bindings bindings, ELContext context) { e ) ); + value = null; continue; } if (filter == null) { diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java index ae05cec6f..3f9d99773 100644 --- a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java @@ -2,13 +2,19 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import com.hubspot.jinjava.Jinjava; import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.Context; import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError.ErrorItem; +import com.hubspot.jinjava.interpret.TemplateError.ErrorReason; import com.hubspot.jinjava.objects.date.PyishDate; import java.time.ZonedDateTime; import java.util.HashMap; import java.util.Map; +import java.util.Set; import org.junit.Before; import org.junit.Test; @@ -106,4 +112,60 @@ public void itFallsBackToUnoptimizedForUnknownFilterParity() { .as("Optimized should match un-optimized for unknown filter in chain") .isEqualTo(unoptimizedResult.getOutput()); } + + @Test + public void itSkipsDisabledFilterAndContinuesChain() { + Map> disabled = ImmutableMap.of( + Context.Library.FILTER, + ImmutableSet.of("lower") + ); + Jinjava jinjavaWithDisabled = new Jinjava( + JinjavaConfig + .newBuilder() + .withEnableFilterChainOptimization(true) + .withDisabled(disabled) + .build() + ); + + RenderResult result = jinjavaWithDisabled.renderForResult( + "{{ name|trim|lower|capitalize }}", + context + ); + + assertThat(result.getErrors()).isNotEmpty(); + assertThat(result.getErrors().get(0).getItem()).isEqualTo(ErrorItem.FILTER); + assertThat(result.getErrors().get(0).getReason()).isEqualTo(ErrorReason.DISABLED); + assertThat(result.getErrors().get(0).getMessage()).contains("lower"); + } + + @Test + public void itMatchesNonChainedBehaviorForDisabledFilter() { + Map> disabled = ImmutableMap.of( + Context.Library.FILTER, + ImmutableSet.of("lower") + ); + String template = "{{ name|trim|lower|capitalize }}"; + + Jinjava optimized = new Jinjava( + JinjavaConfig + .newBuilder() + .withEnableFilterChainOptimization(true) + .withDisabled(disabled) + .build() + ); + Jinjava unoptimized = new Jinjava( + JinjavaConfig + .newBuilder() + .withEnableFilterChainOptimization(false) + .withDisabled(disabled) + .build() + ); + + RenderResult optimizedResult = optimized.renderForResult(template, context); + RenderResult unoptimizedResult = unoptimized.renderForResult(template, context); + + assertThat(optimizedResult.getOutput()) + .as("Optimized should match un-optimized for disabled filter in chain") + .isEqualTo(unoptimizedResult.getOutput()); + } } From 025ae5ef6e1dd9ce35a81cb899dd11987fb63548 Mon Sep 17 00:00:00 2001 From: Libo Song Date: Wed, 18 Feb 2026 17:28:29 -0500 Subject: [PATCH 4/5] Remove hasUnknownFilter fallback, handle unknown filters inline Unknown filters are now handled the same as disabled filters in AstFilterChain: set value to null and continue. This matches the non-chained behavior where null propagates through subsequent filters. The parse-time hasUnknownFilter check and buildUnoptimizedFromSpecs fallback are no longer needed and are removed. Co-Authored-By: Claude Opus 4.6 --- .../jinjava/el/ext/AstFilterChain.java | 15 +------ .../jinjava/el/ext/ExtendedParser.java | 41 ------------------- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java index 44f46ecc6..cecaa9ea6 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/AstFilterChain.java @@ -89,19 +89,8 @@ public Object eval(Bindings bindings, ELContext context) { continue; } if (filter == null) { - interpreter.addError( - new TemplateError( - ErrorType.WARNING, - ErrorReason.UNKNOWN, - ErrorItem.FILTER, - String.format("Unknown filter: %s", spec.getName()), - spec.getName(), - interpreter.getLineNumber(), - -1, - null - ) - ); - return null; + value = null; + continue; } Object[] args = evaluateFilterArgs(spec, bindings, context); diff --git a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java index 164a7f331..543726a7b 100644 --- a/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java +++ b/src/main/java/com/hubspot/jinjava/el/ext/ExtendedParser.java @@ -24,7 +24,6 @@ import com.google.common.collect.Sets; import com.hubspot.jinjava.JinjavaConfig; import com.hubspot.jinjava.LegacyOverrides; -import com.hubspot.jinjava.interpret.DisabledException; import com.hubspot.jinjava.interpret.JinjavaInterpreter; import de.odysseus.el.tree.impl.Builder; import de.odysseus.el.tree.impl.Builder.Feature; @@ -582,49 +581,9 @@ private AstNode parseFiltersAsChain(AstNode left) throws ScanException, ParseExc filterSpecs.add(new FilterSpec(filterName, filterParams)); } while ("|".equals(getToken().getImage())); - if (hasUnknownFilter(filterSpecs)) { - return buildUnoptimizedFromSpecs(left, filterSpecs); - } return createAstFilterChain(left, filterSpecs); } - private boolean hasUnknownFilter(List filterSpecs) { - return JinjavaInterpreter - .getCurrentMaybe() - .map(interp -> { - for (FilterSpec spec : filterSpecs) { - try { - if (interp.getContext().getFilter(spec.getName()) == null) { - return true; - } - } catch (DisabledException e) { - return false; - } - } - return false; - }) - .orElse(false); - } - - private AstNode buildUnoptimizedFromSpecs(AstNode input, List filterSpecs) { - AstNode v = input; - for (FilterSpec spec : filterSpecs) { - List filterParams = Lists.newArrayList(v, interpreter()); - if (spec.hasParams()) { - for (int i = 0; i < spec.getParams().getCardinality(); i++) { - filterParams.add(spec.getParams().getChild(i)); - } - } - AstProperty filterProperty = createAstDot( - identifier(FILTER_PREFIX + spec.getName()), - "filter", - true - ); - v = createAstMethod(filterProperty, createAstParameters(filterParams)); - } - return v; - } - protected AstNode parseFiltersAsNestedMethods(AstNode left) throws ScanException, ParseException { AstNode v = left; From 96d3f35ed38cb411f8be9faf7e6755bd11b15264 Mon Sep 17 00:00:00 2001 From: Libo Song Date: Wed, 18 Feb 2026 17:33:55 -0500 Subject: [PATCH 5/5] Rename test methods to reflect removal of fallback behavior Co-Authored-By: Claude Opus 4.6 --- .../java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java index 3f9d99773..6c514bfd7 100644 --- a/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java +++ b/src/test/java/com/hubspot/jinjava/el/ext/AstFilterChainTest.java @@ -78,7 +78,7 @@ public void itHandlesFilterWithStringConversion() { } @Test - public void itFallsBackToUnoptimizedForUnknownFilterInChain() { + public void itHandlesUnknownFilterInChain() { context.put("module", new PyishDate(ZonedDateTime.parse("2024-01-15T10:30:00Z"))); RenderResult renderResult = jinjava.renderForResult( "{% set mid = module | local_dt|unixtimestamp | pprint | md5 %}{{ mid }}", @@ -93,12 +93,12 @@ public void itFallsBackToUnoptimizedForUnknownFilterInChain() { .stream() .noneMatch(e -> e.getMessage().contains("Unknown filter")) ) - .as("Should not report 'Unknown filter' error when falling back") + .as("Should not report 'Unknown filter' error") .isTrue(); } @Test - public void itFallsBackToUnoptimizedForUnknownFilterParity() { + public void itMatchesNonChainedBehaviorForUnknownFilter() { String template = "{{ name | unknown_filter | lower | md5 }}"; Jinjava jinjavaUnoptimized = new Jinjava( JinjavaConfig.newBuilder().withEnableFilterChainOptimization(false).build()