Mehr als eine Abfragesprache

SQL im 21. Jahrhundert
Keine Kommentare

Die Einführung neuer QL-Sprachen erfreut sich momentan großer Beliebtheit. Erst im Sommer machte Amazon mit der Einführung von PartiQL einen neuen Vorstoß in diese Richtung. Bei diesem QL-Wildwuchs darf man das Original aber nicht aus den Augen verlieren. Was wurde eigentlich aus SQL? Steckt es tatsächlich noch in den 90ern fest? Natürlich nicht. Ein Update für Entwickler.

Das letzte Jahrzehnt war von neuen Datenbankkonzepten rund um das Thema NoSQL geprägt. Anfangs diente SQL dabei als Antithese, später wurde NoSQL als Backronym für „Not only SQL“ definiert. Zuletzt gab es dann eine regelrechte Explosion neuer QL-Sprachen. Viele davon erinnern nicht nur dem Namen nach, sondern auch der Syntax nach an SQL.

Das Versprechen dieser QL-Sprachen ist einfach: Die bekannte Grundstruktur erleichtert den Start, die punktuellen Erweiterungen liefern nach, was bei SQL fehlt. Dieses Versprechen hält einer genauen Prüfung aber nicht immer Stand. Denn zwischen dem SQL-92-Funktionsumfang, den viele aus Studium, Kursen oder Büchern kennen, und modernem SQL liegen Welten. So wurde das relationale Korsett von SQL zum Beispiel schon vor 20 Jahren aufgegeben.

Um diese und andere Überraschungen geht es in diesem Artikel. Um den Unterschied zwischen SQL und anderen Datenzugriffsmethoden zu verdeutlichen, teile ich den Funktionsumfang von SQL in vier Niveaustufen ein. Erst auf der letzten Stufe wird es um modernes SQL gehen. Die ersten drei Stufen handeln von älteren SQL-Funktionen, deren Nutzen im NoSQL-Jahrzehnt vielfach in Vergessenheit geraten ist – daher eine kurze Einführung.

Stufe 0: CRUD

Am Anfang jedes Datenzugriffs stehen die vier Grundoperationen Create, Read, Update und Delete – zusammen kurz CRUD. SQL stellt diese Grundoperationen über die Anweisungen Insert, Select, Update und Delete zur Verfügung. Das heißt aber nicht, dass diese Anweisungen jeweils nur eine dieser Grundoperationen umsetzen.

Der Unterschied zwischen reinem CRUD-Denken und SQL wird beim Inkrementieren eines Zählers deutlich. Im CRUD-Denken liest man dafür zuerst den aktuellen Zählerstand ein (Select), erhöht ihn dann in der Applikation, um den neuen Wert schließlich zu speichern (Update).

Denselben Effekt kann man in SQL aber auch mit einer einzelnen Update-Anweisung erreichen:

UPDATE Tabelle
  SET Spalte = Spalte + 1
WHERE ID = ?

Der Lesezugriff steckt zusammen mit der Inkrementierung im rechten Argument der Zuweisung. Dort kann man beliebig komplexe Ausdrücke verwenden – von einfachen Formeln über bedingte Ausdrücke (Case) bis hin zu Unterabfragen.

Auch Delete kann mehr als nur bestimmte Zeilen löschen. Da die Where-Klausel nicht auf Schlüsselwerte beschränkt ist, kann eine Delete-Anweisung selbst herausfinden, welche Zeilen zu löschen sind. Im folgenden Beispiel sind das jene Zeilen der Quelltabelle, deren ID in der Zieltabelle vorhanden ist:

DELETE FROM Quelltabelle
WHERE EXISTS (SELECT *
      FROM Zieltabelle
      WHERE Zieltabelle.ID = Quelltabelle.ID
             )

Nicht einmal die Insert-Anweisung ist auf das bloße Anlegen neuer Zeilen beschränkt. Durch Kombination mit Select können Daten zum Beispiel kopiert und transformiert werden:

INSERT INTO Zieltabelle
SELECT *
  FROM Quelltabelle

