diff --git a/CHANGELOG.md b/CHANGELOG.md index 29ad5961..09ad0e8b 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 5d541141..eb206e8c 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::::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 { + pub fn unique(values: Vec, kwargs: Kwargs) -> Result { use std::collections::BTreeSet; + let attr = ok!(kwargs.get::>("attribute")); + let case_sensitive = ok!(kwargs.get::>("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. diff --git a/minijinja/tests/inputs/filters.txt b/minijinja/tests/inputs/filters.txt index e705ca95..cd52cdca 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 240887ae..14a61668 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",