diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java index 1d53f617c..66790369d 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltIn.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltIn.java @@ -85,7 +85,7 @@ abstract class BuiltIn extends Expression implements Cloneable { static final Set CAMEL_CASE_NAMES = new TreeSet<>(); static final Set SNAKE_CASE_NAMES = new TreeSet<>(); - static final int NUMBER_OF_BIS = 302; + static final int NUMBER_OF_BIS = 305; static final HashMap BUILT_INS_BY_NAME = new HashMap<>(NUMBER_OF_BIS * 3 / 2 + 1, 1f); static final String BI_NAME_SNAKE_CASE_WITH_ARGS = "with_args"; @@ -116,6 +116,8 @@ abstract class BuiltIn extends Expression implements Cloneable { putBI("datetime", new BuiltInsForMultipleTypes.dateBI(TemplateDateModel.DATETIME)); putBI("datetime_if_unknown", "datetimeIfUnknown", new BuiltInsForDates.dateType_if_unknownBI(TemplateDateModel.DATETIME)); putBI("default", new BuiltInsForExistenceHandling.defaultBI()); + putBI("distinct", new BuiltInsForSequences.distinctBI()); + putBI("distinct_by", "distinctBy", new BuiltInsForSequences.distinctByBI()); putBI("double", new doubleBI()); putBI("drop_while", "dropWhile", new BuiltInsForSequences.drop_whileBI()); putBI("empty_to_null", "emptyToNull", new BuiltInsForExistenceHandling.empty_to_nullBI()); diff --git a/freemarker-core/src/main/java/freemarker/core/BuiltInsForSequences.java b/freemarker-core/src/main/java/freemarker/core/BuiltInsForSequences.java index 4daf8ef81..714147952 100644 --- a/freemarker-core/src/main/java/freemarker/core/BuiltInsForSequences.java +++ b/freemarker-core/src/main/java/freemarker/core/BuiltInsForSequences.java @@ -25,6 +25,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.Date; +import java.util.LinkedHashSet; import java.util.List; import freemarker.ext.beans.CollectionModel; @@ -545,61 +546,7 @@ TemplateModel _eval(Environment env) } } - static class sort_byBI extends sortBI { - class BIMethod implements TemplateMethodModelEx { - TemplateSequenceModel seq; - - BIMethod(TemplateSequenceModel seq) { - this.seq = seq; - } - - @Override - public Object exec(List args) - throws TemplateModelException { - // Should be: - // checkMethodArgCount(args, 1); - // But for BC: - if (args.size() < 1) throw _MessageUtil.newArgCntError("?" + key, args.size(), 1); - - String[] subvars; - Object obj = args.get(0); - if (obj instanceof TemplateScalarModel) { - subvars = new String[]{((TemplateScalarModel) obj).getAsString()}; - } else if (obj instanceof TemplateSequenceModel) { - TemplateSequenceModel seq = (TemplateSequenceModel) obj; - int ln = seq.size(); - subvars = new String[ln]; - for (int i = 0; i < ln; i++) { - Object item = seq.get(i); - try { - subvars[i] = ((TemplateScalarModel) item) - .getAsString(); - } catch (ClassCastException e) { - if (!(item instanceof TemplateScalarModel)) { - throw new _TemplateModelException( - "The argument to ?", key, "(key), when it's a sequence, must be a " - + "sequence of strings, but the item at index ", Integer.valueOf(i), - " is not a string."); - } - } - } - } else { - throw new _TemplateModelException( - "The argument to ?", key, "(key) must be a string (the name of the subvariable), or a " - + "sequence of strings (the \"path\" to the subvariable)."); - } - return sort(seq, subvars); - } - } - - @Override - TemplateModel calculateResult(TemplateSequenceModel seq) { - return new BIMethod(seq); - } - } - - static class sortBI extends BuiltInForSequence { - + static abstract class compareBI extends BuiltInForSequence { private static class BooleanKVPComparator implements Comparator, Serializable { @Override @@ -625,7 +572,7 @@ public int compare(Object arg0, Object arg1) { /** * Stores a key-value pair. */ - private static class KVP { + static class KVP { private Object key; private Object value; @@ -633,6 +580,17 @@ private KVP(Object key, Object value) { this.key = key; this.value = value; } + + @Override + public boolean equals(Object obj) { + return obj instanceof KVP + && key.equals(((KVP) obj).key); + } + + @Override + public int hashCode() { + return key.hashCode(); + } } private static class LexicalKVPComparator implements Comparator { private Collator collator; @@ -666,8 +624,67 @@ public int compare(Object arg0, Object arg1) { } } } - - static TemplateModelException newInconsistentSortKeyTypeException( + + static class TransFormResult { + private final ArrayList res; + + private final Comparator keyComparator; + + TransFormResult(ArrayList res, Comparator keyComparator) { + this.res = res; + this.keyComparator = keyComparator; + } + } + + abstract class BIMethod implements TemplateMethodModelEx { + TemplateSequenceModel seq; + + BIMethod(TemplateSequenceModel seq) { + this.seq = seq; + } + + @Override + public Object exec(List args) + throws TemplateModelException { + // Should be: + // checkMethodArgCount(args, 1); + // But for BC: + if (args.size() < 1) throw _MessageUtil.newArgCntError("?" + key, args.size(), 1); + + String[] subvars; + Object obj = args.get(0); + if (obj instanceof TemplateScalarModel) { + subvars = new String[]{((TemplateScalarModel) obj).getAsString()}; + } else if (obj instanceof TemplateSequenceModel) { + TemplateSequenceModel seq = (TemplateSequenceModel) obj; + int ln = seq.size(); + subvars = new String[ln]; + for (int i = 0; i < ln; i++) { + Object item = seq.get(i); + try { + subvars[i] = ((TemplateScalarModel) item) + .getAsString(); + } catch (ClassCastException e) { + if (!(item instanceof TemplateScalarModel)) { + throw new _TemplateModelException( + "The argument to ?", key, "(key), when it's a sequence, must be a " + + "sequence of strings, but the item at index ", Integer.valueOf(i), + " is not a string."); + } + } + } + } else { + throw new _TemplateModelException( + "The argument to ?", key, "(key) must be a string (the name of the subvariable), or a " + + "sequence of strings (the \"path\" to the subvariable)."); + } + return apply(seq, subvars); + } + + abstract Object apply(TemplateSequenceModel seq, String[] subvars) throws TemplateModelException; + } + + static TemplateModelException newInconsistentCompareKeyTypeException( int keyNamesLn, String firstType, String firstTypePlural, int index, TemplateModel key) { String valueInMsg; String valuesInMsg; @@ -687,24 +704,10 @@ static TemplateModelException newInconsistentSortKeyTypeException( new _DelayedFTLTypeDescription(key), "."); } - /** - * Sorts a sequence for the {@code sort} and {@code sort_by} - * built-ins. - * - * @param seq the sequence to sort. - * @param keyNames the name of the subvariable whose value is used for the - * sorting. If the sorting is done by a sub-subvaruable, then this - * will be of length 2, and so on. If the sorting is done by the - * sequene items directly, then this argument has to be 0 length - * array or null. - * @return a new sorted sequence, or the original sequence if the - * sequence length was 0. - */ - static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) + static TransFormResult transform(TemplateSequenceModel seq, String[] keyNames) throws TemplateModelException { int ln = seq.size(); - if (ln == 0) return seq; - + ArrayList res = new ArrayList(ln); int keyNamesLn = keyNames == null ? 0 : keyNames.length; @@ -723,9 +726,9 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) throw new _TemplateModelException( startErrorMessage(keyNamesLn, i), (keyNameI == 0 - ? "Sequence items must be hashes when using ?sort_by. " + ? "Sequence items must be hashes when using ?sort_by or ?distinct_by. " : "The " + StringUtil.jQuote(keyNames[keyNameI - 1])), - " subvariable is not a hash, so ?sort_by ", + " subvariable is not a hash, so ?sort_by or ?distinct_by ", "can't proceed with getting the ", new _DelayedJQuote(keyNames[keyNameI]), " subvariable."); @@ -759,7 +762,8 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) } else { throw new _TemplateModelException( startErrorMessage(keyNamesLn, i), - "Values used for sorting must be numbers, strings, date/times or booleans."); + "Values used for sorting or distincting must be numbers, strings, date/times or " + + "booleans."); } } switch(keyType) { @@ -770,7 +774,7 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) item)); } catch (ClassCastException e) { if (!(key instanceof TemplateScalarModel)) { - throw newInconsistentSortKeyTypeException( + throw newInconsistentCompareKeyTypeException( keyNamesLn, "string", "strings", i, key); } else { throw e; @@ -785,7 +789,7 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) item)); } catch (ClassCastException e) { if (!(key instanceof TemplateNumberModel)) { - throw newInconsistentSortKeyTypeException( + throw newInconsistentCompareKeyTypeException( keyNamesLn, "number", "numbers", i, key); } } @@ -798,7 +802,7 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) item)); } catch (ClassCastException e) { if (!(key instanceof TemplateDateModel)) { - throw newInconsistentSortKeyTypeException( + throw newInconsistentCompareKeyTypeException( keyNamesLn, "date/time", "date/times", i, key); } } @@ -811,7 +815,7 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) item)); } catch (ClassCastException e) { if (!(key instanceof TemplateBooleanModel)) { - throw newInconsistentSortKeyTypeException( + throw newInconsistentCompareKeyTypeException( keyNamesLn, "boolean", "booleans", i, key); } } @@ -821,6 +825,118 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) throw new BugException("Unexpected key type"); } } + return new TransFormResult(res, keyComparator); + } + + static Object[] startErrorMessage(int keyNamesLn, int index) { + return new Object[] { + (keyNamesLn == 0 ? "?sort/distinct" : "?sort_by/distinct_by(...)"), + " failed at sequence index ", Integer.valueOf(index), + (index == 0 ? ": " : " (0-based): ") }; + } + + static final int KEY_TYPE_NOT_YET_DETECTED = 0; + + static final int KEY_TYPE_STRING = 1; + + static final int KEY_TYPE_NUMBER = 2; + + static final int KEY_TYPE_DATE = 3; + + static final int KEY_TYPE_BOOLEAN = 4; + + } + + static class distinctBI extends compareBI { + + static TemplateModel distinct(TemplateSequenceModel seq, String[] keyNames) throws TemplateModelException { + int ln = seq.size(); + if (ln <= 1) { + return seq; + } + + TransFormResult transform = transform(seq, keyNames); + ArrayList res = transform.res; + + LinkedHashSet set = new LinkedHashSet(); + set.addAll(res); + + ArrayList result = new ArrayList(); + for (Object o : set) { + if (!result.contains(o)) { + result.add(((KVP) o).value); + } + } + return new TemplateModelListSequence(result); + } + + @Override + TemplateModel calculateResult(TemplateSequenceModel seq) throws TemplateModelException { + return distinct(seq, null); + } + } + + static class distinctByBI extends distinctBI { + class DistinctByBIMethod extends BIMethod { + DistinctByBIMethod(TemplateSequenceModel seq) { + super(seq); + } + + @Override + Object apply(TemplateSequenceModel seq, String[] subvars) throws TemplateModelException { + return distinct(seq, subvars); + } + } + + @Override + TemplateModel calculateResult(TemplateSequenceModel seq) { + return new DistinctByBIMethod(seq); + } + } + + static class sort_byBI extends sortBI { + + class SortByBIMethod extends BIMethod { + SortByBIMethod(TemplateSequenceModel seq) { + super(seq); + } + + @Override + Object apply(TemplateSequenceModel seq, String[] subvars) throws TemplateModelException { + return sort(seq, subvars); + } + } + + @Override + TemplateModel calculateResult(TemplateSequenceModel seq) { + return new SortByBIMethod(seq); + } + } + + static class sortBI extends compareBI { + + /** + * Sorts a sequence for the {@code sort} and {@code sort_by} + * built-ins. + * + * @param seq the sequence to sort. + * @param keyNames the name of the subvariable whose value is used for the + * sorting. If the sorting is done by a sub-subvaruable, then this + * will be of length 2, and so on. If the sorting is done by the + * sequene items directly, then this argument has to be 0 length + * array or null. + * @return a new sorted sequence, or the original sequence if the + * sequence length was 0. + */ + static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) + throws TemplateModelException { + int ln = seq.size(); + if (ln == 0) return seq; + int keyNamesLn = keyNames == null ? 0 : keyNames.length; + + TransFormResult transform = transform(seq, keyNames); + ArrayList res = transform.res; + Comparator keyComparator = transform.keyComparator; // Sort the List[KVP]: try { @@ -838,33 +954,16 @@ static TemplateSequenceModel sort(TemplateSequenceModel seq, String[] keyNames) return new TemplateModelListSequence(res); } - static Object[] startErrorMessage(int keyNamesLn) { - return new Object[] { (keyNamesLn == 0 ? "?sort" : "?sort_by(...)"), " failed: " }; - } - - static Object[] startErrorMessage(int keyNamesLn, int index) { - return new Object[] { - (keyNamesLn == 0 ? "?sort" : "?sort_by(...)"), - " failed at sequence index ", Integer.valueOf(index), - (index == 0 ? ": " : " (0-based): ") }; - } - - static final int KEY_TYPE_NOT_YET_DETECTED = 0; - - static final int KEY_TYPE_STRING = 1; - - static final int KEY_TYPE_NUMBER = 2; - - static final int KEY_TYPE_DATE = 3; - - static final int KEY_TYPE_BOOLEAN = 4; - @Override TemplateModel calculateResult(TemplateSequenceModel seq) throws TemplateModelException { return sort(seq, null); } - + + static Object[] startErrorMessage(int keyNamesLn) { + return new Object[] { (keyNamesLn == 0 ? "?sort" : "?sort_by(...)"), " failed: " }; + } + } static class sequenceBI extends BuiltIn { diff --git a/freemarker-core/src/test/java/freemarker/core/DistinctBiTest.java b/freemarker-core/src/test/java/freemarker/core/DistinctBiTest.java new file mode 100644 index 000000000..d30c97bc5 --- /dev/null +++ b/freemarker-core/src/test/java/freemarker/core/DistinctBiTest.java @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 + * + * http://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 freemarker.core; + +import static freemarker.core.Configurable.*; + +import java.io.IOException; +import java.sql.Timestamp; +import java.util.List; + +import org.junit.Test; + +import com.google.common.collect.ImmutableList; + +import freemarker.template.Configuration; +import freemarker.template.TemplateException; +import freemarker.test.TemplateTest; + +public class DistinctBiTest extends TemplateTest { + + private static final List TEST_DISTINCT_PARAMS = ImmutableList.of( + new TestParam(ImmutableList.of("a", "b", "c", "a", "c", "d"), "a, b, c, d"), + new TestParam(ImmutableList.of(1, 2, 3, 1, 5), "1, 2, 3, 5"), + new TestParam(ImmutableList.of(new Timestamp(0), new Timestamp(1000), new Timestamp(123000), + new Timestamp(5000), + new Timestamp(123000)), + "1970-01-01 01:00:00, 1970-01-01 01:00:01, 1970-01-01 01:02:03, 1970-01-01 01:00:05"), + new TestParam(ImmutableList.of(), ""), + new TestParam(ImmutableList.of("x"), "x") + ); + + private static final List TEST_DISTINCT_BY_PARAMS; + + static { + ImmutableList beans = ImmutableList.of( + new TestBean(1, "name1", 13, false, new Timestamp(1000)), + new TestBean(2, "name2", 10, false, new Timestamp(2000)), + new TestBean(3, "name2", 10, false, new Timestamp(3000)), + new TestBean(4, "name2", 25, true, new Timestamp(2000)) + ); + TEST_DISTINCT_BY_PARAMS = ImmutableList.of( + new TestParam(beans, "name", "1, 2", "4, 1"), + new TestParam(beans, "age", "1, 2, 4", "4, 3, 1"), + new TestParam(beans, "adult", "1, 4", "4, 3"), + new TestParam(beans, "birthday", "1, 2, 3", "4, 3, 1")) + ; + } + + @Override + protected Configuration createConfiguration() throws Exception { + Configuration configuration = super.createConfiguration(); + configuration.setDateTimeFormat("YYYY-MM-dd HH:mm:ss"); + configuration.setBooleanFormat(BOOLEAN_FORMAT_LEGACY_DEFAULT); + return configuration; + } + + @Test + public void testDistinct() throws TemplateException, IOException { + for (TestParam testParam : TEST_DISTINCT_PARAMS) { + addToDataModel("xs", testParam.list); + assertOutput( + "<#list xs?distinct as x>${x}<#sep>, ", + testParam.result); + assertOutput( + "<#assign fxs = xs?distinct>" + + "${fxs?join(', ')}", + testParam.result); + } + } + + @Test + public void testDistinctBy() throws TemplateException, IOException { + for (TestParam testParam : TEST_DISTINCT_BY_PARAMS) { + addToDataModel("xs", testParam.list); + assertOutput( + "<#list xs?distinct_by('" + testParam.field + "') as x>${x.id}<#sep>, ", + testParam.result); + assertOutput( + "<#assign fxs = xs?distinct_by('" + testParam.field + "')>" + + "${fxs?map(i -> i.id)?join(', ')}", + testParam.result); + + } + } + + @Test + public void testDistinctByReverse() throws TemplateException, IOException { + for (TestParam testParam : TEST_DISTINCT_BY_PARAMS) { + addToDataModel("xs", testParam.list); + assertOutput( + "<#list xs?reverse?distinct_by('" + testParam.field + "') as x>${x.id}<#sep>, ", + testParam.reverseResult); + assertOutput( + "<#assign fxs = xs?reverse?distinct_by('" + testParam.field + "')>" + + "${fxs?map(i -> i.id)?join(', ')}", + testParam.reverseResult); + + } + } + + private static class TestParam { + private final List list; + + private String field; + + private final String result; + + private String reverseResult; + + public TestParam(List list, String result) { + this.list = list; + this.result = result; + } + + public TestParam(List list, String field, String result, String reverseResult) { + this.list = list; + this.field = field; + this.result = result; + this.reverseResult = reverseResult; + } + } + + public static class TestBean { + + private final int id; + + private final String name; + + private final int age; + + private final boolean adult; + + private final Timestamp birthday; + + public TestBean(int id, String name, int age, boolean adult, Timestamp birthday) { + this.id = id; + this.name = name; + this.age = age; + this.adult = adult; + this.birthday = birthday; + } + + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public int getAge() { + return age; + } + + public boolean isAdult() { + return adult; + } + + public Timestamp getBirthday() { + return birthday; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof TestBean + && name.equals(((TestBean) obj).name) + && age == ((TestBean) obj).age + && adult == ((TestBean) obj).adult + && birthday.equals(((TestBean) obj).birthday); + } + + @Override + public String toString() { + return id + ""; + } + } + +} diff --git a/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/expected/sequence-builtins.txt b/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/expected/sequence-builtins.txt index 4f7796ade..b6f905d84 100644 --- a/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/expected/sequence-builtins.txt +++ b/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/expected/sequence-builtins.txt @@ -401,4 +401,100 @@ Misc ---- First of set 1: a -First of set 2: a \ No newline at end of file +First of set 2: a + +distinct scalars: +---------------- + +String distinct: +- apple +- banana +- orange + +First: apple +Last: orange +Size 3 + +Numerical distinct: +- 123 +- 5 +- -324 +- -34 +- 0.11 +- 0 +- 111 +- 0.11 +- 123 + +First: 123 +Last: 123 +Size 9 + +Date/time distinct: +- 08:05 +- 18:00 +- 06:05 + +Boolean distinct: +- true +- false + + +Distinct hashes: +--------------- + +Distinct by name: +- 1 +- 2 + +Distinct by name reverse: +- 4 +- 1 + +Distinct by age: +- 1 +- 2 +- 4 + +Distinct by age reverse: +- 4 +- 3 +- 1 + +Distinct by adult: +- 1 +- 4 + +Distinct by adult reverse: +- 4 +- 3 + +Distinct by birthday: +- 1 +- 2 +- 3 + +Distinct by birthday reverse: +- 4 +- 3 +- 1 + +Distinct by a.x.v: +- 1 +- 2 +- 4 + +Distinct by a.x.v reverse: +- 4 +- 3 +- 1 + +Distinct by a.y, which is a date: +- 1 +- 3 +- 4 + +Distinct by a.y reverse, which is a date: +- 4 +- 3 +- 2 \ No newline at end of file diff --git a/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/templates/sequence-builtins.ftl b/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/templates/sequence-builtins.ftl index 7b7650134..ac6e4f852 100644 --- a/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/templates/sequence-builtins.ftl +++ b/freemarker-jython25/src/test/resources/freemarker/test/templatesuite/templates/sequence-builtins.ftl @@ -358,4 +358,122 @@ Misc ---- First of set 1: ${abcSet?first} -First of set 2: ${abcSetNonSeq?first} \ No newline at end of file +First of set 2: ${abcSetNonSeq?first} + +distinct scalars: +---------------- + +String distinct: +<#assign ls = ["apple", "apple", "banana", "apple", "orange"]?distinct> +<#list ls as i> +- ${i} + + +First: ${ls?first} +Last: ${ls?last} +Size ${ls?size} + +Numerical distinct: +<#assign ls = [123?byte, 5, -324, -34?float, 0.11, 0, 111?int, 0.11?double, 123, 5]?distinct> +<#list ls as i> +- ${i} + + +First: ${ls?first} +Last: ${ls?last} +Size ${ls?size} + +Date/time distinct: +<#assign x = [ +'08:05'?time('HH:mm'), +'18:00'?time('HH:mm'), +'06:05'?time('HH:mm'), +'08:05'?time('HH:mm')]> +<#list x?distinct as i> +- ${i?string('HH:mm')} + + +Boolean distinct: +<#assign x = [ +true, +false, +false, +true]> +<#list x?distinct as i> +- ${i} + + + +Distinct hashes: +--------------- + +<#assign ls = [ +{"id": 1, "name": "name1", "age": 13, "adult": false, "birthday": '2024-01-01'?date('yyyy-MM-dd')}, +{"id": 2, "name": "name2", "age": 10, "adult": false, "birthday": '2024-02-01'?date('yyyy-MM-dd')}, +{"id": 3, "name": "name2", "age": 10, "adult": false, "birthday": '2024-03-01'?date('yyyy-MM-dd')}, +{"id": 4, "name": "name2", "age": 25, "adult": true, "birthday": '2024-02-01'?date('yyyy-MM-dd')} +]> +Distinct by name: +<#list ls?distinct_by("name") as i> +- ${i.id} + + +Distinct by name reverse: +<#list ls?reverse?distinct_by("name") as i> +- ${i.id} + + +Distinct by age: +<#list ls?distinct_by("age") as i> +- ${i.id} + + +Distinct by age reverse: +<#list ls?reverse?distinct_by("age") as i> +- ${i.id} + + +Distinct by adult: +<#list ls?distinct_by("adult") as i> +- ${i.id} + + +Distinct by adult reverse: +<#list ls?reverse?distinct_by("adult") as i> +- ${i.id} + + +Distinct by birthday: +<#list ls?distinct_by("birthday") as i> +- ${i.id} + + +Distinct by birthday reverse: +<#list ls?reverse?distinct_by("birthday") as i> +- ${i.id} + + +<#assign x = [ +{"id": "1", "a": {"x": {"v": "xxxx", "w": "asd"}, "y": '1998-02-20'?date('yyyy-MM-dd')}}, +{"id": "2", "a": {"x": {"v": "xx", "w": "asd"}, "y": '1998-02-20'?date('yyyy-MM-dd')}}, +{"id": "3", "a": {"x": {"v": "xx", "w": "asd"}, "y": '1999-04-20'?date('yyyy-MM-dd')}}, +{"id": "4", "a": {"x": {"v": "xxx", "w": "asd"}, "y": '1999-04-19'?date('yyyy-MM-dd')}}]> +Distinct by a.x.v: +<#list x?distinct_by(['a', 'x', 'v']) as i> +- ${i.id} + + +Distinct by a.x.v reverse: +<#list x?reverse?distinct_by(['a', 'x', 'v']) as i> +- ${i.id} + + +Distinct by a.y, which is a date: +<#list x?distinct_by(['a', 'y']) as i> +- ${i.id} + + +Distinct by a.y reverse, which is a date: +<#list x?reverse?distinct_by(['a', 'y']) as i> +- ${i.id} + \ No newline at end of file diff --git a/freemarker-manual/src/main/docgen/en_US/book.xml b/freemarker-manual/src/main/docgen/en_US/book.xml index 46936674e..80c030571 100644 --- a/freemarker-manual/src/main/docgen/en_US/book.xml +++ b/freemarker-manual/src/main/docgen/en_US/book.xml @@ -12941,6 +12941,14 @@ grant codeBase "file:/path/to/freemarker.jar" linkend="ref_builtin_date_if_unknown">datetime_if_unknown + + distinct + + + + distinct_by + + double @@ -18968,6 +18976,123 @@ Filer for positives: linkend="ref_builtin_drop_while">drop_while built-in + +
+ distinct + + + distinct built-in + + + + sequence + + distinct + + + Returns the sequence consisting of the distinct elements, + retaining the first accessed element. (For + retaining the last accessed element use + reverse built + in and then use this.) This will work only if all sub variables are strings, + or if all sub variables are numbers, or if all sub variables are date + values (date, time, or date+time), or if all sub variables are + booleans. If the sub variables are numbers, it uses + equals for distinct (123?float and 123?byte + are not the same). For + example: + + <#assign ls = [1, 2?byte, 1, 2?float, 10]?distinct> +<#list ls as i>${i} </#list> + + will print: + + 1 2 2 10 +
+ +
+ distinct_by + + + distinct_by built-in + + + + sequence + + distinct_by + + + Returns the sequence consisting of the distinct elements by the given hash + subvariable, retaining the first accessed element. (For + retaining the last accessed element use + reverse built + in and then use this.) The rules are the same as the + distinct built in, + except that the sub + variables of the sequence must be hashes, and you have to + give the name of a hash subvariable that will decide the order. For example: + + <#assign ls = [ +{"id": 1, "name": "name1", "age": 13, "adult": false, "birthday": '2024-01-01'?date('yyyy-MM-dd')}, +{"id": 2, "name": "name2", "age": 10, "adult": false, "birthday": '2024-02-01'?date('yyyy-MM-dd')}, +{"id": 3, "name": "name2", "age": 10, "adult": false, "birthday": '2024-03-01'?date('yyyy-MM-dd')}, +{"id": 4, "name": "name2", "age": 25, "adult": true, "birthday": '2024-02-01'?date('yyyy-MM-dd')} +]> +Distinct by name: +<#list ls?distinct_by("name") as i> +- ${i.id} +</#list> + +Distinct by name reverse: +<#list ls?reverse?distinct_by("name") as i> +- ${i.id} +</#list> + + will print: + + Distinct by name: +- 1 +- 2 + +Distinct by name reverse: +- 4 +- 1 + + If the subvariable that you want to use for the distinct is on a deeper level (that + is, if it is a + subvariable of a subvariable and so on), then you can use a sequence as parameter, that + specifies the + names of the sub variables that lead down to the desired subvariable. For example: + + <#assign x = [ + {"id": "1", "a": {"x": {"v": "xxxx", "w": "asd"}, "y": '1998-02-20'?date('yyyy-MM-dd')}}, + {"id": "2", "a": {"x": {"v": "xx", "w": "asd"}, "y": '1998-02-20'?date('yyyy-MM-dd')}}, + {"id": "3", "a": {"x": {"v": "xx", "w": "asd"}, "y": '1999-04-20'?date('yyyy-MM-dd')}}, + {"id": "4", "a": {"x": {"v": "xxx", "w": "asd"}, "y": '1999-04-19'?date('yyyy-MM-dd')}} +]> +Distinct by a.x.v: +<#list x?distinct_by(['a', 'x', 'v']) as i> +- ${i.id} +</#list> + +Distinct by a.x.v reverse: +<#list x?reverse?distinct_by(['a', 'x', 'v']) as i> +- ${i.id} +</#list> + + will print: + + Distinct by a.x.v: +- 1 +- 2 +- 4 + +Distinct by a.x.v reverse: +- 4 +- 3 +- 1 +