angular-filter icon indicating copy to clipboard operation
angular-filter copied to clipboard

Performance issue in groupBy

Open oliversalzburg opened this issue 9 years ago • 10 comments

I have an array of 300 non-trivial objects, which I want to filter with groupBy. When I attempt to do that, my whole applications slows to a crawl.

This is due to enormous amounts of time being spent in getHashKey, which JSON.stringifys the whole array to use that as the key for $$cache.

If there's an alternative approach, I'd love to hear it :)

oliversalzburg avatar Feb 18 '16 15:02 oliversalzburg

I have the same issue

daniel-van-niekerk avatar Feb 29 '16 12:02 daniel-van-niekerk

We moved to a solution that works for our specific use case. It relies on the objects being grouped having an id property:

angular
    .module( "util" )
    .filter( "groupBy", groupByFilterProvider );

/* @ngInject */
function groupByFilterProvider( _ ) {
    return function groupByFilter( array, groupKey ) {
        if( !array || !array.length ) {
            return array;
        }

        if( array.__groupByContainer ) {
            var arrayId = idString( array, groupKey );
            if( array.__groupById === arrayId ) {
                return array.__groupByContainer;
            }
        }

        array.__groupByContainer = {};
        array.__groupById        = idString( array, groupKey );

        var elementProperty;

        _.forEach( array, function addElementToGroup( element ) {
            elementProperty = element[ groupKey ];

            if( !array.__groupByContainer[ elementProperty ] ) {
                array.__groupByContainer[ elementProperty ] = [];
            }
            array.__groupByContainer[ elementProperty ].push( element );
        } );

        return array.__groupByContainer;
    };

    function idString( array, key ) {
        return _.reduce( array, function appendKey( id, element ) {
            return id + element.id + element[ key ];
        }, "" );
    }
}

oliversalzburg avatar Feb 29 '16 12:02 oliversalzburg

+1 for this issue. groupBy works great for simple object arrays, but with complex objects it is really slow. The hashKey that's generated for a simple demo array of 62 complex objects is 61Kb. In the actual application, the array will be much larger.

Update - I took a similar-ish approach to @oliversalzburg above. The hashKey I'm creating for the group objects is a concatenation of the object keys (as opposed to a JSON of everything like the actual library). So I call _groupBy with each iteration to get the keys, then pass them to a modified version of isMemoized that uses those keys instead of the giant one that it is currently using. Simply changing the keys has taken a 10 second plus render cycle to under 1 second...and calling _groupBy with each iteration only takes a millisecond or two.

JustACodeMonkey avatar Apr 13 '16 12:04 JustACodeMonkey

Hi @oliversalzburg ! On your code, what is the meaning of the underscore groupByFilterProvider( _ )? I just pasted your code and it throws this error Unknown provider: _Provider <- _ <- groupByFilter.

Edited: I removed the _ from the function parameter and added lodash to make it work.

rodrigosaling avatar Jul 05 '16 21:07 rodrigosaling

We usually wrap globals in Angular providers so that we can mock them. A simple way would be angular.module("foo").constant("_",_);

oliversalzburg avatar Jul 05 '16 22:07 oliversalzburg

Is this fix going to be included in a new release anytime soon?

PhilHuot avatar Sep 08 '16 17:09 PhilHuot

Yes I have this issue as well, is there an upcoming release?

EssyTech avatar Oct 19 '16 22:10 EssyTech

+1

beatgrabe avatar Oct 20 '16 07:10 beatgrabe

I changed my approach from my previous post. Now, I'm creating a unique key by using the grouping property concatenated with a random number. Then when the cache is checked, it looks for the non-random part of the key and checks to see if the collection is the same (the random part of the key is inferred from the collection match). So...

.filter('groupBy', [ '$rootScope', '$parse', 'filterWatcher', function ( $rootScope, $parse, filterWatcher ) {
    return function (collection, groupOn) {

        if(!isObject(collection) || isUndefined(groupOn) || !collection.length || collection.length === 0) {
            return collection;
        }
        var key     = filterWatcher.getKey(collection, groupOn),
            grpObj  = _groupBy(collection, $parse(groupOn));

        return  filterWatcher.isMemoized(arguments, key) ||
                filterWatcher.memoize(arguments, key, grpObj);
        /**
         * groupBy function
         * @param collection {Array}
         * @param key           {String}
         * @returns {{}}
         */
        function _groupBy(collection, key) {
            var result  = {},
                prop    = '';

            forEach(collection, function(item, i) {
                prop = key(item);
                if(prop == '0' || prop == ''){
                    prop = 'zz'+ prop;
                }

                if(!result[prop]) {
                    result[prop] = [];
                }
                result[prop].push(item);
            });
            return result;
        }
    }
}]);

...And then in the filterWatcher, I changed and added...

    /**
     * @private
     * isSame
     * - Checks to see if the objects in the array are the same
     * @param arr1  {Array}
     * @param arr2  {Array}
     * @returns     {Boolean}
     */
    function isSame(arr1, arr2){
        if(arr1.length !== arr2.length){
            return false;
        }
        for(var i=0; i<arr1.length; i++){
            if(arr1[i] !== arr2[i]){
                return false;
            }
        }
        return true;
    }
    /**
     * @description
     * return the `cacheKey` or undefined.
     * @param args          {Array}
     * @param key           {String} // GHY addition!
     * @returns {*}
     */
    function $$isMemoized(args, key) {
        var cache = $$cache[key];
        if(!cache || (cache && !isSame(cache.$$collection, args[0]))){
            return null;
        }
        return cache.$$result;
    }
    /**
     * @description
     * store `result` in `$$cache` container, based on the hashKey.
     * add $destroy listener and return result
     * @param args      {Array}
     * @param key       {String}
     * @param result    {Object}
     * @returns         {Object}
     */
    function $$memoize(args, key, result) {
        $$cache[key] = {
            $$result:       result,
            $$collection:   args[0]
        };
        cleanStateless();
        return result;
    }
    /**
     * @private
     * getKey
     * - Returns a new key if the array is new
     * @param arr       {Array}
     * @param groupOn   {String}
     * @returns         {String}
     */
    function $$getKey(arr, groupOn){
        var key = '';
        for(key in $$cache){
            if(key.indexOf(groupOn) === 0 && isSame($$cache[key].$$collection, arr)){
                return key;
            }
        }
        return newKey(groupOn);
    }
    /**
     * @private
     * newKey
     * - Ensures a unique key is generated
     * @param groupOn   {String}
     * @returns         {String}
     */
    function newKey(groupOn){
        var key = groupOn +'_'+ Math.random().toString().substring(2);
        if($$cache[key]){
            return newKey();
        }
        return key;
    }

...Checking the object pointers within the array (the isSame function) is much quicker than matching on massive object keys (at least in my case).

JustACodeMonkey avatar Oct 20 '16 18:10 JustACodeMonkey

I was also having heavy performance issue. Sad that this filter is so bad optimized. For me I'm now creating an array of "groups" then on the ng-repeat I only show that specified item if it belongs to that repeated group. Believe or not this works Much faster than this filter... In any case I'm gonna improve my code.

<div ng-repeat="(groupKey, value) in groups track by $index"> <div class="item item-divider divider"> {{groupKey}} </div> <div class="item item-text-wrap" ng-repeat="item in menu" ng-if="item.CAT_DESCRIPTION==group"> <h2>{{item.name}}</h2> <div class="row"> <div class="col-80"> <p>{{item.description}}</p> </div> <div class="col-20" > {{item.price}} </div> </div> </div> </div>

renanss avatar Mar 01 '17 19:03 renanss