Repository: mitsuhiko/minijinja
Pull request #571 (2024-09-01 18:34:17)

Add attribute support to the unique filter
This brings it in line with Jinja2.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29ad596..09ad0e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,11 +2,15 @@
 
 All notable changes to MiniJinja are documented here.
 
-## 2.2.1
+## 2.3.0
 
 - Fixes incorrect ordering of maps when the keys of those maps
   were not in consistent order.  #569
 - Implemented the missing `groupby` filter.  #570
+- The `unique` filter now is case insensitive by default like in
+  Jinja2 and supports an optional flag to make it case sensitive.
+  It also now lets one check individual attributes instead of
+  values.  #571
 
 ## 2.2.0
 
diff --git a/minijinja/src/filters.rs b/minijinja/src/filters.rs
index 5d54114..eb206e8 100644
--- a/minijinja/src/filters.rs
+++ b/minijinja/src/filters.rs
@@ -1027,7 +1027,7 @@ mod builtins {
                 None => Some(ok!(usize::try_from(val.clone()))),
             },
         };
-        args.assert_all_used()?;
+        ok!(args.assert_all_used());
         if let Some(indent) = indent {
             let mut out = Vec::<u8>::new();
             let indentation = " ".repeat(indent);
@@ -1424,7 +1424,7 @@ mod builtins {
             let b = b.get_path_or_default(attr, &default);
             cmp_helper(&a, &b, case_sensitive)
         });