Verwendet man die Anweisungen Insert, Select, Update und Delete nur, um den CRUD-Zyklus über einen Schlüssel abzubilden, verwendet man zwar die Syntax von SQL, nicht aber die SQL-Philosophie. Daher habe ich diesem Muster das Niveau 0 zugewiesen – es sieht zwar aus wie SQL, ist aber eigentlich nicht SQL.

Oft entsteht das CRUD-Denken durch die Reduktion einer SQL-Datenbank auf die bloße Persistenz. Der Name JPA leistet hier einen traurigen Beitrag. Die von ORM-Tools generierten SQL-Anweisungen fallen dann auch großteils in diese Kategorie.

Stufe 1: Transaktionen

Die korrekte Verwendung von Transaktionen – selbst wenn ansonsten nur CRUD-Operationen verwendet werden – begründet bereits das nächste Niveau. Da Transaktionen gut in JPA integriert sind, kann man dieses Niveau noch leicht erreichen.

Der Grund, warum dieses Niveau dennoch nicht immer erreicht wird, liegt darin, dass der volle Funktionsumfang von Transaktionen nicht allgemein bekannt ist. Oft werden Transaktionen nur für die Alles-oder-Nichts-Logik beim Schreiben genutzt. Transaktionen ermöglichen aber auch konsistente Lesezugriffe und können vor Problemen durch Nebenläufigkeit schützen.

Zur Illustration kann wieder das Beispiel mit dem Zähler herangezogen werden. Bei der CRUD-Umsetzung werden zwei SQL-Anweisungen verwendet (Listing 1). Dabei stellt sich die Frage, was schiefgehen kann, wenn dieser Code mehrfach parallel abläuft.

SELECT Zaehler
  FROM Tabelle
 WHERE ID = ?;

-- Erhöhung des Werts
-- in der Applikation

UPDATE Tabelle
   SET Zaehler = ?
 WHERE ID = ?;

Ohne weitere Vorsichtsmaßnahmen kann es zu dem als Lost-Update bekannten Problem kommen. Wenn zwei Threads den Lesezugriff (Select) gleichzeitig durchführen, werden beide denselben Zählerwert erhalten. In weiterer Folge werden beide denselben (erhöhten) Wert in die Datenbank schreiben. Letztendlich wurde der Zähler in der Datenbank also nur einmal erhöht – ein Update ging scheinbar verloren.

DevOps Docker Camp

Sie lernen die Konzepte von Docker und bauen Schritt für Schritt eine eigene Infrastruktur für und mit Docker auf.


Zur Lösung dieses und ähnlicher Probleme bietet SQL die sogenannte Transaktionsisolation an. Damit kann der Code so ablaufen, als gäbe es keine anderen Aktivitäten in der Datenbank. Voraussetzung ist lediglich das richtige Setzen der Transaktionsgrenzen und die Verwendung des richtigen Transaktionsisolationslevels.

Die Ermittlung des richtigen Transaktionsisolationslevels ist eine Wissenschaft für sich – unter anderem, weil die angebotenen Level sowie die damit verbundenen Vor- und Nachteile, produktspezifisch sind. Grundsätzlich kann man aber immer mit dem stärksten Level – Serializeable – anfangen und dann punktuell dort nachbessern, wo es nötig ist. Daher sieht der SQL-Standard Serializeable auch als Voreinstellung vor. Tatsächlich verwenden aber alle gängigen SQL-Datenbanken eine schwächere Voreinstellung – vermutlich, um in der Standardkonfiguration bessere Performance zu erreichen.

Bei einer Transaktion im Level Serializeable stellt die Datenbank sicher, dass sie nur dann erfolgreich abgeschlossen werden kann (Commit), wenn Änderungen, die andere Transaktionen währenddessen durchgeführt haben, zu keinem Ergebnis führen, das bei isolierter Ausführung unmöglich gewesen wäre. Im Beispiel von Listing 1 heißt das, dass das Commit nur dann erfolgreich sein darf, wenn die gelesenen Daten (Select) bis zum Schreiben (Update) nicht geändert wurden. Dafür muss die Datenbank natürlich wissen, dass der Lesezugriff zur Transaktion gehört – die Transaktion muss also vor dem Select beginnen und darf erst nach dem Update enden.

