CodeGym /Kurse /Spring Data JPA /Ergebnis einer nativen Abfrage: Skalar und Projektionen

Ergebnis einer nativen Abfrage: Skalar und Projektionen

Spring Data JPA
Level 13, Lektion 2
Verfügbar

1. Ergebnis einer Abfrage: die halbe Miete

Wenn Sie zum ersten Mal natives SQL in einem Repository schreiben, scheint das Schwierigste bereits getan: „Na gut, ich habe select geschrieben, es hat Zeilen zurückgegeben, Sieg.“ In der Praxis beginnt die Hälfte des Schmerzes genau danach: wie man diese Zeilen in ein normales Java-Ergebnis verwandelt, das lesbar, testbar und wartbar ist, ohne Schamanentrommel und Index-Magie im Array.

SQL ist bereits in der Sprache der Tabellen und Spalten geschrieben. Aber bis zu einer funktionierenden Repository-Method fehlt noch eine Entscheidung: Welche Form soll das Ergebnis haben, damit der Code nicht in Object[], Casts und stille Index-Fehler abrutscht.

In der Welt von Spring Data JPA gibt es die Versuchung, immer Entitäten zurückzugeben – denn „wir sind doch JPA, wir haben Entitäten“. Aber berichtende Lesevorgänge (und native Abfragen verwenden wir heute genau für Berichte) haben oft gar nichts mit Entitäten zu tun. Sie gehen um „gib mir 4 Spalten aus drei Tabellen, sortiere sie und gib sie als Liste von Berichtszeilen zurück“. Deshalb stellen wir heute eine einfache Regel auf: zuerst die Ergebnisform entwerfen, und erst dann das SQL darauf schreiben.

Um das Denken zu erleichtern, drehen wir uns um unseren durchgängigen Use Case: Low-Stock-Report – eine Liste von Artikeln mit geringem verfügbarem Bestand (stock_item.available_quantity <= threshold). Dies ist ein typischer Berichts-Lesevorgang: Wir laden kein Domänenmodell, sondern sammeln eine Tabelle für den Manager.

2. Skalarergebnis: wenn die Abfrage einen einzelnen Wert zurückgibt

Das Wort skalar klingt ein bisschen wie ein Zauberspruch aus „Harry Potter“, aber seine Bedeutung ist sehr alltäglich: die Abfrage gibt einen einzigen Wert zurück, eine einzelne Zelle. Zum Beispiel count, min, max, sum, manchmal exists. Für solche Fragen zu versuchen, eine Entität zurückzugeben, ist ungefähr so, als würde man mit einem Panzer zum Bäcker fahren: technisch möglich, aber die Nachbarn schließen nervös die Fenster.

Im Low-Stock-Kontext tauchen Skalar-Fragen ständig auf. „Wie viele Positionen im Lager liegen bereits unter dem Schwellenwert?“, „Was ist der niedrigste verfügbare Bestand überhaupt?“, „Gibt es überhaupt einen Artikel mit Bestand 0?“. Und hier ist eine native Abfrage besonders angebracht: SQL beantwortet solche Fragen sehr direkt.

count(*) → long: die ehrlichste Variante

Beginnen wir mit dem Banalen und Nützlichen: wie viele Zeilen in stock_item haben available_quantity <= threshold.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface StockItemRepository extends JpaRepository<StockItem, Long> {

    @Query(
        value = """
                select count(*)
                from stock_item s
                where s.available_quantity <= ?1
                """,
        nativeQuery = true
    )
    long countLowStockItems(int threshold); // Wir geben long zurück, weil count(*) in PostgreSQL bigint ist
}

Beachten Sie ein paar Details. count(*) in PostgreSQL gibt bigint zurück, therefore in Java ist fast immer der angemessenste Typ long. Man könnte int zurückgeben, aber das ist wie die „Anzahl aller Bestellungen im Jahr“ in einem Joghurtbecher zu speichern: Irgendwann läuft es über, und das wird unangenehm.

Wenn Sie dieses Szenario mit einem Service ergänzen möchten (damit sichtbar wird, was zurückgegeben wird), würde er maximal langweilig aussehen – und langweiliger Code im Data-Layer bedeutet oft „gut lesbar“.

import org.springframework.stereotype.Service;

@Service
public class InventoryStatsService {
    private final StockItemRepository stockItemRepository;

    public InventoryStatsService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    public void printLowStockCount(int threshold) {
        // Hier laden wir keine Entitäten – wir bekommen eine einzelne Ergebniszelle (Skalar)
        long count = stockItemRepository.countLowStockItems(threshold);

        // Beispielverwendung: im echten Code eher Logging/Metriken, aber zur Demo – println
        System.out.println("Low-stock items = " + count); // Low-stock items = 7
    }
}

