From f4e222f3967a05d57edc1d1e2f1777bcb17c7c62 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 1 Sep 2024 16:30:55 +0200 Subject: [PATCH 1/2] Implemented the groupby filter --- minijinja/src/defaults.rs | 1 + minijinja/src/filters.rs | 123 +++++++++++++++++- minijinja/src/value/mod.rs | 9 ++ minijinja/tests/inputs/groupby-filter.txt | 25 ++++ .../test_templates__vm@debug.txt.snap | 1 + ...test_templates__vm@groupby-filter.txt.snap | 36 +++++ 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 minijinja/tests/inputs/groupby-filter.txt create mode 100644 minijinja/tests/snapshots/test_templates__vm@groupby-filter.txt.snap diff --git a/minijinja/src/defaults.rs b/minijinja/src/defaults.rs index ae5a54ad..c4fe40c2 100644 --- a/minijinja/src/defaults.rs +++ b/minijinja/src/defaults.rs @@ -95,6 +95,7 @@ pub(crate) fn get_builtin_filters() -> BTreeMap, filters::Boxe rv.insert("selectattr".into(), BoxedFilter::new(filters::selectattr)); rv.insert("rejectattr".into(), BoxedFilter::new(filters::rejectattr)); rv.insert("map".into(), BoxedFilter::new(filters::map)); + rv.insert("groupby".into(), BoxedFilter::new(filters::groupby)); rv.insert("unique".into(), BoxedFilter::new(filters::unique)); rv.insert("pprint".into(), BoxedFilter::new(filters::pprint)); diff --git a/minijinja/src/filters.rs b/minijinja/src/filters.rs index aef99dab..160c76aa 100644 --- a/minijinja/src/filters.rs +++ b/minijinja/src/filters.rs @@ -295,7 +295,7 @@ mod builtins { use crate::error::ErrorKind; use crate::utils::splitn_whitespace; use crate::value::ops::as_f64; - use crate::value::{Kwargs, ValueKind, ValueRepr}; + use crate::value::{Enumerator, Kwargs, Object, ObjectRepr, ValueKind, ValueRepr}; use std::borrow::Cow; use std::cmp::Ordering; use std::fmt::Write; @@ -1362,6 +1362,127 @@ mod builtins { Ok(rv) } + /// Group a sequence of objects by an attribute. + /// + /// The attribute can use dot notation for nested access, like `"address.city"``. + /// The values are sorted first so only one group is returned for each unique value. + /// The attribute can be passed as first argument or as keyword argument named + /// `atttribute`. + /// + /// For example, a list of User objects with a city attribute can be + /// rendered in groups. In this example, grouper refers to the city value of + /// the group. + /// + /// ```jinja + /// + /// ``` + /// + /// groupby yields named tuples of `(grouper, list)``, which can be used instead + /// of the tuple unpacking above. As such this example is equivalent: + /// + /// ```jinja + /// + /// ``` + /// + /// You can specify a default value to use if an object in the list does not + /// have the given attribute. + /// + /// ```jinja + /// + /// ``` + /// + /// Like the [`sort`] filter, sorting and grouping is case-insensitive by default. + /// The key for each group will have the case of the first item in that group + /// of values. For example, if a list of users has cities `["CA", "NY", "ca"]``, + /// the "CA" group will have two values. This can be disabled by passing + /// `case_sensitive=True`. + #[cfg_attr(docsrs, doc(cfg(feature = "builtins")))] + pub fn groupby(value: Value, attribute: Option<&str>, kwargs: Kwargs) -> Result { + let default = ok!(kwargs.get::>("default")).unwrap_or_default(); + let case_sensitive = ok!(kwargs.get::>("case_sensitive")).unwrap_or(false); + let attr = match attribute { + Some(attr) => attr, + None => ok!(kwargs.get::<&str>("attribute")), + }; + let mut items: Vec = ok!(value.try_iter()).collect(); + items.sort_by(|a, b| { + let a = a.get_path_or_default(attr, &default); + let b = b.get_path_or_default(attr, &default); + sort_helper(&a, &b, case_sensitive) + }); + kwargs.assert_all_used()?; + + #[derive(Debug)] + pub struct GroupTuple { + grouper: Value, + list: Vec, + } + + impl Object for GroupTuple { + fn repr(self: &Arc) -> ObjectRepr { + ObjectRepr::Seq + } + + fn get_value(self: &Arc, key: &Value) -> Option { + match (key.as_usize(), key.as_str()) { + (Some(0), None) | (None, Some("grouper")) => Some(self.grouper.clone()), + (Some(1), None) | (None, Some("list")) => { + Some(Value::make_object_iterable(self.clone(), |this| { + Box::new(this.list.iter().cloned()) + as Box + Send + Sync> + })) + } + _ => None, + } + } + + fn enumerate(self: &Arc) -> Enumerator { + Enumerator::Seq(2) + } + } + + let mut rv = Vec::new(); + let mut grouper = None::; + let mut list = Vec::new(); + + for item in items { + let group_by = item.get_path_or_default(attr, &default); + if let Some(ref last_grouper) = grouper { + if sort_helper(last_grouper, &group_by, case_sensitive) != Ordering::Equal { + rv.push(Value::from_object(GroupTuple { + grouper: last_grouper.clone(), + list: std::mem::take(&mut list), + })); + } + } + grouper = Some(group_by); + list.push(item); + } + + if !list.is_empty() { + rv.push(Value::from_object(GroupTuple { + grouper: grouper.unwrap(), + list, + })); + } + + Ok(Value::from_object(rv)) + } + /// Returns a list of unique items from the given iterable. /// /// ```jinja diff --git a/minijinja/src/value/mod.rs b/minijinja/src/value/mod.rs index 719e22d8..31ab4b0d 100644 --- a/minijinja/src/value/mod.rs +++ b/minijinja/src/value/mod.rs @@ -1460,6 +1460,15 @@ impl Value { } Ok(rv) } + + #[cfg(feature = "builtins")] + pub(crate) fn get_path_or_default(&self, path: &str, default: &Value) -> Value { + match self.get_path(path) { + Err(_) => default.clone(), + Ok(val) if val.is_undefined() => default.clone(), + Ok(val) => val, + } + } } impl Serialize for Value { diff --git a/minijinja/tests/inputs/groupby-filter.txt b/minijinja/tests/inputs/groupby-filter.txt new file mode 100644 index 00000000..31e91266 --- /dev/null +++ b/minijinja/tests/inputs/groupby-filter.txt @@ -0,0 +1,25 @@ +{ + "posts": [ + {"city": "Vienna", "text": "First post in Vienna"}, + {"city": "London", "text": "First post in London"}, + {"city": "Vienna", "text": "Second post in Vienna"}, + {"city": "vienna", "text": "First post in lowercase Vienna"}, + {"text": "no city!?"} + ] +} +--- +{%- for city, posts in posts|groupby("city", default="No City") %} + - {{ city }}: + {%- for post in posts %} + - {{ post.text }} + {%- endfor %} +{%- endfor %} +-- +{%- for group in posts|groupby(attribute="city", case_sensitive=true) %} + - {{ group.grouper }}: + {%- for post in group.list %} + - {{ post.text }} + {%- endfor %} +{%- endfor %} +-- +{{ (posts|groupby("city", default="AAA"))[0] }} \ No newline at end of file diff --git a/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap b/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap index 62fa94ef..fbca3f1c 100644 --- a/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap +++ b/minijinja/tests/snapshots/test_templates__vm@debug.txt.snap @@ -86,6 +86,7 @@ State { "escape", "first", "float", + "groupby", "indent", "int", "items", diff --git a/minijinja/tests/snapshots/test_templates__vm@groupby-filter.txt.snap b/minijinja/tests/snapshots/test_templates__vm@groupby-filter.txt.snap new file mode 100644 index 00000000..6f81904b --- /dev/null +++ b/minijinja/tests/snapshots/test_templates__vm@groupby-filter.txt.snap @@ -0,0 +1,36 @@ +--- +source: minijinja/tests/test_templates.rs +description: "{%- for city, posts in posts|groupby(\"city\", default=\"No City\") %}\n - {{ city }}:\n {%- for post in posts %}\n - {{ post.text }}\n {%- endfor %}\n{%- endfor %}\n--\n{%- for group in posts|groupby(attribute=\"city\", case_sensitive=true) %}\n - {{ group.grouper }}:\n {%- for post in group.list %}\n - {{ post.text }}\n {%- endfor %}\n{%- endfor %}\n--\n{{ (posts|groupby(\"city\", default=\"AAA\"))[0] }}" +info: + posts: + - city: Vienna + text: First post in Vienna + - city: London + text: First post in London + - city: Vienna + text: Second post in Vienna + - city: vienna + text: First post in lowercase Vienna + - text: no city!? +input_file: minijinja/tests/inputs/groupby-filter.txt +--- + - London: + - First post in London + - No City: + - no city!? + - vienna: + - First post in Vienna + - Second post in Vienna + - First post in lowercase Vienna +-- + - : + - no city!? + - London: + - First post in London + - Vienna: + - First post in Vienna + - Second post in Vienna + - vienna: + - First post in lowercase Vienna +-- +["AAA", [{"text": "no city!?"}]] From 4a95882b01d46017adec5ff7e540b275abe8d2a0 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 1 Sep 2024 16:32:41 +0200 Subject: [PATCH 2/2] Added changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19979d05..29ad5961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to MiniJinja are documented here. - Fixes incorrect ordering of maps when the keys of those maps were not in consistent order. #569 +- Implemented the missing `groupby` filter. #570 ## 2.2.0