From aea98a187e88dfc4e1a06c4369dd63eac3a24ac5 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Wed, 15 Apr 2020 21:15:45 +0200 Subject: [PATCH] Qute if section - consider truthy/falsy values during evaluation - resolves #8582 --- docs/src/main/asciidoc/qute-reference.adoc | 11 ++-- .../main/java/io/quarkus/qute/Booleans.java | 59 +++++++++++++++++++ .../java/io/quarkus/qute/IfSectionHelper.java | 32 ++++++---- .../java/io/quarkus/qute/ValueResolvers.java | 8 ++- .../java/io/quarkus/qute/BooleansTest.java | 52 ++++++++++++++++ .../java/io/quarkus/qute/IfSectionTest.java | 42 ++++++++++++- .../test/java/io/quarkus/qute/SimpleTest.java | 2 + 7 files changed, 187 insertions(+), 19 deletions(-) create mode 100644 independent-projects/qute/core/src/main/java/io/quarkus/qute/Booleans.java create mode 100644 independent-projects/qute/core/src/test/java/io/quarkus/qute/BooleansTest.java diff --git a/docs/src/main/asciidoc/qute-reference.adoc b/docs/src/main/asciidoc/qute-reference.adoc index 91b79fc905603..e48dd69ff5221 100644 --- a/docs/src/main/asciidoc/qute-reference.adoc +++ b/docs/src/main/asciidoc/qute-reference.adoc @@ -222,6 +222,8 @@ This could be useful to access data for which the key is overriden: |`{item.isActive ? item.name : 'Inactive item'}` outputs the value of `item.name` if `item.isActive` resolves to `true`. |=== +TIP: The condition in a ternary operator evaluates to `true` if the value is not considered `falsy` as described in the <>. + NOTE: In fact, the operators are implemented as "virtual methods" that consume one parameter and can be used with infix notation, i.e. `{person.name or 'John'}` is translated to `{person.name.or('John')}`. ===== Character Escapes @@ -374,8 +376,9 @@ The output will be: [[if_section]] ===== If Section -A basic control flow section. -The simplest possible version accepts a single parameter and renders the content if it's evaluated to `true` (or `Boolean.TRUE`). +The `if` section represents a basic control flow section. +The simplest possible version accepts a single parameter and renders the content if the condition is evaluated to `true`. +A condition without an operator evaluates to `true` if the value is not considered `falsy`, i.e. if the value is not `null`, `false`, an empty collection, an empty map, an empty array, an empty string/char sequence or a number equal to zero. [source] ---- @@ -384,7 +387,7 @@ The simplest possible version accepts a single parameter and renders the content {/if} ---- -You can also use the following operators: +You can also use the following operators in a condition: |=== |Operator |Aliases |Precedence (higher wins) @@ -456,7 +459,7 @@ Precedence rules can be overridden by parentheses. ---- -You can add any number of `else` blocks: +You can also add any number of `else` blocks: [source] ---- diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/Booleans.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/Booleans.java new file mode 100644 index 0000000000000..af20027042ec1 --- /dev/null +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/Booleans.java @@ -0,0 +1,59 @@ +package io.quarkus.qute; + +import io.quarkus.qute.Results.Result; +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class Booleans { + + private Booleans() { + } + + /** + * A value is considered falsy if it's null, {@link Result#NOT_FOUND}, {code false}, an empty collection, an empty map, an + * empty array, an empty string/char sequence or a number equal to zero. + * + * @param value + * @return {@code true} if the value is falsy + */ + public static boolean isFalsy(Object value) { + if (value == null || Results.Result.NOT_FOUND.equals(value)) { + return true; + } else if (value instanceof Boolean) { + return !(Boolean) value; + } else if (value instanceof AtomicBoolean) { + return !((AtomicBoolean) value).get(); + } else if (value instanceof Collection) { + return ((Collection) value).isEmpty(); + } else if (value instanceof Map) { + return ((Map) value).isEmpty(); + } else if (value.getClass().isArray()) { + return Array.getLength(value) == 0; + } else if (value instanceof CharSequence) { + return ((CharSequence) value).length() == 0; + } else if (value instanceof Number) { + return isZero((Number) value); + } + return false; + } + + private static boolean isZero(Number number) { + if (number instanceof BigDecimal) { + return BigDecimal.ZERO.compareTo((BigDecimal) number) == 0; + } else if (number instanceof BigInteger) { + return BigInteger.ZERO.equals(number); + } + if (number instanceof Float || number instanceof Double) { + return number.doubleValue() == 0.0; + } + if (number instanceof Byte || number instanceof Short || number instanceof Integer || number instanceof Long) { + return number.longValue() == 0L; + } + return BigDecimal.ZERO.compareTo(new BigDecimal(number.toString())) == 0; + } + +} diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java index 7aca06158b18f..2b033790e3864 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/IfSectionHelper.java @@ -1,5 +1,7 @@ package io.quarkus.qute; +import static io.quarkus.qute.Booleans.isFalsy; + import io.quarkus.qute.Results.Result; import io.quarkus.qute.SectionHelperFactory.ParserDelegate; import io.quarkus.qute.SectionHelperFactory.SectionInitContext; @@ -40,10 +42,20 @@ public class IfSectionHelper implements SectionHelper { @Override public CompletionStage resolve(SectionResolutionContext context) { - return resolveCondition(context, blocks.iterator()); + if (blocks.size() == 1) { + IfBlock block = blocks.get(0); + return block.condition.evaluate(context).thenCompose(r -> { + if (isFalsy(r)) { + return CompletableFuture.completedFuture(ResultNode.NOOP); + } else { + return context.execute(block.block, context.resolutionContext()); + } + }); + } + return resolveBlocks(context, blocks.iterator()); } - private CompletionStage resolveCondition(SectionResolutionContext context, + private CompletionStage resolveBlocks(SectionResolutionContext context, Iterator blocks) { IfBlock block = blocks.next(); if (block.condition.isEmpty()) { @@ -51,13 +63,13 @@ private CompletionStage resolveCondition(SectionResolutionContext co return context.execute(block.block, context.resolutionContext()); } return block.condition.evaluate(context).thenCompose(r -> { - if (Boolean.TRUE.equals(r)) { - return context.execute(block.block, context.resolutionContext()); - } else { + if (isFalsy(r)) { if (blocks.hasNext()) { - return resolveCondition(context, blocks); + return resolveBlocks(context, blocks); } return CompletableFuture.completedFuture(ResultNode.NOOP); + } else { + return context.execute(block.block, context.resolutionContext()); } }); } @@ -218,7 +230,7 @@ CompletionStage evaluateNext(SectionResolutionContext context, Object va } else { Object val; if (next.isLogicalComplement()) { - r = Boolean.TRUE.equals(r) ? Boolean.FALSE : Boolean.TRUE; + r = Booleans.isFalsy(r) ? Boolean.TRUE : Boolean.FALSE; } if (operator == null || !operator.isBinary()) { val = r; @@ -300,7 +312,7 @@ boolean evaluate(Object op1, Object op2) { return compare(op1, op2); case AND: case OR: - return Boolean.TRUE.equals(op2); + return !isFalsy(op2); default: throw new TemplateException("Not a binary operator: " + this); } @@ -338,9 +350,9 @@ boolean compare(Object op1, Object op2) { Boolean evaluate(Object op1) { switch (this) { case AND: - return Boolean.TRUE.equals(op1) ? null : Boolean.FALSE; + return isFalsy(op1) ? Boolean.FALSE : null; case OR: - return Boolean.TRUE.equals(op1) ? Boolean.TRUE : null; + return isFalsy(op1) ? null : Boolean.TRUE; default: throw new TemplateException("Not a short-circuiting operator: " + this); } diff --git a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ValueResolvers.java b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ValueResolvers.java index cff98a07211e8..dfea812c226b5 100644 --- a/independent-projects/qute/core/src/main/java/io/quarkus/qute/ValueResolvers.java +++ b/independent-projects/qute/core/src/main/java/io/quarkus/qute/ValueResolvers.java @@ -1,5 +1,7 @@ package io.quarkus.qute; +import static io.quarkus.qute.Booleans.isFalsy; + import io.quarkus.qute.Results.Result; import java.util.Collection; import java.util.Map; @@ -105,10 +107,10 @@ public boolean appliesTo(EvalContext context) { @Override public CompletionStage resolve(EvalContext context) { - if (Boolean.TRUE.equals(context.getBase())) { - return context.evaluate(context.getParams().get(0)); + if (isFalsy(context.getBase())) { + return Results.NOT_FOUND; } - return Results.NOT_FOUND; + return context.evaluate(context.getParams().get(0)); } }; diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/BooleansTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/BooleansTest.java new file mode 100644 index 0000000000000..7c8669f0fdb77 --- /dev/null +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/BooleansTest.java @@ -0,0 +1,52 @@ +package io.quarkus.qute; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +public class BooleansTest { + + @Test + public void testIsFalsy() { + assertTrue(Booleans.isFalsy(null)); + assertTrue(Booleans.isFalsy(false)); + assertTrue(Booleans.isFalsy("")); + assertTrue(Booleans.isFalsy(new StringBuffer())); + assertTrue(Booleans.isFalsy(0f)); + assertTrue(Booleans.isFalsy(0)); + assertTrue(Booleans.isFalsy(0.0)); + assertTrue(Booleans.isFalsy(new BigDecimal("0.0"))); + assertTrue(Booleans.isFalsy(new BigInteger("0"))); + assertTrue(Booleans.isFalsy(new ArrayList<>())); + assertTrue(Booleans.isFalsy(new HashSet<>())); + assertTrue(Booleans.isFalsy(new HashMap<>())); + assertTrue(Booleans.isFalsy(new String[0])); + assertTrue(Booleans.isFalsy(new AtomicLong(0))); + assertTrue(Booleans.isFalsy(new AtomicBoolean(false))); + // truthy values + assertFalse(Booleans.isFalsy(new Object())); + assertFalse(Booleans.isFalsy(true)); + assertFalse(Booleans.isFalsy("foo")); + assertFalse(Booleans.isFalsy(new StringBuffer().append("foo"))); + assertFalse(Booleans.isFalsy(1f)); + assertFalse(Booleans.isFalsy(4)); + assertFalse(Booleans.isFalsy(0.2)); + assertFalse(Booleans.isFalsy(new BigDecimal("0.1"))); + assertFalse(Booleans.isFalsy(new BigInteger("10"))); + assertFalse(Booleans.isFalsy(Collections.singletonList("foo"))); + assertFalse(Booleans.isFalsy(Collections.singletonMap("foo", "bar"))); + assertFalse(Booleans.isFalsy(new String[] { "foo" })); + assertFalse(Booleans.isFalsy(new AtomicLong(10))); + assertFalse(Booleans.isFalsy(new AtomicBoolean(true))); + } + +} diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java index 3f35182aea09a..03d186697ae60 100644 --- a/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/IfSectionTest.java @@ -6,6 +6,7 @@ import io.quarkus.qute.IfSectionHelper.Operator; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -28,7 +29,7 @@ public void tesIfElse() { } @Test - public void tesIfOperator() { + public void testIfOperator() { Engine engine = Engine.builder().addDefaults().build(); Map data = new HashMap<>(); @@ -43,7 +44,7 @@ public void tesIfOperator() { assertEquals("OK", engine.parse("{#if one >= one}OK{/if}").render(data)); assertEquals("OK", engine.parse("{#if one >= 0}OK{/if}").render(data)); assertEquals("OK", engine.parse("{#if one == one}OK{/if}").render(data)); - assertEquals("OK", engine.parse("{#if one}NOK{#else if name eq foo}OK{/if}").render(data)); + assertEquals("OK", engine.parse("{#if one is 2}NOK{#else if name eq foo}OK{/if}").render(data)); assertEquals("OK", engine.parse("{#if name is foo}OK{/if}").render(data)); assertEquals("OK", engine.parse("{#if two is 2}OK{/if}").render(data)); assertEquals("OK", engine.parse("{#if name != null}OK{/if}").render(data)); @@ -116,6 +117,43 @@ public void testParameterParsing() { assertEquals("true", params.get(2)); } + @Test + public void testFalsy() { + Engine engine = Engine.builder().addDefaults().build(); + + Map data = new HashMap<>(); + data.put("name", "foo"); + data.put("nameEmpty", ""); + data.put("boolTrue", true); + data.put("boolFalse", false); + data.put("intTwo", Integer.valueOf(2)); + data.put("intZero", Integer.valueOf(0)); + data.put("list", Collections.singleton("foo")); + data.put("setEmpty", Collections.emptySet()); + data.put("mapEmpty", Collections.emptyMap()); + data.put("array", new String[] { "foo" }); + data.put("arrayEmpty", new String[] {}); + + assertEquals("1", engine.parse("{#if name}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if nameEmpty}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if boolTrue}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if boolFalse}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if intTwo}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if intZero}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if list}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if setEmpty}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if mapEmpty}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if array}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if arrayEmpty}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if !arrayEmpty}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if arrayEmpty || name}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if arrayEmpty && name}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if array && intTwo}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if (array && intZero) || true}1{#else}0{/if}").render(data)); + assertEquals("0", engine.parse("{#if nonExistent}1{#else}0{/if}").render(data)); + assertEquals("1", engine.parse("{#if !nonExistent}1{#else}0{/if}").render(data)); + } + private void assertParserError(String template, String message, int line) { Engine engine = Engine.builder().addDefaultSectionHelpers().build(); try { diff --git a/independent-projects/qute/core/src/test/java/io/quarkus/qute/SimpleTest.java b/independent-projects/qute/core/src/test/java/io/quarkus/qute/SimpleTest.java index 4990591b1d05b..089aab2f5d85c 100644 --- a/independent-projects/qute/core/src/test/java/io/quarkus/qute/SimpleTest.java +++ b/independent-projects/qute/core/src/test/java/io/quarkus/qute/SimpleTest.java @@ -93,6 +93,8 @@ public void testTernaryOperator() { Template template = engine .parse("{name ? 'Name true' : 'Name false'}. {surname ? 'Surname true' : foo}."); assertEquals("Name true. baz.", template.data("name", true).data("foo", "baz").render()); + + assertEquals("1", engine.parse("{name ? 1 : 2}").data("name", "foo").render()); } @Test