Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add distinct, distinct_by built-ins for sequence #113

Open
wants to merge 1 commit into
base: 2.3-gae
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion freemarker-core/src/main/java/freemarker/core/BuiltIn.java
Original file line number Diff line number Diff line change
@@ -85,7 +85,7 @@ abstract class BuiltIn extends Expression implements Cloneable {

static final Set<String> CAMEL_CASE_NAMES = new TreeSet<>();
static final Set<String> SNAKE_CASE_NAMES = new TreeSet<>();
static final int NUMBER_OF_BIS = 302;
static final int NUMBER_OF_BIS = 305;
static final HashMap<String, BuiltIn> 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());
305 changes: 202 additions & 103 deletions freemarker-core/src/main/java/freemarker/core/BuiltInsForSequences.java
Original file line number Diff line number Diff line change
@@ -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,14 +572,25 @@ 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;
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 <code>null</code>.
* @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 <code>null</code>.
* @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 {
196 changes: 196 additions & 0 deletions freemarker-core/src/test/java/freemarker/core/DistinctBiTest.java
Original file line number Diff line number Diff line change
@@ -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<TestParam> 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<TestParam> TEST_DISTINCT_BY_PARAMS;

static {
ImmutableList<TestBean> 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>, </#list>",
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>, </#list>",
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>, </#list>",
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 + "";
}
}

}
Original file line number Diff line number Diff line change
@@ -401,4 +401,100 @@ Misc
----

First of set 1: a
First of set 2: a
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
Original file line number Diff line number Diff line change
@@ -358,4 +358,122 @@ Misc
----

First of set 1: ${abcSet?first}
First of set 2: ${abcSetNonSeq?first}
First of set 2: ${abcSetNonSeq?first}

distinct scalars:
----------------

String distinct:
<#assign ls = ["apple", "apple", "banana", "apple", "orange"]?distinct>
<#list ls as i>
- ${i}
</#list>

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}
</#list>

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')}
</#list>

Boolean distinct:
<#assign x = [
true,
false,
false,
true]>
<#list x?distinct as i>
- ${i}
</#list>


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}
</#list>

Distinct by name reverse:
<#list ls?reverse?distinct_by("name") as i>
- ${i.id}
</#list>

Distinct by age:
<#list ls?distinct_by("age") as i>
- ${i.id}
</#list>

Distinct by age reverse:
<#list ls?reverse?distinct_by("age") as i>
- ${i.id}
</#list>

Distinct by adult:
<#list ls?distinct_by("adult") as i>
- ${i.id}
</#list>

Distinct by adult reverse:
<#list ls?reverse?distinct_by("adult") as i>
- ${i.id}
</#list>

Distinct by birthday:
<#list ls?distinct_by("birthday") as i>
- ${i.id}
</#list>

Distinct by birthday reverse:
<#list ls?reverse?distinct_by("birthday") as i>
- ${i.id}
</#list>

<#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>

Distinct by a.y, which is a date:
<#list x?distinct_by(['a', 'y']) as i>
- ${i.id}
</#list>

Distinct by a.y reverse, which is a date:
<#list x?reverse?distinct_by(['a', 'y']) as i>
- ${i.id}
</#list>
125 changes: 125 additions & 0 deletions freemarker-manual/src/main/docgen/en_US/book.xml
Original file line number Diff line number Diff line change
@@ -12941,6 +12941,14 @@ grant codeBase "file:/path/to/freemarker.jar"
linkend="ref_builtin_date_if_unknown">datetime_if_unknown</link></para>
</listitem>

<listitem>
<para><link linkend="ref_builtin_distinct">distinct</link></para>
</listitem>

<listitem>
<para><link linkend="ref_builtin_distinct_by">distinct_by</link></para>
</listitem>

<listitem>
<para><link linkend="ref_builtin_numType">double</link></para>
</listitem>
@@ -18968,6 +18976,123 @@ Filer for positives:
linkend="ref_builtin_drop_while"><literal>drop_while</literal>
built-in</link></para>
</section>

<section xml:id="ref_builtin_distinct">
<title>distinct</title>

<indexterm>
<primary>distinct built-in</primary>
</indexterm>

<indexterm>
<primary>sequence</primary>

<secondary>distinct</secondary>
</indexterm>

<para>Returns the sequence consisting of the distinct elements,
retaining the first accessed element. (For
retaining the last accessed element use <link linkend="ref_builtin_reverse">
<literal>reverse</literal> built
in</link> 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 (<literal>123?float</literal> and <literal>123?byte</literal>
are not the same). For
example:</para>

<programlisting role="template">&lt;#assign ls = [1, 2?byte, 1, 2?float, 10]?distinct&gt;
&lt;#list ls as i&gt;${i} &lt;/#list&gt;</programlisting>

<para>will print:</para>

<programlisting role="output">1 2 2 10</programlisting>
</section>

<section xml:id="ref_builtin_distinct_by">
<title>distinct_by</title>

<indexterm>
<primary>distinct_by built-in</primary>
</indexterm>

<indexterm>
<primary>sequence</primary>

<secondary>distinct_by</secondary>
</indexterm>

<para>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 <link linkend="ref_builtin_reverse"><literal>
reverse</literal> built
in</link> and then use this.) The rules are the same as the
<link linkend="ref_builtin_distinct"><literal>distinct</literal> built in</link>,
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:</para>

<programlisting role="template">&lt;#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')}
]&gt;
Distinct by name:
&lt;#list ls?distinct_by("name") as i&gt;
- ${i.id}
&lt;/#list&gt;

Distinct by name reverse:
&lt;#list ls?reverse?distinct_by("name") as i&gt;
- ${i.id}
&lt;/#list&gt;</programlisting>

<para>will print:</para>

<programlisting role="output">Distinct by name:
- 1
- 2

Distinct by name reverse:
- 4
- 1</programlisting>

<para>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:</para>

<programlisting role="template">&lt;#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')}}
]&gt;
Distinct by a.x.v:
&lt;#list x?distinct_by(['a', 'x', 'v']) as i&gt;
- ${i.id}
&lt;/#list&gt;

Distinct by a.x.v reverse:
&lt;#list x?reverse?distinct_by(['a', 'x', 'v']) as i&gt;
- ${i.id}
&lt;/#list&gt;</programlisting>

<para>will print:</para>

<programlisting role="output">Distinct by a.x.v:
- 1
- 2
- 4

Distinct by a.x.v reverse:
- 4
- 3
- 1</programlisting>
</section>
</section>

<section xml:id="ref_builtins_hash">