Repository: mozilla/nunjucks (js)

Issue:
groupby complex attributes
I have a list of objects. These objects have a `date` attribute. I want to group by year.
Something along the lines of the following would be nice.

```
for year, posts in collections.post | groupby("date.year")
```

But it begs the question how to deal with transforms that need to happen before the grouping.
Is there a way to use `groupby` like this?

Pull request:
Add support for nested attributes in `groupBy` filter
## Summary

Proposed change:

Add support for nested attributes access for `groupBy` filter to match [Jinja2 behaviour](https://jinja.palletsprojects.com/en/2.11.x/templates/#groupby)

Closes #1198 .


## Checklist

I've completed the checklist below to ensure I didn't forget anything. This makes reviewing this PR as easy as possible for the maintainers. And it gets this change released as soon as possible.

* [x] Proposed change helps towards [*purpose of this project*](https://github.com/mozilla/nunjucks/blob/master/CONTRIBUTING.md#purpose).
* [x] [*Documentation*](https://github.com/mozilla/nunjucks/tree/master/docs/) is added / updated to describe proposed change.
* [x] [*Tests*](https://github.com/mozilla/nunjucks/tree/master/tests) are added / updated to cover proposed change.
* [x] [*Changelog*](https://github.com/mozilla/nunjucks/blob/master/CHANGELOG.md) has an entry for proposed change (if user-facing fix or feature).

<!-- Tick of items by replacing `[ ]` by `[x]` -->

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6eb67f8..b17d2bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,12 @@ Changelog
   [ogonkov](https://github.com/ogonkovv)!
 * Fix precompile binary script `TypeError: name.replace is not a function`.
   Fixes [#1295](https://github.com/mozilla/nunjucks/issues/1295).
+* Add support for nested attributes on
+  [`groupby` filter](https://mozilla.github.io/nunjucks/templating.html#groupby);
+  respect `throwOnUndefined` option, if the groupby attribute is undefined.
+  Merge of [#1276](https://github.com/mozilla/nunjucks/pull/1276); fixes
+  [#1198](https://github.com/mozilla/nunjucks/issues/1198). Thanks
+  [ogonkov](https://github.com/ogonkovv)!
 
 3.2.1 (Mar 17 2020)
 -------------------
diff --git a/docs/templating.md b/docs/templating.md
index 5c21301..f3bcfcd 100644
--- a/docs/templating.md
+++ b/docs/templating.md
@@ -1160,6 +1160,48 @@ green : james jessie
 blue : john jim
 ```
 
+Attribute can use dot notation to use nested attribute, like `date.year`.
+
+**Input**
+
+```jinja
+{% set posts = [
+      {
+        date: {
+          year: 2019
+        },
+        title: 'Post 1'
+      },
+      {
+        date: {
+          year: 2018
+        },
+        title: 'Post 2'
+      },
+      {
+        date: {
+          year: 2019
+        },
+        title: 'Post 3'
+      }
+    ]
+%}
+
+{% for year, posts in posts | groupby("date.year") %}
+    :{{ year }}:
+    {% for post in posts %}
+        {{ post.title }}
+    {% endfor %}
+{% endfor %}
+```
+
+**Output**
+:2018:
+Post 2
+:2019:
+Post 1
+Post 3
+
 ### indent
 
 Indent a string using spaces.
diff --git a/nunjucks/src/filters.js b/nunjucks/src/filters.js
index b14c298..8e17f78 100644
--- a/nunjucks/src/filters.js
+++ b/nunjucks/src/filters.js
@@ -164,7 +164,7 @@ function forceescape(str) {
 exports.forceescape = forceescape;
 
 function groupby(arr, attr) {
-  return lib.groupBy(arr, attr);
+  return lib.groupBy(arr, attr, this.env.opts.throwOnUndefined);
 }
 
 exports.groupby = groupby;
diff --git a/nunjucks/src/lib.js b/nunjucks/src/lib.js
index b63e2d0..6e66621 100644
--- a/nunjucks/src/lib.js
+++ b/nunjucks/src/lib.js
@@ -167,12 +167,58 @@ function isObject(obj) {
 
 exports.isObject = isObject;
 
-function groupBy(obj, val) {
+/**
+ * @param {string|number} attr
+ * @returns {(string|number)[]}
+ * @private
+ */
+function _prepareAttributeParts(attr) {
+  if (!attr) {
+    return [];
+  }
+
+  if (typeof attr === 'string') {
+    return attr.split('.');
+  }
+
+  return [attr];
+}
+
+/**
+ * @param {string}   attribute      Attribute value. Dots allowed.
+ * @returns {function(Object): *}
+ */
+function getAttrGetter(attribute) {
+  const parts = _prepareAttributeParts(attribute);
+
+  return function attrGetter(item) {
+    let _item = item;
+
+    for (let i = 0; i < parts.length; i++) {
+      const part = parts[i];
+
+      // If item is not an object, and we still got parts to handle, it means
+      // that something goes wrong. Just roll out to undefined in that case.
+      if (hasOwnProp(_item, part)) {
+        _item = _item[part];
+      } else {
+        return undefined;
+      }
+    }
+
+    return _item;
+  };
+}
+
+function groupBy(obj, val, throwOnUndefined) {
   const result = {};
-  const iterator = isFunction(val) ? val : (o) => o[val];
+  const iterator = isFunction(val) ? val : getAttrGetter(val);
   for (let i = 0; i < obj.length; i++) {
     const value = obj[i];
     const key = iterator(value, i);
+    if (key === undefined && throwOnUndefined === true) {
+      throw new TypeError(`groupby: attribute "${val}" resolved to undefined`);
+    }
     (result[key] || (result[key] = [])).push(value);
   }
   return result;


Test patch:
diff --git a/tests/filters.js b/tests/filters.js
index 26c610f..6d526d3 100644
--- a/tests/filters.js
+++ b/tests/filters.js
@@ -248,6 +248,34 @@
     });
 
     it('groupby', function(done) {
+      const namesContext = {
+        items: [{
+          name: 'james',
+          type: 'green'
+        },
+        {
+          name: 'john',
+          type: 'blue'
+        },
+        {
+          name: 'jim',
+          type: 'blue'
+        },
+        {
+          name: 'jessie',
+          type: 'green'
+        }]
+      };
+      equal(
+        '{% for type, items in items | groupby("type") %}' +
+        ':{{ type }}:' +
+        '{% for item in items %}' +
+        '{{ item.name }}' +
+        '{% endfor %}' +
+        '{% endfor %}',
+        namesContext,
+        ':green:jamesjessie:blue:johnjim');
+
       equal(
         '{% for type, items in items | groupby("type") %}' +
         ':{{ type }}:' +
@@ -270,10 +298,107 @@
           },
           {
             name: 'jessie',
-            type: 'green'
+            color: 'green'
           }]
         },
-        ':green:jamesjessie:blue:johnjim');
+        ':green:james:blue:johnjim:undefined:jessie');
+
+      equal(
+        '{% for year, posts in posts | groupby("date.year") %}' +
+        ':{{ year }}:' +
+        '{% for post in posts %}' +
+        '{{ post.title }}' +
+        '{% endfor %}' +
+        '{% endfor %}',
+        {
+          posts: [
+            {
+              date: {
+                year: 2019
+              },
+              title: 'Post 1'
+            },
+            {
+              date: {
+                year: 2018
+              },
+              title: 'Post 2'
+            },
+            {
+              date: {
+                year: 2019
+              },
+              title: 'Post 3'
+            }
+          ]
+        },
+        ':2018:Post 2:2019:Post 1Post 3');
+
+      equal(
+        '{% for year, posts in posts | groupby("date.year") %}' +
+        ':{{ year }}:' +
+        '{% for post in posts %}' +
+        '{{ post.title }}' +
+        '{% endfor %}' +
+        '{% endfor %}',
+        {
+          posts: [
+            {
+              date: {
+                year: 2019
+              },
+              title: 'Post 1'
+            },
+            {
+              date: {
+                year: 2018
+              },
+              title: 'Post 2'
+            },
+            {
+              meta: {
+                month: 2
+              },
+              title: 'Post 3'
+            }
+          ]
+        },
+        ':2018:Post 2:2019:Post 1:undefined:Post 3');
+
+      equal(
+        '{% for type, items in items | groupby({}) %}' +
+        ':{{ type }}:' +
+        '{% for item in items %}' +
+        '{{ item.name }}' +
+        '{% endfor %}' +
+        '{% endfor %}',
+        namesContext,
+        ':undefined:jamesjohnjimjessie'
+      );
+
+      const undefinedTemplate = (
+        '{% for type, items in items | groupby("a.b.c") %}' +
+        ':{{ type }}:' +
+        '{% for item in items %}' +
+        '{{ item.name }}' +
+        '{% endfor %}' +
+        '{% endfor %}'
+      );
+      equal(
+        undefinedTemplate,
+        namesContext,
+        ':undefined:jamesjohnjimjessie'
+      );
+
+      expect(function() {
+        render(
+          undefinedTemplate,
+          namesContext,
+          {
+            throwOnUndefined: true
+          }
+        );
+      }).to.throwError(/groupby: attribute "a\.b\.c" resolved to undefined/);
 
       finish(done);
     });