Langweilig? Ja. Zuverlässig? Auch ja.

min(...), max(...) und null: sorgfältig den Typ wählen

Wenn Sie eine Aggregation auf einer leeren Tabelle durchführen, kann SQL null zurückgeben. Zum Beispiel min(s.available_quantity) auf leerem stock_item – null. Und dabei taucht eine ziemlich unangenehme Kleinigkeit auf: der primitive int kann nicht null sein.

Daher ist es für potenziell nullable Aggregate besser, entweder Integer zu verwenden oder per SQL den Wert nicht-null zu erzwingen (z.B. coalesce).

import org.springframework.data.jpa.repository.Query;

public interface StockItemRepository extends JpaRepository<StockItem, Long> {

    @Query(value = "select min(s.available_quantity) from stock_item s", nativeQuery = true)
    Integer findMinAvailableQuantity(); // Integer, weil min(...) auf leerer Menge null zurückgeben kann
}

Wenn Sie definitiv immer eine Zahl möchten (z.B. „Minimum, aber wenn keine Daten vorhanden, nehmen wir 0“), können Sie das direkt in SQL erledigen.

import org.springframework.data.jpa.repository.Query;

public interface StockItemRepository extends JpaRepository<StockItem, Long> {

    @Query(value = "select coalesce(min(s.available_quantity), 0) from stock_item s",
           nativeQuery = true)
    int findMinAvailableQuantityOrZero(); // coalesce garantiert nicht-null, therefore ist der primitive Typ hier sicher
}

exists(...) → boolean: schnelles „Ja/Nein“ ohne Daten zu laden

Manchmal brauchen Sie keine Liste, sondern die Antwort „vorhanden/nicht vorhanden“. Zum Beispiel: „Gibt es Artikel, die bereits ausverkauft sind?“

In PostgreSQL kann das sehr sauber formuliert werden:

import org.springframework.data.jpa.repository.Query;

public interface StockItemRepository extends JpaRepository<StockItem, Long> {

    @Query(
        value = """
                select exists(
                    select 1
                    from stock_item s
                    where s.available_quantity = 0
                )
                """,
        nativeQuery = true
    )
    boolean existsOutOfStockItems(); // Wir übertragen keine Zeilen – nur die boolesche Antwort der Datenbank
}

Das Angenehme daran ist: Sie laden keine einzige Zeile. Die Datenbank antwortet einfach: „Ja, solche Zeilen gibt es“ oder „Nein, sehe ich nicht“.

3. Mehrere Spalten: Object[] vs. „Berichtszeile“

Sobald Sie von „zähle“ zu „zeige Zeilen“ übergehen, hat die Abfrage fast immer mehrere Spalten: SKU des Artikels, Name, Bestand, Kategorie, noch etwas. Und hier tappt der Neuling oft in eine Falle: „Ach, ich gebe einfach List<Object[]> zurück und kümmere mich später darum.“

Das sieht nach einem schnellen Hack aus, aber in der Praxis verwandelt sich Object[] sehr schnell in eine stille Mine. Solange Sie sich merken, dass [0] die sku ist, [1] die available_quantity, ist alles in Ordnung. Nach einer Woche fügen Sie in der Mitte eine Spalte hinzu, und der gesamte Code wird ein bisschen falsch … und kompiliert sehr selbstbewusst. Der Compiler kann nicht warnen: „Sie haben anscheinend den Index 1 und 2 verwechselt, viel Glück.“

So sieht die „rohe“ Variante aus:

import org.springframework.data.jpa.repository.Query;

public interface InventoryReportRepository extends JpaRepository<StockItem, Long> {

    @Query(
        value = """
                select p.sku, s.available_quantity
                from stock_item s
                    join product p on p.id = s.product_id
                where s.available_quantity <= ?1
                """,
        nativeQuery = true
    )
    java.util.List<Object[]> findLowStockRaw(int threshold); // Object[] – eine „Berichtszeile ohne Namen“
}

Und so sieht die Verwendung später aus (und ja, das tut weh):

java.util.List<Object[]> rows = repository.findLowStockRaw(3);

// Wichtig: alles basiert auf einer „Vereinbarung über Indizes“
Object[] first = rows.get(0);

// [0] – sku (String), weil p.sku zuerst ausgewählt wurde
String sku = (String) first[0];

