Hilla AutoGrid with MongoDB

René Wilby | Jan 15, 2024 min read

Hilla AutoGrid

The full-stack web framework Hilla provides a component called AutoGrid, which can be used to display, sort and filter data in a table quickly and easily. The data is made available as a service from a Java backend via a so-called BrowserCallable. If the data is stored in a relational database, such as MySQL or PostgeSQL, it can be connected very easily with the help of Spring Data and JPA. The official documentation from Hilla provides a good guide to get started with this.

Hilla AutoGrid and MongoDB

Since version 2.5 of Hilla, there is now a simplified option for using other non-relational databases, such as MongoDB, as a data source for the AutoGrid component. The following example describes how to achieve this.

Start MongoDB locally

First of all, you need a running MongoDB instance. This can be done either via MongoDB Atlas or via a locally executed instance. For this example, MongoDB is executed locally in a container. This requires an appropriate runtime environment for containers, like Docker Desktop or Podman Desktop.

After the runtime environment has been installed and started, a new container with a running MongoDB instance can be started as follows:

podman run --name mongodb --detach --publish 27017:27017 --env MONGODB_INITDB_ROOT_USERNAME=user --env MONGODB_INITDB_ROOT_PASSWORD=password mongodb/mongodb-community-server:latest

The username and password are passed as environment variables. The MongoDB instance can then be accessed via localhost and port 27017.

The following commands can be used to stop and delete the container if required:

podman stop mongodb && podman rm mongodb

MongoDB Compass

You can use MongoDB Compass to connect to a running MongoDB instance, manage databases and collections and execute queries. To check that the local MongoDB instance has been started and is running correctly, you can connect to the instance via MongoDB Compass. The following connection URI must be used:

MongoDB Compass - New Connection

mongodb://user:password@localhost:27017/

Hilla project hilla-autogrid-mongodb

Now that the MongoDB instance is available, the next step is to create a new Hilla project. The Hilla CLI can be used for this purpose:

npx @hilla/cli init hilla-autogrid-mongodb

The project created is a regular Spring Boot project based on Maven. It also has a frontend folder containing automatically generated and manually written TpyeScript and React code that is used for the web frontend.

However, before the frontend can display the data from the MongoDB instance in the AutoGrid component, the backend must first be extended with the necessary functions.

Add Spring Data MongoDB

The required dependencies for Spring Data and MongoDB are added in the pom.xml.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

Create a DTO

In this example project, the class Person.java is used as a DTO. An instance of the class corresponds to a document in a MongoDB collection. The class is created with its fields, a constructor, getter and setter methods:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;

@Document
@JsonIgnoreProperties(value = { "id"})
public class Person {

    @Id
    private String id;

    private String firstName;

    private String lastName;

    private String street;

    private int zipcode;

    private String city;

    private String country;

    private String mail;

    private String phone;

    public Person(String firstName, String lastName, String street, int zipcode, String city, String country, String mail, String phone) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.street = street;
        this.zipcode = zipcode;
        this.city = city;
        this.country = country;
        this.mail = mail;
        this.phone = phone;
    }

    // Getter

    // Setter
}

The field id contains the ID of the document in the collection generated by MongoDB. This field should not be displayed in the frontend, so it is excluded from code generation by Hilla using @JsonIgnoreProperties(value = { "id"}).

Providing a MongoTemplate

The MongoDB instance is accessed via the Template API. Alternatively, you could also use the familiar Repositories approach of Spring Data. In conjunction with Hilla and the AutoGrid component, however, the use of the Template API proves to be more flexible.

The required MongoClient and the MongoTemplate are provided as beans via the MongoConfig.java class:

import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.core.MongoTemplate;

@Configuration
public class MongoConfig {

    @Value("${mongodb.connection-uri}")
    private String connectionUri;

    @Value("${mongodb.database}")
    private String database;

    @Bean
    public MongoClient mongoClient() {
        return MongoClients.create(connectionUri);
    }

    @Bean
    public MongoTemplate mongoTemplate(MongoClient mongoClient) {
        return new MongoTemplate(mongoClient, database);
    }
}