-        kwargs.assert_all_used()?;
+        ok!(kwargs.assert_all_used());
 
         #[derive(Debug)]
         pub struct GroupTuple {
@@ -1493,21 +1493,51 @@ mod builtins {
     /// The unique items are yielded in the same order as their first occurrence
     /// in the iterable passed to the filter.  The filter will not detect
     /// duplicate objects or arrays, only primitives such as strings or numbers.
+    ///
+    /// Optionally the `attribute` keyword argument can be used to make the filter
+    /// operate on an attribute instead of the value itself.  In this case only
+    /// one city per state would be returned:
+    ///
+    /// ```jinja
+    /// {{ list_of_cities|unique(attribute='state') }}
+    /// ```
+    ///
+    /// Like the [`sort`] filter this operates case-insensitive by default.
+    /// For example, if a list has the US state codes `["CA", "NY", "ca"]``,
+    /// the resulting list will have `["CA", "NY"]`.  This can be disabled by
+    /// passing `case_sensitive=True`.
     #[cfg_attr(docsrs, doc(cfg(feature = "builtins")))]
-    pub fn unique(values: Vec<Value>) -> Value {
+    pub fn unique(values: Vec<Value>, kwargs: Kwargs) -> Result<Value, Error> {
         use std::collections::BTreeSet;
 
+        let attr = ok!(kwargs.get::<Option<&str>>("attribute"));
+        let case_sensitive = ok!(kwargs.get::<Option<bool>>("case_sensitive")).unwrap_or(false);
+        ok!(kwargs.assert_all_used());
+
         let mut rv = Vec::new();
         let mut seen = BTreeSet::new();
 
         for item in values {
-            if !seen.contains(&item) {
-                rv.push(item.clone());
-                seen.insert(item);
+            let value_to_compare = if let Some(attr) = attr {
+                item.get_path_or_default(attr, &Value::UNDEFINED)
+            } else {
+                item.clone()
+            };
+            let memorized_value = if case_sensitive {
+                value_to_compare.clone()
+            } else if let Some(s) = value_to_compare.as_str() {
+                Value::from(s.to_lowercase())
+            } else {
+                value_to_compare.clone()
+            };
+
+            if !seen.contains(&memorized_value) {
+                rv.push(item);
+                seen.insert(memorized_value);
             }
         }
 
-        Value::from(rv)
+        Ok(Value::from(rv))
     }
 
     /// Pretty print a variable.


Test patch:
diff --git a/minijinja/tests/inputs/filters.txt b/minijinja/tests/inputs/filters.txt
index e705ca9..cd52cdc 100644
--- a/minijinja/tests/inputs/filters.txt
+++ b/minijinja/tests/inputs/filters.txt
@@ -101,6 +101,9 @@ map-attr-deep: {{ [dict(a=[1]), dict(a=[2]), dict(a=[])]|map(attribute='a.0', de
 map-attr-int: {{ [[1], [1, 2]]|map(attribute=1, default=999) }}
 attr-filter: {{ map|attr("a") }}
 unique-filter: {{ [1, 1, 1, 4, 3, 0, 0, 5]|unique }}
+unique-filter-ci: {{ ["a", "A", "b", "c", "b", "D", "d"]|unique }}
+unique-filter-cs: {{ ["a", "A", "b", "c", "b", "D", "d"]|unique(case_sensitive=true) }}
+unique-attr-filter: {{ [{'x': 1}, {'x': 1, 'y': 2}, {'x': 2}]|unique }}
 pprint-filter: {{ objects|pprint }}
 int-filter: {{ true|int }}, {{ "42"|int }}, {{ "-23"|int }}, {{ 42.0|int }}, {{ 42.42|int }}, {{ "42.42"|int }}
 float-filter: {{ true|float }}, {{ "42"|float }}, {{ "-23.5"|float }}, {{ 42.5|float }}
diff --git a/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap b/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap
index 240887a..14a6166 100644
--- a/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap
+++ b/minijinja/tests/snapshots/test_templates__vm@filters.txt.snap
@@ -1,6 +1,6 @@
 ---
 source: minijinja/tests/test_templates.rs
-description: "lower: {{ word|lower }}\nupper: {{ word|upper }}\ntitle: {{ word|title }}\ntitle-sentence: {{ \"the bIrd, is The:word\"|title }}\ntitle-three-words: {{ three_words|title }}\ncapitalize: {{ word|capitalize }}\ncapitalize-three-words: {{ three_words|capitalize }}\nreplace: {{ word|replace(\"B\", \"th\") }}\nescape: {{ \"<\"|escape }}\ne: {{ \"<\"|e }}\ndouble-escape: {{ \"<\"|escape|escape }}\nsafe: {{ \"<\"|safe|escape }}\nlist-length: {{ list|length }}\nlist-from-list: {{ list|list }}\nlist-from-map: {{ map|list }}\nlist-from-word: {{ word|list }}\nlist-from-undefined: {{ undefined|list }}\nbool-empty-string: {{ \"\"|bool }}\nbool-non-empty-string: {{ \"hello\"|bool }}\nbool-empty-list: {{ []|bool }}\nbool-non-empty-list: {{ [42]|bool }}\nbool-undefined: {{ undefined|bool }}\nmap-length: {{ map|length }}\nstring-length: {{ word|length }}\nstring-count: {{ word|count }}\nreverse-list: {{ list|reverse }}\nreverse-string: {{ word|reverse }}\ntrim: |{{ word_with_spaces|trim }}|\ntrim-bird: {{ word|trim(\"Bd\") }}\njoin-default: {{ list|join }}\njoin-pipe: {{ list|join(\"|\") }}\njoin_string: {{ word|join('-') }}\ndefault: {{ undefined|default == \"\" }}\ndefault-value: {{ undefined|default(42) }}\nfirst-list: {{ list|first }}\nfirst-word: {{ word|first }}\nfirst-undefined: {{ []|first is undefined }}\nlast-list: {{ list|last }}\nlast-word: {{ word|last }}\nlast-undefined: {{ []|first is undefined }}\nmin: {{ other_list|min }}\nmax: {{ other_list|max }}\nsort: {{ other_list|sort }}\nsort-reverse: {{ other_list|sort(reverse=true) }}\nsort-case-insensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort }}\nsort-case-sensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort(case_sensitive=true) }}\nsort-case-insensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort }}\nsort-case-sensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort(case_sensitive=true) }}\nsort-attribute {{ objects|sort(attribute=\"name\") }}\nd: {{ undefined|d == \"\" }}\njson: {{ map|tojson }}\njson-pretty: {{ map|tojson(true) }}\njson-scary-html: {{ scary_html|tojson }}\nurlencode: {{ \"hello world/foo-bar_baz.txt\"|urlencode }}\nurlencode-kv: {{ dict(a=\"x y\", b=2, c=3, d=None)|urlencode }}\nbatch: {{ range(10)|batch(3) }}\nbatch-fill: {{ range(10)|batch(3, '-') }}\nslice: {{ range(10)|slice(3) }}\nslice-fill: {{ range(10)|slice(3, '-') }}\nitems: {{ dict(a=1)|items }}\nindent: {{ \"foo\\nbar\\nbaz\"|indent(2)|tojson }}\nindent-first-line: {{ \"foo\\nbar\\nbaz\"|indent(2, true)|tojson }}\nint-abs: {{ -42|abs }}\nfloat-abs: {{ -42.5|abs }}\nint-round: {{ 42|round }}\nfloat-round: {{ 42.5|round }}\nfloat-round-prec2: {{ 42.512345|round(2) }}\nselect-odd: {{ [1, 2, 3, 4, 5, 6]|select(\"odd\") }}\nselect-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|select }}\nreject-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|reject }}\nreject-odd: {{ [1, 2, 3, 4, 5, 6]|reject(\"odd\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"active\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"active\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"key\", \"even\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"key\", \"even\") }}\nmap-maps: {{ [-1, -2, 3, 4, -5]|map(\"abs\") }}\nmap-attr: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=None) }}\nmap-attr-undefined: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=definitely_undefined) }}\nmap-attr-deep: {{ [dict(a=[1]), dict(a=[2]), dict(a=[])]|map(attribute='a.0', default=None) }}\nmap-attr-int: {{ [[1], [1, 2]]|map(attribute=1, default=999) }}\nattr-filter: {{ map|attr(\"a\") }}\nunique-filter: {{ [1, 1, 1, 4, 3, 0, 0, 5]|unique }}\npprint-filter: {{ objects|pprint }}\nint-filter: {{ true|int }}, {{ \"42\"|int }}, {{ \"-23\"|int }}, {{ 42.0|int }}, {{ 42.42|int }}, {{ \"42.42\"|int }}\nfloat-filter: {{ true|float }}, {{ \"42\"|float }}, {{ \"-23.5\"|float }}, {{ 42.5|float }}\nsplit: {{ three_words|split|list }}\nsplit-at-and: {{ three_words|split(\" and \")|list }}\nsplit-n-ws: {{ three_words|split(none, 1)|list }}\nsplit-n-d: {{ three_words|split(\"d\", 1)|list }}\nsplit-n-ws-filter-empty: {{ \"  foo    bar baz  \"|split(none, 1)|list }}"
+description: "lower: {{ word|lower }}\nupper: {{ word|upper }}\ntitle: {{ word|title }}\ntitle-sentence: {{ \"the bIrd, is The:word\"|title }}\ntitle-three-words: {{ three_words|title }}\ncapitalize: {{ word|capitalize }}\ncapitalize-three-words: {{ three_words|capitalize }}\nreplace: {{ word|replace(\"B\", \"th\") }}\nescape: {{ \"<\"|escape }}\ne: {{ \"<\"|e }}\ndouble-escape: {{ \"<\"|escape|escape }}\nsafe: {{ \"<\"|safe|escape }}\nlist-length: {{ list|length }}\nlist-from-list: {{ list|list }}\nlist-from-map: {{ map|list }}\nlist-from-word: {{ word|list }}\nlist-from-undefined: {{ undefined|list }}\nbool-empty-string: {{ \"\"|bool }}\nbool-non-empty-string: {{ \"hello\"|bool }}\nbool-empty-list: {{ []|bool }}\nbool-non-empty-list: {{ [42]|bool }}\nbool-undefined: {{ undefined|bool }}\nmap-length: {{ map|length }}\nstring-length: {{ word|length }}\nstring-count: {{ word|count }}\nreverse-list: {{ list|reverse }}\nreverse-string: {{ word|reverse }}\ntrim: |{{ word_with_spaces|trim }}|\ntrim-bird: {{ word|trim(\"Bd\") }}\njoin-default: {{ list|join }}\njoin-pipe: {{ list|join(\"|\") }}\njoin_string: {{ word|join('-') }}\ndefault: {{ undefined|default == \"\" }}\ndefault-value: {{ undefined|default(42) }}\nfirst-list: {{ list|first }}\nfirst-word: {{ word|first }}\nfirst-undefined: {{ []|first is undefined }}\nlast-list: {{ list|last }}\nlast-word: {{ word|last }}\nlast-undefined: {{ []|first is undefined }}\nmin: {{ other_list|min }}\nmax: {{ other_list|max }}\nsort: {{ other_list|sort }}\nsort-reverse: {{ other_list|sort(reverse=true) }}\nsort-case-insensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort }}\nsort-case-sensitive: {{ [\"B\", \"a\", \"C\", \"z\"]|sort(case_sensitive=true) }}\nsort-case-insensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort }}\nsort-case-sensitive-mixed: {{ [0, 1, \"true\", \"false\", \"True\", \"False\", true, false]|sort(case_sensitive=true) }}\nsort-attribute {{ objects|sort(attribute=\"name\") }}\nd: {{ undefined|d == \"\" }}\njson: {{ map|tojson }}\njson-pretty: {{ map|tojson(true) }}\njson-scary-html: {{ scary_html|tojson }}\nurlencode: {{ \"hello world/foo-bar_baz.txt\"|urlencode }}\nurlencode-kv: {{ dict(a=\"x y\", b=2, c=3, d=None)|urlencode }}\nbatch: {{ range(10)|batch(3) }}\nbatch-fill: {{ range(10)|batch(3, '-') }}\nslice: {{ range(10)|slice(3) }}\nslice-fill: {{ range(10)|slice(3, '-') }}\nitems: {{ dict(a=1)|items }}\nindent: {{ \"foo\\nbar\\nbaz\"|indent(2)|tojson }}\nindent-first-line: {{ \"foo\\nbar\\nbaz\"|indent(2, true)|tojson }}\nint-abs: {{ -42|abs }}\nfloat-abs: {{ -42.5|abs }}\nint-round: {{ 42|round }}\nfloat-round: {{ 42.5|round }}\nfloat-round-prec2: {{ 42.512345|round(2) }}\nselect-odd: {{ [1, 2, 3, 4, 5, 6]|select(\"odd\") }}\nselect-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|select }}\nreject-truthy: {{ [undefined, null, 0, 42, 23, \"\", \"aha\"]|reject }}\nreject-odd: {{ [1, 2, 3, 4, 5, 6]|reject(\"odd\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"active\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"active\") }}\nselect-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|selectattr(\"key\", \"even\") }}\nreject-attr: {{ [dict(active=true, key=1), dict(active=false, key=2)]|rejectattr(\"key\", \"even\") }}\nmap-maps: {{ [-1, -2, 3, 4, -5]|map(\"abs\") }}\nmap-attr: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=None) }}\nmap-attr-undefined: {{ [dict(a=1), dict(a=2), {}]|map(attribute='a', default=definitely_undefined) }}\nmap-attr-deep: {{ [dict(a=[1]), dict(a=[2]), dict(a=[])]|map(attribute='a.0', default=None) }}\nmap-attr-int: {{ [[1], [1, 2]]|map(attribute=1, default=999) }}\nattr-filter: {{ map|attr(\"a\") }}\nunique-filter: {{ [1, 1, 1, 4, 3, 0, 0, 5]|unique }}\nunique-filter-ci: {{ [\"a\", \"A\", \"b\", \"c\", \"b\", \"D\", \"d\"]|unique }}\nunique-filter-cs: {{ [\"a\", \"A\", \"b\", \"c\", \"b\", \"D\", \"d\"]|unique(case_sensitive=true) }}\nunique-attr-filter: {{ [{'x': 1}, {'x': 1, 'y': 2}, {'x': 2}]|unique }}\npprint-filter: {{ objects|pprint }}\nint-filter: {{ true|int }}, {{ \"42\"|int }}, {{ \"-23\"|int }}, {{ 42.0|int }}, {{ 42.42|int }}, {{ \"42.42\"|int }}\nfloat-filter: {{ true|float }}, {{ \"42\"|float }}, {{ \"-23.5\"|float }}, {{ 42.5|float }}\nsplit: {{ three_words|split|list }}\nsplit-at-and: {{ three_words|split(\" and \")|list }}\nsplit-n-ws: {{ three_words|split(none, 1)|list }}\nsplit-n-d: {{ three_words|split(\"d\", 1)|list }}\nsplit-n-ws-filter-empty: {{ \"  foo    bar baz  \"|split(none, 1)|list }}"
 info:
   word: Bird
   word_with_spaces: " Spacebird\n"
@@ -109,6 +109,9 @@ map-attr-deep: [1, 2, none]
 map-attr-int: [999, 2]
 attr-filter: b
 unique-filter: [1, 4, 3, 0, 5]
+unique-filter-ci: ["a", "b", "c", "D"]
+unique-filter-cs: ["a", "A", "b", "c", "D", "d"]
+unique-attr-filter: [{"x": 1}, {"x": 1, "y": 2}, {"x": 2}]
 pprint-filter: [
     {
         "name": "b",