Das von JPA angebotene pessimistische Locking verfolgt dasselbe Ziel wie die Transaktionsisolation. Pessimistisches Locking verhindert, dass die gelesenen Daten während der Transaktion verändert werden. Die dafür verwendeten expliziten Locks gehen aber mit größeren Performanceeinbußen als die Transaktionsisolation einher. Pessimistisches Locking ist daher nur dann sinnvoll, wenn die verwendete Datenbank den Transaktionsisolationslevel Serializeable nicht anbietet oder nicht richtig umsetzt (wie die Oracle-Datenbank).

Da sowohl Transaktionsisolation als auch pessimistisches Locking auf Transaktionen aufbauen, kann man damit nur Abläufe schützen, die man sinnvoll in eine Transaktion zusammenfassen kann. Das schließt insbesondere Vorgänge aus, die auf Benutzereingaben warten. Diese Lücke schließt das optimistische Locking, mit dem man die Konsistenz bei Nebenläufigkeit auch über Transaktionen hinweg sicherstellen kann. Da der Verwaltungsaufwand hierbei noch größer ist – u. a. durch eine Versionsspalte – sollte das optimistische Locking die letzte Wahl sein.

In der Praxis ist oft das Gegenteil der Fall. Da das optimistische Locking völlig ohne Verständnis von Transaktionen und Transaktionsisolation funktioniert, ist es oft die erste Wahl. Das ist ein weiteres Symptom der Reduktion einer SQL-Datenbank auf die Persistenz.

Stufe 2: SQL-92

Niveaustufe 2 ist dadurch gekennzeichnet, dass die aus dem alten SQL-92-Standard bekannte Funktionalität sinnvoll genutzt wird. An dieser Stelle wird es mit der Toolunterstützung schwierig – viele Funktionen stehen nur noch über QL-Sprachen zur Verfügung (JPQL, HQL, SQL). Da diese Sprachen in ORM-fokussierten Architekturen oft verpönt, manchmal sogar verboten sind, trifft man dieses Niveau nur noch selten an.

Das bekannteste Symptom, das darauf hindeutet, dass Niveau 2 nicht erreicht wird, ist das N+1-Selects-Problem. Dieses Problem tritt auf, wenn für jede Zeile eines Ergebnisses eine neue Abfrage abgesetzt wird – in Summe dann also N+1 Abfragen, wenn die erste Abfrage N Zeilen liefert. Dieses Problem führt stets zu schlechter Performance – unter anderem wegen der Anhäufung von Latenzen. Aus SQL-Sicht sollte man daher alle für einen Verarbeitungsschritt benötigten Daten auf einmal beschaffen. Dafür muss man aber schon bei der ersten Abfrage wissen, welche Abfragen dann für jede Zeile ausgeführt werden. Diese Information ist in objektorientiertem Java-Code aber nicht so ohne Weiteres ersichtlich. Die von JPA dafür angebotenen Features Eager Fetching oder @NamedEntityGraph helfen zwar, haben aber auch ihre Grenzen.

Diese Grenzen werden bei Schreiboperationen noch deutlicher. So kann keines der Insert-, Update– und Delete-Beispiele nur mit O/R Mapping sinnvoll umgesetzt werden. Greift man auf JPQL zurück, gibt es zwar für die Update– und Delete-Beispiele eine Entsprechung, nicht aber für das Insert-Beispiel – dafür bleibt dann nur HQL oder eben SQL.

Stufe 3: SQL aus dem 21. Jahrhundert