The connection URI and the name of the database are read from application.properties and are stored there as follows:

mongodb.connection-uri=mongodb://user:password@localhost:27017/
mongodb.database=address-book

To see which queries are executed on the MongoDB instance, it is useful to adjust the corresponding log level. This can also be achieved in application.properties:

logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG

Generating test data

The MongoDB instance does not contain any application-specific data initially. Spring Data creates the database with the name address-book and the collection with the name person based on the information already known. When the application is started, some test data should also be generated, which can then be displayed later in the AutoGrid component. The test data is generated in Application.java:

import com.example.application.entities.Person;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.theme.Theme;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Query;

@SpringBootApplication
@Theme(value = "hilla-autogrid-mongodb")
public class Application implements AppShellConfigurator {

    @Autowired
    private MongoTemplate mongoTemplate;

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @PostConstruct
    public void init() {
        mongoTemplate.findAllAndRemove(new Query(), Person.class);

        // Generate test data for 500 persons
        for (int i = 1; i <= 500; i++) {
            mongoTemplate.insert(new Person("First Name " + i, "Last Name " + i, "Street " + i, 10000 + i, "City " + i, "Country " + i, "mail" + i + "@example.com", "123456789" + i));
        }
    }
}

The init method first deletes all existing documents in the person collection and then inserts 500 new documents with test data.

Implementing the ListService interface

At this point, the example has a MongoDB instance and a MongoTemplate that can be used to access the data. To be able to use the data for the AutoGrid component, the next step is to provide a service that implements the interface ListService<T> and that makes a list method available for the frontend in which the AutoGrid component will be located later.

The required service is implemented in the class PersonService.java:

import com.example.application.entities.Person;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import dev.hilla.BrowserCallable;
import dev.hilla.Nonnull;
import dev.hilla.Nullable;
import dev.hilla.crud.ListService;
import dev.hilla.crud.filter.Filter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Pageable;
import org.springframework.data.mongodb.core.MongoTemplate;

import java.util.List;

@BrowserCallable
@AnonymousAllowed
public class PersonService implements ListService<Person> {

    @Autowired
    private MongoTemplate mongoTemplate;

    @Override
    public @Nonnull List<@Nonnull Person> list(Pageable pageable, @Nullable Filter filter) {
        // TODO
    }
}

The annotation @BrowserCallable ensures that Hilla automatically generates the required endpoint and the associated client-side TypeScript code. This means that no further programming is required later, for example to establish the communication link between the frontend and backend and to serialize or deserialize data. But more on this later.

The annotation @AnonymousAllowed ensures that the methods within this service can be called without authentication. Hilla offers a variety of different authentication mechanisms, which are based on Spring Security in the backend and have been extended by corresponding utility functions in the frontend.

The list method returns a list of persons. It receives a Pageable object and a Filter object as arguments. Both objects are automatically generated by the AutoGrid component and passed when the backend is called. The two objects can be used to sort, filter and load the data in the AutoGrid component page by page.

For this example, the next task is to process the Pageable object and the Filter object in such a way that they can be used for a database query via the MongoTemplate. To do this, the PersonService.java is extended accordingly:

    @Override
    public @Nonnull List<@Nonnull Person> list(Pageable pageable, @Nullable Filter filter) {
        return mongoTemplate.find(createQuery(pageable, filter), Person.class);
    }

    private Query createQuery(Pageable pageable, Filter filter) {
        Query query = new Query();
        query.with(pageable);
        query.addCriteria(createCriteria(filter));
        return query;
    }

    private Criteria createCriteria(Filter filter) {
        Criteria criteria = new Criteria();
        if (filter instanceof AndFilter andFilter) {
            if (andFilter.getChildren().isEmpty()) {
                return criteria;
            } else {
                return criteria.andOperator(andFilter.getChildren().stream()
                        .map(this::createCriteria).toList());
            }
        } else if (filter instanceof OrFilter orFilter) {
            if (orFilter.getChildren().isEmpty()) {
                return criteria;
            } else {
                return criteria.orOperator(orFilter.getChildren().stream()
                        .map(this::createCriteria).toList());
            }
        } else if (filter instanceof PropertyStringFilter propertyFilter) {
            return switch (propertyFilter.getPropertyId()) {
                case "firstName", "lastName", "street", "city", "country", "mail", "phone" -> Criteria.where(propertyFilter.getPropertyId()).regex(propertyFilter.getFilterValue(), "i");
                case "zipcode" ->
                    switch (propertyFilter.getMatcher()) {
                        case GREATER_THAN -> Criteria.where(propertyFilter.getPropertyId()).gt(Integer.valueOf(propertyFilter.getFilterValue()));
                        case LESS_THAN -> Criteria.where(propertyFilter.getPropertyId()).lt(Integer.valueOf(propertyFilter.getFilterValue()));
                        default -> Criteria.where(propertyFilter.getPropertyId()).is(Integer.valueOf(propertyFilter.getFilterValue()));
                };
                default -> throw new IllegalArgumentException("Unknown filter property " + propertyFilter.getPropertyId());
            };
        } else {
            throw new IllegalArgumentException("Unknown filter type " + filter.getClass().getName());
        }
    }

