Hilla AutoCrud with MongoDB

René Wilby | Mar 12, 2024 min read

Hilla AutoCrud

The full-stack web framework Hilla provides a component called AutoCrud, that consists of a table (AutoGrid) and an integrated form (AutoForm). CRUD functions are available via the form, which means data can be created, read, updated and deleted. All data can be displayed, sorted and filtered in the 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 AutoCrud 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 AutoCrud 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:

docker 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:

docker stop mongodb && docker 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-autocrud-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-autocrud-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 and alter the data from the MongoDB instance in the AutoCrud 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, getter and setter methods:

import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.mapping.Document;

import java.time.LocalDate;

@Document
public class Person {

    @Id
    private String id;

    @NotBlank
    private String firstName;

    @NotBlank
    private String lastName;

    @NotBlank
    private String street;

    @NotNull
    @DecimalMin("10000")
    @DecimalMax("99999")
    private int zipcode;

    @NotBlank
    private String city;

    @NotBlank
    private String country;

    @NotBlank
    @Email
    private String mail;

    @NotBlank
    private String phone;

    @NotNull
    private LocalDate dateOfBirth;

    @Version
    private int version;

    // Getter

    // Setter
}

The fields id and version are managed by MongoDB and should therefore only be displayed in the frontend, but not changed. Currently, this cannot yet be achieved without errors using suitable annotations directly in the DTO, so the necessary configuration is done later in the frontend. The other fields carry various annotations from the jakarta.validation.constraints package, such as @NotBlank, @NotNull or @Email. Hilla takes these annotations into account when validating the data entered in the form of the AutoCrud component.

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 AutoCrud 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

Implementing the CrudService 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 AutoCrud component, the next step is to provide a service that implements the interface CrudService<T, ID> and that makes the methods list, save and delete available for the frontend in which the AutoCrud 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.CrudService;
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 CrudService<Person, String> {

    @Autowired
    private MongoTemplate mongoTemplate;

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

    @Override
    public @Nullable Person save(Person person) {
        // TODO
    }

    @Override
    public void delete(String id) {
        // 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 AutoCrud component and passed when the backend is called. The two objects can be used to sort, filter and load the data in the AutoCrud 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()));
                };
                case "dateOfBirth" ->
                    switch (propertyFilter.getMatcher()) {
                        case GREATER_THAN -> Criteria.where(propertyFilter.getPropertyId()).gt(LocalDate.parse(propertyFilter.getFilterValue()));
                        case LESS_THAN -> Criteria.where(propertyFilter.getPropertyId()).lt(LocalDate.parse(propertyFilter.getFilterValue()));
                        default -> Criteria.where(propertyFilter.getPropertyId()).is(LocalDate.parse(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 AutoCrud 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=[]]

The remaining methods save and delete are relatively easy to implement:

    @Override
    public @Nullable Person save(Person person) {
        return mongoTemplate.save(person);
    }

    @Override
    public void delete(String id) {
        mongoTemplate.findAndRemove(Query.query(Criteria.where("id").is(id)), Person.class);
    }

In both cases, the appropriate methods of MongoTemplate can be used directly.

Connecting the frontend

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

import { AutoCrud } from "@hilla/react-crud";
import PersonModel from "Frontend/generated/com/example/application/entities/PersonModel";
import { PersonService } from "Frontend/generated/endpoints";

export default function PersonView() {
  const columnsToHide = ['id']
  const visibleColumns = Object.getOwnPropertyNames(PersonModel.prototype).filter((key) => columnsToHide.indexOf(key) == -1) 
  const fieldsToHide = ['id', 'version']
  const visibleFields = Object.getOwnPropertyNames(PersonModel.prototype).filter((key) => fieldsToHide.indexOf(key) == -1)

  return (
    <AutoCrud service={PersonService} model={PersonModel} gridProps={{ visibleColumns }} formProps={{ visibleFields }} />
  )
}

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.

As described above, the id and version fields are managed by MongoDB and should therefore not be changeable in the AutoCrud component and should only be displayed if necessary. This can be achieved using the visibleColumns properties of the integrated AutoGrid component or visibleFields of the integrated AutoForm component. In this example, the id field is neither displayed in the table nor in the form. The version field is displayed in the table, but not in the form. The values for visibleColumns and visibleFields are determined using a little workaround with Object.getOwnPropertyNames(PersonModel.prototype). In this way, only the fields that are not to be displayed need to be explicitly named instead of listing all the fields that are to be displayed.

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

AutoCrud with PersonService

The column headings can be used to sort the data in the AutoCrud component. The filters can be combined with each other to filter the data to be displayed. The AutoCrud component reads the data to be displayed using pagination - the further you scroll down in the component, the more data is fetched dynamically. If an entry is selected, it can be edited and deleted using the integrated form. New entries can be entered via the form starting with the + New button.

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 AutoCrud component in the frontend. With the help of the CrudService<T, ID> interface, the AutoCrud 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 AutoCrud 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.