Das vierte und letzte SQL-Niveau ist SQL. Eigentlich sollte damit klar sein, dass aktuelles SQL gemeint ist. Oft wird mit dem Begriff SQL aber nur der Funktionsumfang von SQL-92 assoziiert. Daher muss ich etwas deutlicher werden. Gemeint ist das „Post-SQL-92“-SQL. Anders gesagt, alles, was ab dem SQL:1999-Standard eingeführt wurde. Ich verwende dafür auch die Begriffe „modernes SQL“ oder eben „SQL aus dem 21. Jahrhundert“. Diese Abgrenzung zur vorherigen Stufe mag willkürlich erscheinen, es gibt jedoch mehrere Gründe, die für diese Trennung sprechen:

  • Mit dem Standard von 1999 endete die rein relationale Ära von SQL.
  • Die neuen SQL-Funktionen sind nicht allgemein bekannt.
  • Sprachen wie JPQL oder HQL unterstützen diese Funktionen nicht.

Im Folgenden stelle ich einige dieser neuen SQL-Funktionen anhand typischer Anwendungsfälle vor.

Rekursive Abfragen für Graphen

Eine der Einschränkungen von SQL-92 betraf das Bearbeiten von Graphen im Sinne der Graphentheorie. So war es mit SQL-92 zum Beispiel nicht möglich, den kürzesten Pfad zwischen zwei Knoten in einem Graphen zu ermitteln. Diese Lücke wurde mit rekursiven Abfragen geschlossen.

Der Einfachheit halber möchte ich die Idee der rekursiven Abfragen am folgenden Beispiel einer Hierarchie erklären:

CREATE TABLE Hierarchie (
  ID       INTEGER NOT NULL PRIMARY KEY,
  ParentID INTEGER,
  …
)

Dafür sollen von einem bekannten Knoten ausgehend alle darunterliegenden Knoten abgerufen werden. Das könnte zum Beispiel die Mitarbeiterliste einer Organisationseinheit oder die Stückliste einer Baugruppe sein. Mit dem Union-All-Operator hatte SQL-92 nur eine Teillösung geliefert:

SELECT *
  FROM Hierarchie
WHERE ID = 42
UNION ALL
SELECT *
  FROM Hierarchie
WHERE ParentID = 42

Die Abfrage liefert sowohl den Ausgangsknoten (ID=42) als auch alle Knoten, deren ParentID denselben Wert haben – das sind alle, die hierarchisch unmittelbar darunter liegen. Um auch die dritte Ebene auszugeben, könnte man zwar grundsätzlich einen weiteren Union-All-Zweig verwenden, nur ist der Suchwert in der Where-Klausel dann nicht mehr trivial. Schließlich braucht man dort die ID-Werte der zweiten Ebene.

Dieses Problem löst man heutzutage mit einer rekursiven Abfrage. Dadurch kann die Where-Klausel indirekt auf das eigene Ergebnis – und damit auf die ID-Werte der vorherigen Ebene – zugreifen. Die Syntax dafür ist erstaunlich kurz. Listing 2 zeigt die vollständige Abfrage, die nun für beliebig tiefe Hierarchien funktioniert. Im Kern steht nach wie vor die Union-All-Abfrage. Die wesentliche Änderung ist, dass der zweite Zweig nicht mehr fest kodiert nach ParentID = 42 sucht, sondern nach dem Wert der Spalte ID in der Tabelle cte (Listing 2: cte.ID). Die Tabelle cte ist jedoch das Ergebnis der Abfrage selbst. Das ergibt sich daraus, dass die Abfrage in eine With-Klausel gekapselt ist, und dem Ergebnis der Abfrage somit der Name cte zugewiesen wird (Listing 2: WITH RECURSIVE cte).

WITH RECURISVE cte (ID, ParentID)
AS (

SELECT *
  FROM Hierarchie
 WHERE ID = 42
UNION ALL
SELECT *
  FROM Hierarchie h
  JOIN cte ON h.ParentID = cte.ID

)
SELECT *
  FROM cte