// [1] – available_quantity (Zahl), aber JDBC kann verschiedene Number-Typen liefern, daher vorsichtig casten
int qty = ((Number) first[1]).intValue();

System.out.println(sku + " -> " + qty); // SKU-001 -> 2

Jetzt „funktioniert“ alles. Aber wenn Sie zum Beispiel die Spalte p.name hinzufügen – wird plötzlich first[1] nicht mehr die Menge sein, sondern der Name. Und Sie werden „SKU-001 -> USB Cable“ ausgeben, und die Welt verliert für einen Moment ihren Sinn.

Genau hier kommt das richtige Modell ins Spiel: Berichtszeile. Das ist keine Entität, kein DTO für REST (wir sind gerade nicht im Web-Layer), sondern einfach eine verständliche Struktur: „eine Zeile des Berichts“.

Und die einfachste Form einer solchen Struktur in Spring Data ist die interfacebasierte Projektion.

4. Interfacebasierte Projektion für natives SQL

Eine Interface-Projektion ist im Grunde ein Vertrag: „Ich erwarte, dass die Abfrage Spalten zurückgibt, die mit solchen Gettern gelesen werden können.“ Das ist besonders praktisch für Berichtslesevorgänge: Sie tun nicht so, als wäre es eine Domänenentität, aber Sie erhalten ein benanntes Ergebnis, kein „Array von irgendwas“.

Für den Low-Stock-Bericht beginnen wir mit einer minimalen Zeile: SKU und verfügbare Menge.

Für diesen Abschnitt reicht uns eine minimale Berichtszeile – SKU und Bestand. Es ist nicht die alternative finale Version des Low-Stock-Reports, sondern ein reduzierter Ausschnitt, an dem die Alias-Disziplin einfacher ruhig betrachtet werden kann.

Projektion deklarieren (1 Berichtszeile)

public interface LowStockRow {
    // Die Getter-Namen geben die erwarteten „Feldnamen“ im Ergebnis an (über Alias)
    String getSku();

    int getAvailableQuantity();
}

Keine Annotationen, keine Klassen, keine Mapper. Nur ein Interface.

Und eine kleine, aber wichtige Nuance: int ist hier passend, weil available_quantity vom Sinn her nicht null sein sollte. Wenn Ihre Abfrage einen left join macht und die Spalte null werden kann, dann besser Integer. Wir sind nicht paranoid, aber „nullable Spalte in int“ ist fast ein garantierter Weg, sich einen Abend mit NullPointerException zu bescheren.

Native Abfrage schreiben und List<LowStockRow> zurückgeben

Jetzt das Wichtigste: In nativem SQL geben wir Spalten zurück, und Spring Data muss verstehen, wie diese Spalten mit den Gettern des Interfaces verknüpft werden. Die Verknüpfung erfolgt über Aliase.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface InventoryReportRepository extends JpaRepository<StockItem, Long> {

    @Query(
        value = """
                select
                    p.sku as sku,
                    s.available_quantity as availableQuantity
                from stock_item s
                    join product p on p.id = s.product_id
                where s.available_quantity <= ?1
                """,
        nativeQuery = true
    )
    java.util.List<LowStockRow> findLowStock(int threshold); // Wir geben eine „benannte“ Berichtszeile zurück
}

Beachten Sie die zwei as:

- p.sku as sku – stimmt mit getSku() überein → Eigenschaft sku
- s.available_quantity as availableQuantity – stimmt mit getAvailableQuantity() überein → Eigenschaft availableQuantity

Wir sagen also explizit: „Spalte available_quantity wird in diesem Bericht availableQuantity heißen“, damit die Java-Seite nicht raten muss.

Verwendung des Ergebnisses: lesbar und ohne Index-Magie

java.util.List<LowStockRow> rows = repository.findLowStock(3);

for (LowStockRow row : rows) {
    // Jetzt erfolgt der Zugriff über Namen, nicht über Indizes und Casts
    System.out.println(row.getSku() + " -> " + row.getAvailableQuantity());
    // SKU-001 -> 2
}

Ein solcher LowStockRow lässt sich bequem als reduzierte Berichtszeile auffassen. Wenn Sie den Artikelnamen oder Kategoriecode hinzufügen, ändert sich das Prinzip nicht: Sie verknüpfen weiterhin SQL-Aliase mit den Gettern der Projektion.

Um es ganz transparent zu machen, stellen Sie sich die Pipeline so vor:

