Search code examples
javamongodbkotlinquarkusquarkus-panache

Best practice: MongoDB Panache entity one-to-many relationship handling in Quarkus?


The Quarkus Guide on using MongoDB with Panache does unfortunately not mention what is considered the best practice dealing with one-to-many relations of entities. Note: I would like to model the dependent sub-document, but as an entity on its own. The MongoDB site demonstrates this pattern in: Model One-to-Many Relationships with Document References so that you would have to lookup in your repository all entities linked to the parent ID.

Does Quarkus (Panache) provide currently any means to make the lookup more convenient?

PS: There seems to be an open enhancement request from May 2020 to automatically fetch the referenced entities.


Solution

  • As informed by @loicmathieu there is no direct way to do populate() in quarkus as you are able to do it in mongoose. If you set mongoose.set('debug', true) then you can check in console that first

    • find query on collection is executed
    • in query on reference collection is executed.

    Example json document:

    {
        "_id" : ObjectId("612f6c5c081d0796761ec2e4"),
        "content" : "cannot restart Mongodb even when memory space is available",
        "created_by" : ObjectId("612cb8608d22afb0e404adf6"),
        "tags" : [ 
            ObjectId("612f6c5c081d0796761ec2e2")
        ],
        "createdAt" : ISODate("2021-09-01T12:04:44.361Z"),
        "updatedAt" : ISODate("2021-09-10T05:30:51.384Z"),
        "__v" : 2,
        "likes" : [],
        "replies" : []
    }
    

    Tag Document

    {
        "_id" : ObjectId("612f6c5c081d0796761ec2e2"),
        "name" : "mongodb",
        "created_by" : ObjectId("612cb8608d22afb0e404adf6"),
        "createdAt" : ISODate("2021-09-01T12:04:44.358Z"),
        "updatedAt" : ISODate("2021-09-01T12:04:44.358Z"),
        "__v" : 0
    }
    

    And finally user document

    {
        "_id" : ObjectId("612cb8608d22afb0e404adf6"),
        "username" : "silentsudo",
        "password" : "sosimplebcrypted",
        "name" : "Ashish",
        "__v" : 0
    }
    

    To get all documents i execute following query:

    exports.getRecentTweets = async () => {
        return Tweet.find()
            .populate('created_by', '_id name username mediaId')
            .populate('tags', '_id name')
            .sort({'createdAt': -1});
    }
    

    Mongoose internally executing this in the following manner

    Mongoose: tweets.find({}, { sort: { createdAt: -1 }, projection: {} })
    Mongoose: users.find({ _id: { '$in': [ new ObjectId("612cb8608d22afb0e404adf6"), new ObjectId("612f57ca9ea5c5cabbd89b38"), new ObjectId("612f57ae9ea5c5cabbd89b32"), new ObjectId("6134cb55328b793a1dacb9c2"), new ObjectId("612cb86a8d22afb0e404adfc") ] }}, { skip: undefined, limit: undefined, perDocumentLimit: undefined, projection: { _id: 1, name: 1, username: 1, mediaId: 1 }})
    Mongoose: tags.find({ _id: { '$in': [ new ObjectId("6144d9ef02f6da33f39fe134"), new ObjectId("612f6c5c081d0796761ec2e2"), new ObjectId("612f6e9f081d0796761ec343"), new ObjectId("612dc4623c401072c8098a84"), new ObjectId("612f6cb1081d0796761ec2f1"), new ObjectId("612f6c72081d0796761ec2eb"), new ObjectId("612f6e5b081d0796761ec337"), new ObjectId("612f6e42081d0796761ec330"), new ObjectId("612f6df9081d0796761ec320"), new ObjectId("612f6e0a081d0796761ec328"), new ObjectId("612f6de8081d0796761ec316"), new ObjectId("612f6de8081d0796761ec319"), new ObjectId("612f6dc9081d0796761ec30c"), new ObjectId("612f6dc9081d0796761ec30f"), new ObjectId("612f6db0081d0796761ec303"), new ObjectId("612f6db0081d0796761ec306"), new ObjectId("612f6cc7081d0796761ec2f7") ] }}, { skip: undefined, limit: undefined, perDocumentLimit: undefined, projection: { _id: 1, name: 1 }})
    
    

    I was experimenting ab tool for benchmarking. So i decided to go with quarkus as i never built any production application using it before. So i quickly created model and arrived at this thread only to know there is no direct way to achieve populate in java based driver. But we can use $lookup with aggregation pipeline what we need. First and simplest way is to use mongodb compass tool to build the query and then simply export the result in java language. my json query to produce output similar to populate query looked like this.

    [
      {
        '$unwind': {
          'path': '$tags', 
          'preserveNullAndEmptyArrays': true
        }
      }, {
        '$lookup': {
          'from': 'tags', 
          'localField': 'tags', 
          'foreignField': '_id', 
          'as': 'tweet_tags'
        }
      }, {
        '$unwind': {
          'path': '$tweet_tags', 
          'preserveNullAndEmptyArrays': true
        }
      }, {
        '$group': {
          '_id': '$_id', 
          'content': {
            '$first': '$content'
          }, 
          'user': {
            '$first': '$created_by'
          }, 
          'tags': {
            '$push': '$tweet_tags'
          }
        }
      }, {
        '$lookup': {
          'from': 'users', 
          'localField': 'user', 
          'foreignField': '_id', 
          'as': 'createdByUser'
        }
      }, {
        '$unwind': {
          'path': '$createdByUser', 
          'preserveNullAndEmptyArrays': false
        }
      }, {
        '$set': {
          'createdByUser.password': null
        }
      }
    ]
    

    Which when converted into equivalent java gives

    public static List<Document> getTweetByUserQuery() {
                return Arrays.asList(new Document("$unwind",
                                new Document("path", "$tags")
                                        .append("preserveNullAndEmptyArrays", true)),
                        new Document("$lookup",
                                new Document("from", "tags")
                                        .append("localField", "tags")
                                        .append("foreignField", "_id")
                                        .append("as", "tweet_tags")),
                        new Document("$unwind",
                                new Document("path", "$tweet_tags")
                                        .append("preserveNullAndEmptyArrays", true)),
                        new Document("$group",
                                new Document("_id", "$_id")
                                        .append("content",
                                                new Document("$first", "$content"))
                                        .append("user",
                                                new Document("$first", "$created_by"))
                                        .append("tags",
                                                new Document("$push", "$tweet_tags"))),
                        new Document("$lookup",
                                new Document("from", "users")
                                        .append("localField", "user")
                                        .append("foreignField", "_id")
                                        .append("as", "createdByUser")),
                        new Document("$unwind",
                                new Document("path", "$createdByUser")
                                        .append("preserveNullAndEmptyArrays", false)),
                        new Document("$set",
                                new Document("createdByUser.password",
                                        new BsonNull())));
            }
    

    quarkus Controller code as below:

    @Path("/hello")
    public class ExampleResource {
        @Inject
        private MongoClient mongoClient;
    
        @GET
        @Produces(MediaType.APPLICATION_JSON)
        public Map<Object, Object> hello() {
            MongoCollection<Tweet> tweetsCollection = mongoClient.getDatabase("simple-node-js")
                    .getCollection("tweets", Tweet.class);
    
    
            List<Document> groupByQueryUser = Tweet.GroupByIdDetails.getTweetByUserQuery();
    
            MongoCollection<Document> collections = mongoClient.getDatabase("simple-node-js")
                    .getCollection("tweets");
            AggregateIterable<Document> aggregate = collections.aggregate(groupByQueryUser);
            List<Document> data = new ArrayList<>();
            for (Document value : aggregate) {
                System.out.println(value);
                data.add(value);
            }
    
            return Map.of("size", data.size(), "data", data);
        }
    
    
    }
    

    There are many other things needs to be done with the response like removing unwanted fields but this ways i could achieve same result on node+mongoose api(except few more things which i skipped here)