Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented the groupby filter #570

Merged
merged 2 commits into from
Sep 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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] }}
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
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!?"}]]