The section Querying Documents in the Spring Data MongoDB documentation describes how database queries can be composed easily with the help of Query and Criteria.

The existing Pageable object can be added to a Query object without further transformation via query.with(pageable).

The Filter object from Hilla is transferred to a Criteria object from Spring Data MongoDB. This is done in the createCriteria method. The AndFilter and OrFilter contained in the Filter object are called recursively until a PropertyStringFilter is available at the lowest level. Depending on the data type of the contained field, corresponding Criteria objects are created.

A concrete Filter object that is sent by the AutoGrid component and contains, for instance, 3 PropertyStringFilter objects that are connected via an AndFilter can look like this:

AndFilter [children=[PropertyStringFilter [propertyId=firstName, matcher=CONTAINS, filterValue=99], PropertyStringFilter [propertyId=lastName, matcher=CONTAINS, filterValue=N], PropertyStringFilter [propertyId=zipcode, matcher=GREATER_THAN, filterValue=9]]]

An empty Filter object, on the other hand, looks like this:

AndFilter [children=[]]

Connecting the frontend

Thanks to the annotation @BrowserCallable and the implementation of the interface ListService<T>, the provided PersonService can be used very conveniently in the frontend in conjunction with the AutoGrid component. To do this, the file PersonView.tsx is created in the directory frontend/views/person:

import { VerticalLayout } from "@hilla/react-components/VerticalLayout.js";
import { AutoGrid } from "@hilla/react-crud";
import PersonModel from "Frontend/generated/com/example/application/entities/PersonModel";
import { PersonService } from "Frontend/generated/endpoints";

export default function PersonView() {
  return (
    <VerticalLayout className="h-full">
      <AutoGrid service={PersonService} model={PersonModel} />
    </VerticalLayout>
  )
}

PersonModel and PersonService are TypeScript classes that Hilla automatically generates based on the information in the backend. Hilla also keeps this files up-to-date when changes are made. While PersonModel is the client-side representation of the DTO Person.java, PersonService takes care of the type-safe communication between frontend and backend.

The entire project can be started via ./mvnw and opened in the browser via http://localhost:8088.

AutoGrid with PersonService

The column headings can be used to sort the data in the AutoGrid component. The filters can be combined with each other to filter the data to be displayed. The AutoGrid component reads the data to be displayed using pagination - the further you scroll down in the component, the more data is fetched dynamically.

Summary

A Hilla project is a classic Spring Boot project in the backend. This means that MongoDB can be integrated very easily via the spring-boot-starter-data-mongodb dependency. The necessary configuration and the MongoTemplate are quickly created. The PersonService acts as the connection between the database in the backend and the AutoGrid component in the frontend. With the help of the ListService<T> interface, the AutoGrid component can be easily connected to a non-JPA-based database, such as MongoDB. Thanks to @BrowserCallable, the service is immediately ready to use, without any additional configuration or development. For the frontend, Hilla automatically generates the required TypeScript classes that establish the connection between the AutoGrid component and the PersonService. The integrated TypeScript client handles the communication between backend and frontend.

The source code for the example is available at GitHub.