Zur Laufzeit beginnt die Datenbank mit dem ersten Union-All-Zweig. Das Ergebnis wird unmittelbar Teil der Tabelle cte und ist damit überall dort sichtbar, wo auf diese Tabelle zugegriffen wird. Das passiert zum einen in der Hauptabfrage ganz am Ende des Listings, aber eben auch im zweiten Union-All-Zweig. Zu diesem Zeitpunkt steht die Referenz cte.ID nur für eine einzelne Zeile mit dem ID-Wert 42. Der zweite Union-All-Zweig liefert daraufhin alle Zeilen der zweiten Ebene – wie beim SQL-92 Beispiel zuvor. Diese Zeilen gehören nun aber auch zur Tabelle cte, sodass die Referenz cte.ID de facto für alle ID-Werte der ersten beiden Ebenen steht. Damit das Ergebnis des zweiten Union-All-Zweigs wieder stimmt, muss die Datenbank also noch die Zeilen der dritten Ebene hinzufügen. Das Ganze geht so lange weiter, bis keine neuen Zeilen mehr gefunden werden.

Rekursionen sind auf den ersten Blick oft verwirrend – nicht nur in SQL. Das sollte aber niemanden davon abhalten, sich näher damit zu beschäftigten.

Abb. 1: Verfügbarkeit von rekursiven Abfragen

Abb. 1: Verfügbarkeit von rekursiven Abfragen

Window-Funktionen gegen Self-Joins

Eine andere Einschränkung von SQL-92 war, dass die Verwendung von Aggregatfunktionen (sum, count, …) an die Zusammenfassung von Zeilen im Rahmen einer Gruppierung gebunden war. Die Abgrenzung zwischen den jeweils zusammengefassten Zeilen legt dabei gleichzeitig fest, über welche Zeilen die Aggregatfunktion anwendet wird. Mit Window-Funktionen wurde diese Verbindung aufgehoben. Jetzt kann man Aggregatfunktionen ohne Gruppierung nutzen, wenn man stattdessen direkt bei der Aggregatfunktion angibt, auf welche Zeilen sie angewendet wird.

Ein gutes Illustrationsbeispiel ist die Ermittlung einer fortlaufenden Summe. Dabei soll für einen Kontoauszug eine Transaktionsliste ausgegeben werden, bei der auch der aktuelle Kontostand nach jeder Transaktion aufscheint. Der Kontostand ergibt sich dabei aus der Summe der vorangegangenen und der aktuellen Transaktion.

Listing 3 setzt diese Anforderung in alter SQL-92-Manier um. Erschwerend ist dabei, dass man zwar eine Aggregatfunktion braucht, die Transaktionen aber einzeln ausgegeben muss. Eine Gruppierung in der Hauptabfrage ist also nicht ohne Weiteres möglich. Deswegen verlagert das Beispiel die Aggregierung in eine Unterabfrage. Dort kann die Where-Klausel dann frei auswählen, welche Zeilen aufsummiert werden sollen. Im Beispiel sind das die angelaufenen Transaktionen (i.TxID <= o.TxID) zum aktuellen Konto (i.KontoNr = o.KontoNr).

SELECT KontoNr, TxID, Umsatz
  , (SELECT SUM(i.Umsatz)
      FROM Transaktionen i
      WHERE i.KontoNr = o.KontoNr
        AND i.TxID   <= o.TxID
    ) Kontostand
  FROM Transaktionen o
ORDER BY KontoNr, TxID

Window-Funktionen bieten eine völlig andere Art auszuwählen, welche Zeilen einer Aggregatfunktion zugeführt werden. Dafür verwendet man, wie in Listing 4 dargestellt, die Over-Klausel unmittelbar hinter dem Aufruf der Aggregatfunktion.

SELECT KontoNr, TxID, Umsatz
  , SUM(Umsatz)
    OVER(PARTITION BY KontoNr
             ORDER BY TxID
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) Kontostand
  FROM Transaktionen
ORDER BY KontoNr, TxID

Um die Wirkung der Over-Klausel zu verstehen, kann man sich vorstellen, dass sie bei jeder Zeile einzeln angewendet wird, und als Ergebnis die ausgewählten Zeilen an die vorangestellte Aggregatfunktion liefert. Im Beispiel erfolgt die Auswahl der Zeilen mit drei Subklauseln: Partition by, Order by und dem sogenannten Framing (im Beispiel Between).

