n:m-Beziehungen in Hilla mit AutoGrid und AutoForm

René Wilby | 24.06.2024 Min. Lesezeit

Entitäten in Geschäftsanwendungen

Bei der Entwicklung von Geschäftsanwendungen spielen Entitäten eine wichtige Rolle für mich. Bevor ich mit der Entwicklung beginne, denke ich zunächst über die beteiligten fachlichen Entitäten und ihre Beziehungen zueinander nach. Ein Entity-Relationship-Modell ist dabei ein nützliches Hilfsmittel, um die Beziehungen zwischen Entitäten zu beschreiben und grafisch darzustellen. Eine häufige Beziehung zwischen Entitäten ist die n:m-Beziehung. Bei einer Anwendung wie bspw. einer Aufgabenliste kann eine Aufgabe bspw. beliebig viele Labels haben, um die Aufgabe anhand der Labels zu kategorisieren. Aus Sicht eines Labels kann ein Label beliebig vielen Aufgaben zugewiesen sein. Aufgabe und Label stehen in einer n:m-Beziehung zueinander. Häufig wird dafür auch die Bezeichnung Many-to-Many-Beziehung verwendet.

Ein passender Technologie-Stack

Bei der Entwicklung von Geschäftsanwendungen ist es wichtig, dass die verwendeten Frameworks diese n:m-Beziehung möglichst komfortabel und flexibel unterstützen. Bei der Entwicklung von Geschäftsanwendungen mit Hilla werden diese Anforderungen sehr gut erfüllt.

Im Backend kommt Spring Boot zum Einsatz. In Verbindung mit relationalen Datenbanken kann man Spring Data JPA einsetzen und dabei viele Vorteile nutzen, dazu zählen unter anderem:

  • Das Datenbank-Schema kann automatisch aus den Klassen der Entitäten generiert werden.
  • Die Kommunikation mit der Datenbank kann über die sehr mächtigen JPA-Repositories erfolgen.

Im Frontend kann man die Anwendung mit Hilfe der fertigen UI-Komponenten von Hilla sehr schnell entwickeln. So kann ein typisches Master-Detail-UI-Pattern mit Hilfe der Komponenten AutoGrid und AutoForm schnell umgesetzt werden.

Hilla unterstützt die 1:1-Beziehungen von Entitäten in der AutoGrid-Komponente bereits Out-of-the-box (siehe Beispiel). Für n:m-Beziehungen kann man sowohl die AutoGrid- als auch die AutoForm-Komponente leicht anpassen, um die Beziehung sinnvoll zu unterstützen.

Besonderheiten der n:m-Beziehung

Eine Besonderheit der n:m-Beziehung ist, dass sie mit Hilfe einer Mapping-Tabelle aufgelöst werden muss, wenn man sie in einem relationalen Datenbanksystem abbilden möchte. Andernfalls müsste man gegen die 1. Normalform (1NF) verstoßen. Die n:m-Beziehung zwischen einer Aufgabe und einem Label lässt sich folgendermaßen in einem relationalen Modell abbilden:

n:m-Beziehung zwischen Aufgabe und Label

Beispiel

Im nachfolgenden Beispiel soll dies anhand der Entitäten Task und Label gezeigt werden. Eine Aufgabe kann beliebig viele Labels besitzen und ein Label kann beliebig vielen Aufgaben zugeordnet sein.

Backend

Im Backend werden zunächst die beiden Entitäten Task und Label erstellt.

@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
}

Anhand der Annotation @JoinTable(name = "tasks_labels") wird die Mapping-Tabelle spezifiziert, über die die n:m-Beziehung aufgelöst wird. Spring Data JPA kümmert sich automatisch darum, diese Mapping-Tabelle anzulegen und zu verwalten.

@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
}

Es wäre auch möglich die n:m-Beziehung bi-direktional abzubilden und auch in der Entität Label eine Liste von Aufgaben vorzuhalten. Für das Beispiel soll es jedoch genügen, nur eine uni-direktionale n:m-Beziehung aus Sicht der Aufgabe zu verwalten.

Im Anschluss werden die zugehörigen JPA-Repositories erstellt.

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

Die Kommunikation zwischen Backend und Frontend erfolgt über entsprechende Services für Task und Label, die mit @BrowserCallable annotiert werden. Dies genügt, damit Hilla die erforderlichen TypeScript-Klassen generiert, die man anschließend im Frontend verwenden kann.

@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

Die Master-View der Beispiel-Anwendung zeigt alle Aufgaben in der AutoGrid-Komponente. In der Tabelle ist eine Spalte für die Labels vorgesehen. Im Standard wird diese Spalte nicht angezeigt, da die AutoGrid-Komponente keinen Default-Renderer für ein Feld vom Typ List vorsieht. Daher wird die Spalte für Label über die Eigenschaft visibleColumns explizit eingeblendet. Mit Hilfe eines eigenen Renderes kann man nun flexibel bestimmen, wie die Labels einer Aufgabe angezeigt werden sollen. Im Beispiel werden alle Label in einer Komma-separierten Liste angezeigt.

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

Die AutoGrid-Komponente verfügt über einen tollen Filter-Mechanismus für jede Spalte. Die Spalte für die Labels erhält einen individuellen 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"]} 
/>

Die Detail-View der Anwendung zeigt eine Aufgabe in einer AutoForm-Komponente, so dass eine Aufgabe erstellt und bearbeitet werden kann. Auch hier kann das Feld für die Labels individuell konfiguriert werden. Im vorliegenden Beispiel bietet es sich an, die Label über eine Multi-Select Combo Box-Komponente darzustellen. Diese Komponente ermöglicht die Auswahl mehrerer Labels, inkl. einer hilfreichen Filtermöglichkeit.

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

Alle verfügbaren Label werden über den LabelService geladen und stehen in der Multi-Select Combo Box zur Auswahl zur Verfügung.

Fazit

Die Kombination aus Spring Boot und Spring Data JPA im Backend und der UI-Komponenten und der Code-Generierung von Hilla im Frontend ermöglichen eine sehr produktive und gleichzeitig sehr flexible Entwicklung von Geschäftsanwendungen. Auch der Umgang mit n:m-Beziehungen zwischen Entitäten fällt dabei leicht.