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://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.
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.