Skip to content

Commit

Permalink
Implemented the groupby filter (#570)
Browse files Browse the repository at this point in the history
  • Loading branch information
mitsuhiko authored Sep 1, 2024
1 parent 4474eef commit 4a93a8f
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions minijinja/src/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
123 changes: 122 additions & 1 deletion minijinja/src/filters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions minijinja/src/value/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions minijinja/tests/inputs/groupby-filter.txt
Original file line number Diff line number Diff line change
@@ -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] }}
1 change: 1 addition & 0 deletions minijinja/tests/snapshots/[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ State {
"escape",
"first",
"float",
"groupby",
"indent",
"int",
"items",
Expand Down
36 changes: 36 additions & 0 deletions minijinja/tests/snapshots/[email protected]
Original file line number Diff line number Diff line change
@@ -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!?"}]]

0 comments on commit 4a93a8f

Please sign in to comment.