Partition by KontoNr begrenzt die Zeilen auf jene, die in der Spalte KontoNr denselben Wert wie die aktuelle Zeile haben. Damit ist jegliche Vermischung von Transaktionen zu verschiedenen Konten ausgeschlossen. Der nächste Schritt ist, dafür zu sorgen, dass nur die Zeilen bis zur aktuellen Transaktion herangezogen werden. Dafür wird mit Order by zuerst eine passende Reihenfolge festgelegt und mit Rows between unbounded preceding and current row auf den Bereich zwischen der ersten Zeile (unbounded preceding) und der aktuellen Zeile (current row) eingegrenzt. Diese Zeilen werden dann an die Aggregatfunktion übergeben – im Beispiel Sum.

Die Flexibilität der Over-Klausel kann nicht überschätzt werden. So kann man mit Rows between 3 preceding and 3 following jeweils drei Zeilen vor und nach der aktuellen Zeile auswählen und dann mit der Funktion Avg den gleitenden Mittelwert berechnen. Die Grundfunktion der Over-Klausel besteht darin, aus einer Zeile heraus auf andere Zeilen desselben Ergebnisses zuzugreifen. Das macht nicht nur bei Aggregatfunktionen Sinn. Zur Ermittlung der Position innerhalb des aktuellen Ergebnisses gibt es zum Beispiel auch die Funktionen Row_number, Rank und Dense_rank. Mit Funktionen wie Lag oder Lead kann man sogar direkt auf andere Zeilen zugreifen.

Um mit SQL-92 ähnliches zu erreichen, muss man mehrmals auf dieselbe Tabelle zugreifen (Listing 3). Dieser Kunstgriff – der Self-Join im weitesten Sinn – macht zwar viele Anforderungen mit SQL-92 lösbar, ist aber weder intuitiv noch performant. Daher ist ein Self-Join in vielen Fällen keine praktikable, sondern lediglich eine akademische Lösung. Dieser Tatsache wird seit SQL:1999 verstärkt Rechnung getragen. Heutzutage bietet SQL für fast jeden Self-Join eine bessere Alternative. Window-Funktionen spielen dabei eine wichtige Rolle.

Abb. 2: Verfügbarkeit von Window-Funktionen

Abb. 2: Verfügbarkeit von Window-Funktionen

Zeitreisen für die Nachvollziehbarkeit

SQL selbst erfordert keine höhere Normalisierung. Vielleicht liegt es auch daran, dass SQL für manche Probleme, die mit höherer Normalisierung einhergehen, sehr lange keine expliziten Lösungen hatte. Ein solches Problem ist das Führen von Historien. Es ergibt sich erst durch die Aufteilung logisch zusammenhängender Daten auf mehrere Tabellen.

Das Problem tritt zum Beispiel bei der Trennung von Kundenstammdaten und Bestellungen auf – z. B. in einem Webshop. Ein einfaches Update auf den Kundenstamm führt einerseits dazu, dass die alten Kundendaten verloren gehen, andererseits erscheint es so, als wären die neuen Stammdaten schon bei früheren Bestellungen aktiv gewesen.

Will man den Kundenstamm einer Versionierung unterziehen, war man mit SQL-92 völlig auf sich gestellt. Lediglich die Einführung von Triggern hat später etwas geholfen. Wie im Folgenden gezeigt, kann man mit modernem SQL die Versionierung einer Tabelle ganz einfach an die Datenbank abtreten:

CREATE TABLE … (
  …, -- normale Spalten

  valid_from TIMESTAMP(9) GENERATED ALWAYS AS ROW START NOT NULL,
  valid_till TIMESTAMP(9) GENERATED ALWAYS AS ROW END   NOT NULL,

  PERIOD FOR SYSTEM_TIME (valid_from, valid_till)
) WITH SYSTEM VERSIONING

Dafür braucht man zwei Spalten, die den zeitlichen (temporalen) Gültigkeitsbereich jeder Zeile speichern. Aufgrund der Generated-always-as-Klauseln kümmert sich die Datenbank um die Befüllung dieser Spalten. Danach werden sie in eine Periode zusammengefasst, und die sogenannte Systemversionierung mit der Klausel With system versioning aktiviert. Damit erledigt die Datenbank die Versionierung dieser Tabelle völlig automatisch und für die Applikation transparent.

Bestehende Anweisungen müssen also nicht geändert werden – die Datenbank verhält sich trotzdem anders. Bei einer Insert-Anweisung wird zum Beispiel die aktuelle Systemzeit automatisch als Gültigkeitsbeginn der neuen Zeilen verwendet. Der Endzeitpunkt liegt in der fernen Zukunft – z. B. im Jahr 9999. Da der Sinn der Historisierung darin liegt, später noch auf alte Daten zugreifen zu können, setzt eine Delete-Anweisung lediglich das Enddatum auf den Zeitpunkt des Löschens. Auch die Update-Anweisung kümmert sich um die richtige Speicherung der alten Daten. Konkret heißt das, dass bei den entsprechenden Zeilen lediglich der Endzeitpunkt gesetzt wird, damit die alten Daten unverändert erhalten bleiben. Zusätzlich werden die Daten in neue Zeilen übertragen, entsprechend der Update-Anweisung geändert und mit dem passenden Gültigkeitsbereich gespeichert.

Die Transparenz der Systemversionierung betrifft natürlich auch Abfragen. Bestehende Abfragen können unverändert weiterverwendet werden, da sie nur aktuelle Zeilen liefern. Um auf alte Daten zuzugreifen, muss man ausdrücklich danach fragen. Für solche „Zeitreiseabfragen“ wurde die From-Klausel wie folgt erweitert:

SELECT *
  FROM … FOR SYSTEM_TIME AS OF TIMESTAMP'2019-01-01'

Da die Systemversionierung immer den Zeitpunkt der Transaktion verwendet, wird damit lediglich festgehalten, wann Änderungen in der Datenbank durchgeführt wurden. Oft muss man aber erfassen, wann Änderungen in der realen Welt eingetreten sind (oder eintreten werden). Von einer Namensänderung erfahren Geschäftspartner oft erst Wochen später – z. B. nach der Hochzeitsreise. In diesem Fall muss man eine Änderung rückwirkend erfassen. SQL bietet mit den sogenannten Applikationszeitperioden auch dafür Unterstützung an. Dabei wird die Update– und Delete-Syntax erweitert, damit man den Änderungszeitpunkt an die Datenbank übergeben kann. Dieser kann dann auch in der Zukunft liegen, um zum Beispiel Preisänderungen ab nächstem Monat umzusetzen. Schlussendlich erlaubt SQL auch die Verwendung zweier Gültigkeitsperioden in einer Tabelle – sogenannte bitemporale Tabellen. Damit kann man sowohl abbilden, wie sich die Realität geändert hat – z. B. wann ein Name geändert wurde –, als auch festhalten, wann diese Änderung in der Datenbank erfasst wurde.

Abb. 3: Verfügbarkeit von Systemversionierung

Abb. 3: Verfügbarkeit von Systemversionierung

JSON als Schnittstelle

Auch bei den Datentypen hat sich SQL weiterentwickelt – nämlich über das klassische Verständnis des relationalen Modells hinaus. Während SQL-92 noch auf „atomare“ Datentypen beschränkt war (Strings, Zahlen, Zeitangaben), wird seit SQL:1999 eine modernere Interpretation des relationalen Modells verfolgt. Damit wurden Arrays, Objekttypen aber auch das Speichern und Verarbeiten von XML und JSON-Dokumenten möglich. Durch die Konvertierungsfunktionen, die damit einhergegangen sind, kann man solche Daten nicht nur speichern, sondern auch als Schnittstelle zur Applikation nutzen.

Als Beispiel möchte in eine SQL/JSON-Funktion vorstellen, die ein JSON-Dokument in eine klassische Tabelle überführt. Dafür muss man zuerst die sogenannte SQL/JSON-Path-Sprache kennenlernen. Diese Sprache erfüllt für JSON jene Funktionen, die XPath für XML und CSS-Selektoren für HTML erfüllen. Sie ermöglicht also den Zugriff auf Teile eines JSON-Dokuments. Die JSON-Path-Ausdrücke, die im folgenden Beispiel (Listing 5 und 6) vorkommen, werden gleich erklärt.