flowchart TD
    SQL["native SQL
select ... as sku, ... as availableQuantity"] RS["ResultSet
sku | availableQuantity"] MAP["Spring Data mapping
anhand von Alias-Namen"] PROJ["LowStockRow
getSku(), getAvailableQuantity()"] SQL --> RS --> MAP --> PROJ

Der Clou der Interface-Projektion ist, dass Sie aufhören zu denken „ich bekomme Zeilen“, und anfangen zu denken „ich bekomme Zeilen einer bestimmten Form“.

5. Alias-Disziplin: Vertrag der Spaltennamen

Wenn Sie JPQL schreiben, denken Sie kaum über Spalten-Aliase nach: dort ist die Welt der Entitätsfelder, und Spring Data/Hibernate erledigen vieles für Sie. In nativem SQL stellen Sie plötzlich fest, dass Sie wieder im „echten SQL“ sind, und Sie selbst sind für die Namen der Ergebnisspalten verantwortlich.

Um dieses Thema nicht zur Mystik werden zu lassen, legen wir eine konkrete Regel fest.

Wenn Sie einen Getter getAvailableQuantity() haben, interpretiert Spring Data dies als Zugriff auf die Eigenschaft availableQuantity. Und es erwartet, dass es im Abfrageergebnis eine Spalte gibt, die als availableQuantity identifizierbar ist.

Das Problem ist, dass die Spalte in der Datenbank available_quantity heißt. Wenn Sie also keinen Alias vergeben, wird die Verknüpfung entweder nicht offensichtlich oder bricht sogar.

Schlechte Variante: „Ich hoffe, es errät es selbst“

public interface LowStockRow {
    String getSku();
    int getAvailableQuantity();
}

@Query(
    value = "select p.sku, s.available_quantity " +
            "from stock_item s join product p on p.id = s.product_id " +
            "where s.available_quantity <= ?1",
    nativeQuery = true
)
java.util.List<LowStockRow> broken(int threshold); // Hohes Risiko für ein fragiles/nicht offensichtliches Mapping

Manchmal „funktioniert“ dieser Code, insbesondere bei einfachen Abfragen und in bestimmten Spring-Data-Versionen, die Fallback-Mapping versuchen (z.B. camelCase ↔ snake_case). Aber das Problem ist, dass er fragil und nicht offensichtlich wird. Ein bisschen die Abfrage erschwert, die Spaltenreihenfolge geändert, noch ein available_quantity aus einer anderen Tabelle hinzugefügt – und seltsame Dinge beginnen.

Gute Variante: Alias als „Spaltenname für die Java-Welt“

@Query(
    value = "select p.sku as sku, " +
            "s.available_quantity as availableQuantity " +
            "from stock_item s join product p on p.id = s.product_id " +
            "where s.available_quantity <= ?1",
    nativeQuery = true
)
java.util.List<LowStockRow> ok(int threshold); // Alias-Namen = Vertrag für die Projektion

Jetzt haben Sie einen klaren Vertrag: SQL spricht in der Sprache der Tabellen, aber das Ergebnis wird in die Sprache der Java-Eigenschaften umbenannt.

Minimale Tabelle: wie Alias-Namen normalerweise gemappt werden

SQL-Spalte (im Schema) Alias in der Abfrage Getter in der Projektion
sku sku getSku()
available_quantity availableQuantity getAvailableQuantity()
code categoryCode getCategoryCode()

Und ja, Sie könnten den Alias in snake_case und den Getter in camelCase setzen – aber dann laden Sie wieder das Projekt ein mit „warum wurde es nicht gemappt“ und „ich habe null, aber in der Datenbank ist nicht null“. Besser nicht.

Eine weitere Nuance: identische Spaltennamen aus verschiedenen Tabellen.

Stellen Sie sich einen Join von product und category vor. Beide Tabellen können ein Feld name haben. Wenn Sie schreiben:

-- Schlecht: zwei Spalten mit demselben Ergebnissnamen
select p.name, c.name

dann gibt es im Ergebnis zwei Spalten mit dem gleichen „Namen“. JDBC und Mapping werden traurig sein. Alias ist hier keine „Option“, sondern eine Notwendigkeit: p.name as productName, c.name as categoryName. Das ist besonders wichtig für Berichte (also fast alle berechtigten nativen Abfragen).

6. Wahl der Ergebnisform: Skalar vs. Projektion vs. Entität

Wenn die Form der Zeile klar ist, kann man separat entscheiden, ob nur eine Liste oder auch Seiten-Metadaten benötigt werden. Aber zuerst ist es sinnvoll, die Ergebnistypen einmal auseinanderzuhalten.

Jetzt fassen wir alles in einem einfachen Bild zusammen. Stellen Sie sich vor, Sie stehen vor einem Repository und denken: „Was gebe ich zurück?“

Ergebnisform Wann verwenden Beispiel-Signatur Grundidee
Skalar (long, int, boolean, BigDecimal, String) „Wie viele?“, „Gibt es?“, „Was ist das Minimum/Maximum?“ long countLowStockItems(int t) Eine Zelle – kein Objektgraph nötig
Interfacebasierte Projektion Bericht/Tabelle: mehrere Spalten, aber keine Entität List<LowStockRow> findLowStock(int t) „Eine Berichtszeile“ als Vertrag
Entität (Product, StockItem) Wenn Sie wirklich die Entität als Objektmodell möchten List<Product> findByCategoryCodeNative(...) Dann muss SQL entitätsförmig sein (select p.*)

Und hier kommt der wichtige „Anti-Tipp“ des Tages: Geben Sie keine Entität aus einer berichtenden nativen Abfrage zurück, nur weil „es gewohnter ist“. Wenn Sie 4 Spalten brauchen, geben Sie 4 Spalten zurück. Eine Entität ist ein schwereres Ding, sowohl vom Sinn als auch vom Verhalten her.

7. Typische Fehler beim Arbeiten mit dem Ergebnis einer nativen Abfrage

Fehler Nr. 1: Object[] zurückgeben und in einer Welt der Indizes leben.
Anfangs erscheint das wie ein schneller Weg, „einfach Daten zu bekommen“, besonders wenn Sie es eilig haben. Aber sehr schnell wird Object[] zu einem wartbaren Albtraum: jedes [0], [1], Casts zu Number, implizite Vereinbarungen „was Index 2 bedeutet“ und stille Fehler beim Hinzufügen von Spalten. Wenn der Spaltensatz stabil ist, ist es besser, sofort eine interfacebasierte Projektion zu erstellen und das Ergebnis benannt zu machen.

Fehler Nr. 2: Alias vergessen und hoffen, dass Spring „irgendwie“ available_quantity und availableQuantity zuordnet.
Manchmal scheint es, als hätte das Framework es erraten. Aber dann fügen Sie eine zweite Spalte mit ähnlichem Namen hinzu, machen einen left join, ändern die Spaltenreihenfolge – und plötzlich erhalten Sie null an unerwarteter Stelle. Alias in nativen Abfragen ist keine Kosmetik, sondern ein Teil des Vertrags zwischen SQL und der Java-Seite.

Fehler Nr. 3: Primitive Typen (int, long) dort verwenden, wo SQL null zurückgeben kann.
Mit count(*) sind Sie auf der sicheren Seite, aber min, max, sum auf einer leeren Zeilenmenge liefern oft null. Wenn Sie int zurückgeben, wird „null“ zu einer Exception (und normalerweise passiert das nicht dort, wo Sie es erwarten). Verwenden Sie an solchen Stellen entweder Wrapper-Typen (Integer, Long) oder lösen Sie das Problem per SQL mit coalesce.

Fehler Nr. 4: Versuchen, aus einer partiellen Select-Abfrage eine Entität zurückzugeben.
Wenn eine Repository-Methode Product zurückgibt, möchte man select p.sku, p.name from product p ... schreiben und erwarten, dass JPA „Product zusammenbaut“. Aber eine Entität ist kein „beliebiges Feldset“, sondern ein vollwertiges Persistenzmodell. Wenn Sie eine Entität zurückgeben, muss die Abfrage entitätsförmig sein (normalerweise select p.*). Für einen Bericht ist das fast immer überflüssig, also wählen Sie eine Projektion.

Fehler Nr. 5: In einer Projektion Spalten mit gleichem Namen ohne Alias mischen.
name des Artikels, name der Kategorie, code der Kategorie und code des Tags (falls Sie einen Bonus gemacht haben) – all das kann in einem Join-Bericht vorkommen. Wenn Sie keinen Alias gesetzt haben, wird das Ergebnis mehrdeutig und das Mapping unvorhersehbar. In berichtendem SQL ist es besser, etwas weitschweifiger zu sein, als später zwei Stunden zu suchen, „warum es das falsche Feld ist“.

1
Aufgabe
Spring Data JPA, Level 13, Lektion 2
Gesperrt
Minimaler Bestand als Skalarergebnis
Minimaler Bestand als Skalarergebnis
1
Aufgabe
Spring Data JPA, Level 13, Lektion 2
Gesperrt
Low-stock Zeilen via interface-based projection
Low-stock Zeilen via interface-based projection
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION