n:m relationships in Hilla with AutoGrid and AutoForm

René Wilby | Jun 24, 2024 min read

Entities in business applications

Entities play an important role in the development of business applications for me. Before I start to develop, I first think about the business entities involved and their relationships to each other. An Entity-Relationship Model is a useful tool for describing and graphically representing the relationships between entities. A common relationship between entities is the n:m relationship. In an application such as a task list, for example, a task can have any number of labels in order to categorize the task based on the labels. From the perspective of a label, a label can be assigned to any number of tasks. Task and label have a n:m relationship with each other. The term many-to-many relationship is often used for this.

A suitable technology stack

When developing business applications, it is therefore important that the frameworks used support this n:m relationship as conveniently and flexibly as possible. When developing business applications with Hilla, these requirements are met very well.

Spring Boot is used in the backend. In conjunction with relational databases, you can use Spring Data JPA and benefit from many advantages, including the following:

  • The database schema can be generated automatically from the classes of the entities.
  • Communication with the database can take place via the very powerful JPA repositories.

In the frontend, the application can be developed very quickly using the ready-made UI components of Hilla. For example, a typical master-detail UI pattern can be quickly implemented using the AutoGrid and AutoForm components.

Hilla already supports the 1:1 relationships of entities in the AutoGrid component out-of-the-box (see Example). For n:m relationships, you can easily customize both the AutoGrid and AutoForm components to support the relationship in a meaningful way.

Special characteristics of the n:m relationship

A special characteristic of the n:m relationship is that it must be resolved using a mapping table if it is to be used in a relational database system. Otherwise, you would have to violate the 1st normal form (1NF). The n:m relationship between a task and a label can be mapped in a relational model as follows:

n:m relationship between task and label

Example

In the following example, this will be shown using the entities Task and Label. A task can have any number of labels, and a label can be assigned to any number of tasks.

Backend

In the backend, the two entities Task and Label are created first.

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    Long id;

    @NotBlank
    private String name;

    @ManyToMany
    @JoinTable(name = "tasks_labels")
    private List<Label> labels;

    // Getter and Setter omitted
}

The annotation @JoinTable(name = “tasks_labels”) is used to specify the mapping table that is used to resolve the n:m relationship. Spring Data JPA automatically takes care of creating and managing this mapping table.

@Entity
@Table(name = "labels")
public class Label {

    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    Long id;

    @NotBlank
    @Column(name = "val")
    private String value;

    // Getter and Setter omitted
}

It would also be possible to map the n:m relationship bi-directionally and also keep a list of tasks in the Label entity. For this example, however, it is sufficient to manage only a uni-directional n:m relationship from the task’s perspective.

Afterwards, the corresponding JPA repositories are created.

public interface TaskRepository extends JpaRepository<Task, Long>, JpaSpecificationExecutor<Task>  { }
public interface LabelRepository extends JpaRepository<Label, Long>, JpaSpecificationExecutor<Label> { }

Communication between the backend and frontend takes place via corresponding services for Task and Label, which are annotated with @BrowserCallable. This is sufficient for Hilla to generate the required TypeScript classes, which can then be used in the frontend.

@BrowserCallable
@AnonymousAllowed
public class TaskService extends CrudRepositoryService<Task, Long, TaskRepository> { }
@BrowserCallable
@AnonymousAllowed
public class LabelService extends CrudRepositoryService<Label, Long, LabelRepository> {

    public List<Label> getLabels() {
        return super.getRepository().findAll();
    }
}

Frontend

The master view of the sample application shows all tasks in the AutoGrid component. A column for the labels is provided in the table. This column is not displayed by default, as the AutoGrid component does not provide a default renderer for a field of type List. Therefore, the column for labels is explicitly displayed via the visibleColumns property. With the help of a custom renderer, you can flexibly define how the labels of a task should be displayed. In the example, all labels are displayed in a comma-separated list.

<AutoGrid 
  columnOptions={{
    labels: {
      renderer: ({ item }: { item: Task }) => {
        const { labels } = item;
        return <span>{labels?.map((label) => label?.value).join(", ")}</span>
      },
    }
  }}
  model={TaskModel} 
  service={TaskService} 
  visibleColumns={["name", "labels"]} 
/>

AutoGrid - Tasks

The AutoGrid component has a great filter mechanism for each column. The column for the labels is given an individual filter.

<AutoGrid 
  columnOptions={{
    labels: {
      headerFilterRenderer: ({ setFilter }) => (
        <TextField
          theme="small"
          placeholder="Filter..."
          onValueChanged={({ detail: { value } }) =>
            setFilter({
              propertyId: "labels.value",
              filterValue: value,
              matcher: Matcher.CONTAINS,
              "@type": "propertyString",
            })
          }
        />
      ),
      renderer: ({ item }: { item: Task }) => {
        const { labels } = item;
        return <span>{labels?.map((label) => label?.value).join(", ")}</span>
      },
    }
  }}
  model={TaskModel} 
  service={TaskService} 
  visibleColumns={["name", "labels"]} 
/>

The detail view of the application shows a task in an AutoForm component so that a task can be created and edited. The field for the labels can also be configured individually here. In this example, it makes sense to display the labels via a Multi-Select Combo Box component. This component enables the selection of several labels, including a helpful filter option.

const { taskId } = useParams();
const task = useSignal<Task | undefined>(undefined);
const labels = useSignal<Label[]>([]);

useEffect(() => {
  if (taskId) {
    TaskService.get(Number.parseInt(taskId)).then((t) => task.value = t);
  }
  LabelService.getLabels().then((l) => labels.value = l);
}, [taskId])

return (    
  <AutoForm 
    fieldOptions={{
      labels: {
        renderer: ({ field }) => <MultiSelectComboBox {...field} items={labels.value} itemIdPath="id" itemValuePath="value" itemLabelPath="value" />
      }
    }}
    item={task.value} 
    model={TaskModel} 
    service={TaskService}
    visibleFields={["name", "labels"]} 
  />
)

AutoForm - Task

All available labels are loaded via the LabelService and are available for selection in the Multi-Select Combo Box.

Summary

The combination of Spring Boot and Spring Data JPA in the backend and the UI components and the code generation of Hilla in the frontend enable a very productive and at the same time very flexible development of business web apps. Moreover, dealing with n:m relationships between entities is easy.