Repository: mitsuhiko/minijinja
Pull request #570 (2024-09-01 14:31:12)

Implemented the groupby filter
This implements the `groupby` filter that was missing from Jinja2.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19979d0..29ad596 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
 
diff --git a/minijinja/src/defaults.rs b/minijinja/src/defaults.rs
index ae5a54a..c4fe40c 100644
--- a/minijinja/src/defaults.rs
+++ b/minijinja/src/defaults.rs
@@ -95,6 +95,7 @@ pub(crate) fn get_builtin_filters() -> BTreeMap<Cow<'static, str>, 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 aef99da..160c76a 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
+    /// <ul>{% for city, items in users|groupby("city") %}
+    ///   <li>{{ city }}
+    ///   <ul>{% for user in items %}
+    ///     <li>{{ user.name }}
+    ///   {% endfor %}</ul>
+    /// </li>
+    /// {% endfor %}</ul>
+    /// ```
+    ///
+    /// groupby yields named tuples of `(grouper, list)``, which can be used instead
+    /// of the tuple unpacking above.  As such this example is equivalent:
+    ///
+    /// ```jinja
+    /// <ul>{% for group in users|groupby(attribute="city") %}
+    ///   <li>{{ group.grouper }}
+    ///   <ul>{% for user in group.list %}
+    ///     <li>{{ user.name }}
+    ///   {% endfor %}</ul>
+    /// </li>
+    /// {% endfor %}</ul>
+    /// ```
+    ///
+    /// You can specify a default value to use if an object in the list does not
+    /// have the given attribute.
+    ///
+    /// ```jinja
+    /// <ul>{% for city, items in users|groupby("city", default="NY") %}
+    ///   <li>{{ city }}: {{ items|map(attribute="name")|join(", ") }}</li>
+    /// {% endfor %}</ul>
+    /// ```
+    ///
+    /// 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<Value, Error> {
+        let default = ok!(kwargs.get::<Option<Value>>("default")).unwrap_or_default();
+        let case_sensitive = ok!(kwargs.get::<Option<bool>>("case_sensitive")).unwrap_or(false);
+        let attr = match attribute {
+            Some(attr) => attr,
+            None => ok!(kwargs.get::<&str>("attribute")),
+        };
+        let mut items: Vec<Value> = 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<Value>,
+        }
+
+        impl Object for GroupTuple {
+            fn repr(self: &Arc<Self>) -> ObjectRepr {
+                ObjectRepr::Seq
+            }
+
+            fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
+                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<dyn Iterator<Item = _> + Send + Sync>
+                        }))
+                    }
+                    _ => None,
+                }
+            }
+
+            fn enumerate(self: &Arc<Self>) -> Enumerator {
+                Enumerator::Seq(2)
+            }
+        }
+
+        let mut rv = Vec::new();
+        let mut grouper = None::<Value>;
+        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 719e22d..31ab4b0 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 {


Test patch:
diff --git a/minijinja/tests/inputs/groupby-filter.txt b/minijinja/tests/inputs/groupby-filter.txt
new file mode 100644
index 0000000..31e9126
--- /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 62fa94e..fbca3f1 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 0000000..6f81904
--- /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!?"}]]