[ 
  {
    "id": 42,
    "a1": "foo"
  },
  { 
    "id": 43,
    "a1": "bar"
  }
]
Id A1
42 foo
43 bar

Tabelle 1: JSON-Daten als Tabelle

Listing 6 zeigt den vollständigen Code zur Transformation des JSON-Dokuments aus Listing 5 in eine Tabelle (Tabelle 1). Die Transformation selbst wird von der Funktion Json_table durchgeführt. Als erstes Argument erwartet diese Funktion das JSON-Dokument – im Beispiel wird es durch einen Bind-Parameter (?) von der Applikation übergeben. Danach folgt ein JSON-Path-Ausdruck, der festlegt, wie viele Zeilen aus der Transformation hervorgehen. Der Ausdruck im Beispiel (‚$[*]‘) bedeutet, dass ausgehend vom aktuellen Kontext ($) ein Array erwartet wird ([…]) und für jedes Element (*) eine Zeile ausgegeben werden soll. Daraus resultiert, dass aus dem JSON-Dokument in Listing 5 eine Tabelle mit zwei Zeilen wird. Bleibt nur noch die Definition der Spalten mit der Columns-Klausel. Dort wird jeder Spalte ein Name und Typ zugewiesen, und mit einem JSON-Path-Ausdruck ein Wert aus dem JSON-Dokument entnommen. Bei den JSON-Path-Ausdrücken in der Columns-Klausel ist der Kontext ($) das Element, das gerade in eine Zeile überführt wird. Daher genügt ‚$.id‘ und ‚$.a1‘, um die entsprechenden Attribute auszuwählen.

SELECT *
  FROM JSON_TABLE (
    ?
  , '$[*]'
    COLUMNS (
      Id INT        PATH '$.id'
    , A1 VARCHAR(…) PATH '$.a1'
    )
  ) r

Insgesamt macht das Beispiel etwas relativ Sinnloses: es erhält über den Parameter ? von der Applikation ein JSON-Dokument, transformiert es in eine tabellarische Form und retourniert diese Tabelle an die Applikation. Interessant wird das Beispiel erst, wenn man Insert into … voranstellt. Damit kann man ein JSON-Dokument an die Datenbank senden, dort deklarativ in eine tabellarische Form überführen und die so entstandenen Zeilen gleich in eine Tabelle schreiben.

In diesem Beispiel wird JSON also nur als Schnittstelle zur Applikation verwendet. Alternativ kann man die JSON-Dokumente natürlich auch als solche in einer Tabelle speichern. SQL selbst ist in dieser Hinsicht unvoreingenommen.

Abb. 4: Verfügbarkeit von JSON_TABLE

Abb. 4: Verfügbarkeit von JSON_TABLE

Fazit

Wenn die Beispiele aus diesem Artikel eines zeigen, dann, dass SQL-Datenbanken nicht nur passive Datenspeicher sind. SQL ist eine Sprache zum bedeutungsvollen Umgang mit Daten. Bedeutungsvoll heißt, dass es Datentypen zur richtigen Handhabung von Zeitangaben gibt – mit oder ohne Zeitzone. Bedeutungsvoll heißt auch, dass logisch zusammengehörige Zugriffe in Transaktionen zusammengefasst werden können, um einen konsistenten Blick auf die Daten zu gewähren. Der bedeutungsvolle Umgang umfasst die Transformation zwischen verschiedenen Darstellungsformen (relational, als Dokument usw.) und die Ableitung neuer Informationen aus den Grunddaten, wie das Bilden einer fortlaufenden Summe.

Daher ist SQL deutlich mehr als nur eine Abfragesprache. SQL ist auch eine Transformationssprache. Das war es schon immer. Neu ist nur, dass SQL nicht mehr auf die rigiden Konzepte seines relationalen Ursprunges beschränkt ist. Das ist eine tiefgreifende Änderung, die durchaus einen zweiten Blick auf SQL rechtfertigt.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -