Search code examples
mongodbspring-dataspring-data-mongodb

Spring Data - MongoDB - Aggregation how to project all fields of grouped reference document


I have 2 documents, Topic and comment. Each topic has many comments and the documents look like this:

@Document
public class Topic {
    @Id
    private String id;

    @Indexed(unique = true)
    @NotBlank
    private String title;
}


@Document
public class Comment {

    @NotBlank
    private String text;

    @Indexed
    @NotBlank
    private String topic;  // id of topic

    @CreatedDate
    @Indexed
    private LocalDateTime createdDate;
}

So I actually save the id reference of Topic within comments.

This is my aggregation scenario: List the topics, which received most comments today. So three things:

  • Get all comments of today (MatchOperation)
  • Group by topic and sum comments (GroupOperation)
  • Sort by this sum (SortOperation)

This is the code so far:

MatchOperation filterCreatedDate = match(Criteria.where("createdDate").gte(today.atStartOfDay()).lt(today.plusDays(1).atStartOfDay()));
GroupOperation groupByTopic = group("topic").count().as("todaysCommentsCount");
SortOperation sortByTodaysCommentsCount = sort(Sort.Direction.DESC, "todaysCommentsCount");

Aggregation aggregation = newAggregation(filterCreatedDate, groupByTopic, sortByTodaysCommentsCount);
AggregationResults<TopTopic> result = mongoTemplate.aggregate(aggregation, Comment.class, TopTopic.class);

This is a special Class for this output:

public class TopTopic {
    private int todaysCommentsCount;
}

And this is the output of my aggregation where there is only one topic:

{
    "mappedResults": [
        {
            "todaysCommentsCount": 3
        }
    ],
    "rawResults": {
        "results": [
            {
                "_id": "5dbdca8112a617031728c417",     // topic id
                "todaysCommentsCount": 3
            }
        ],
        "ok": 1.0
    },
    "serverUsed": null,
    "uniqueMappedResult": {
        "todaysCommentsCount": 1
    }
}

I thought I am actually pretty close, but somehow it works only when I have only one topic. When there are comments from more than one topic which should then create multiple groups, I get this error:

Could not write JSON: Expected unique result or null, but got more than one!; nested exception is com.fasterxml.jackson.databind.JsonMappingException: Expected unique result or null, but got more than one! (through reference chain: org.springframework.data.mongodb.core.aggregation.AggregationResults[\"uniqueMappedResult\"]

.. althought i do not call any getUniqueMappedResult method.

What do I do wrong?

Secondly, how can I get rid of my output class TopTopic, and instead return the original Topic values extended with todaysCommentsCount, without creating a special output class?

I appreciate for any help.


Solution

  • First Part

    You are sending the AggregationResults back to caller where jackson is serializing into json and failing when it is calling getUniqueMappedResult.

    Add the topic field _id to TopTopic and read mapped results in AggregationResults.

    List<TopTopic> topTopics = result.getMappedResults()
    

    Your output would look like

     [
        {
           "_id": "5dbdca8112a617031728c417",
           "todaysCommentsCount": 3
        }
     ]
    

    Second Part

    You can use $$ROOT variable with $first to map the entire document in the $group stage followed by $replaceRoot to promote the merge document ( $mergeObjects to merge the doc and todaysCommentCount ).

    Add the todaysCommentsCount to Topic class.

    Something like

    MatchOperation filterCreatedDate = match(Criteria.where("createdDate").gte(today.atStartOfDay()).lt(today.plusDays(1).atStartOfDay()));
    GroupOperation groupByTopic = group("topic").first("$$ROOT").as("doc").count().as("todaysCommentsCount");
    SortOperation sortByTodaysCommentsCount = sort(Sort.Direction.DESC, "todaysCommentsCount");
    ReplaceRootOperation replaceRoot = Aggregation.replaceRoot().withValueOf(ObjectOperators.valueOf("doc").mergeWith(new Document("todaysCommentsCount", "$todaysCommentsCount")));
    
    Aggregation aggregation = newAggregation(filterCreatedDate, groupByTopic, sortByTodaysCommentsCount, replaceRoot);
    AggregationResults<TopTopic> result = mongoTemplate.aggregate(aggregation, Comment.class, Topic.class);
    
    List<Topic> topTopics = result.getMappedResults();
    

    Your output would look like

     [
        {
           "_id": "5dbdca8112a617031728c417",
           "title" : "topic",
           "todaysCommentsCount": 3
        }
     ]
    

    Update ( Add $lookup stage to pull in topic fields)

    MatchOperation filterCreatedDate = match(Criteria.where("createdDate").gte(today.atStartOfDay()).lt(today.plusDays(1).atStartOfDay()));
    GroupOperation groupByTopic = group("topic").count().as("todaysCommentsCount");
    SortOperation sortByTodaysCommentsCount = sort(Sort.Direction.DESC, "todaysCommentsCount");
    LookupOperation lookupOperation = LookupOperation.newLookup().
                                        from("topic").
                                        localField("_id").
                                        foreignField("_id").
                                        as("topic");
    ReplaceRootOperation replaceRoot = Aggregation.replaceRoot().withValueOf(ObjectOperators.valueOf("todaysCommentsCount").mergeWithValuesOf(ArrayOperators.arrayOf("topic").elementAt(0)));
    
    Aggregation aggregation = newAggregation(filterCreatedDate, groupByTopic, sortByTodaysCommentsCount, lookupOperation, replaceRoot);
    AggregationResults<TopTopic> result = mongoTemplate.aggregate(aggregation, Comment.class, Topic.class);
    
    List<Topic> topTopics = result.getMappedResults();