2023-08-23

Noch freundlicheres SQL mit DuckDB

Alex Monahan

Looks like a Duck ready to boldly go where databases have not gone before

Wer sagt, dass SQL in der Zeit eingefroren bleiben und an eine Spezifikation von 1999 gekettet sein muss? Zur Erinnerung: Wie hat sich JavaScript vor Promises angefühlt? Die kamen erst 2012! Innovation auf der Syntaxebene kann ein ganzes Sprachökosystem spürbar verbessern.

Wir sehen viele gute Gründe, SQL weiterzuentwickeln: einfachere Basisabfragen und dynamischere Analysen. Viele dieser Features kamen aus Community-Vorschlägen! Ihre SQL-Schmerzpunkte gern auf Discord oder GitHub – machen Sie mit, wenn wir ändern, wie sich SQL-Schreiben anfühlt!

Wer den ersten Teil der Serie noch nicht gelesen hat: „Friendlier SQL with DuckDB“.

Die Zukunft ist jetzt

Die ersten Verbesserungen auf dieser Liste standen im vorigen Beitrag noch unter „Ideen für die Zukunft“.

Wiederverwendbare Spaltenaliase

Bei schrittweise berechneten Ausdrücken in einem Select zwingen klassische SQL-Dialekte dazu, den vollen Ausdruck je Spalte zu wiederholen oder um jeden Berechnungsschritt eine Common Table Expression (CTE) zu legen. Jetzt kann jedes Spaltenalias von nachfolgenden Spalten im selben Select wiederverwendet werden. Und die Aliase gehen auch in Where und Order by.

Alter Weg 1: Sich wiederholen

SELECT
'These are the voyages of the starship Enterprise...' AS intro,
instr('These are the voyages of the starship Enterprise...', 'starship')
AS starship_loc
substr('These are the voyages of the starship Enterprise...',
instr('These are the voyages of the starship Enterprise...', 'starship')
+ len('starship') + 1) AS trimmed_intro;

Alter Weg 2: CTEs ohne Ende

WITH intro_cte AS (
SELECT
'These are the voyages of the starship Enterprise...' AS intro
), starship_loc_cte AS (
SELECT
intro,
instr(intro, 'starship') AS starship_loc
FROM intro_cte
)
SELECT
intro,
starship_loc,
substr(intro, starship_loc + len('starship') + 1) AS trimmed_intro
FROM starship_loc_cte;

Neuer Weg

SELECT
'These are the voyages of the starship Enterprise...' AS intro,
instr(intro, 'starship') AS starship_loc,
substr(intro, starship_loc + len('starship') + 1) AS trimmed_intro;
intro starship_loc trimmed_intro
These are the voyages of the starship Enterprise… 30 Enterprise…

Dynamische Spaltenauswahl

Datenbanken mögen strenge Spaltendefinitionen und flexible Zeilenzahlen. Das hilft bei Datentypen und Spaltenmetadaten. In Data-Science-Workflows und anderswo werden Spalten aber oft dynamisch erzeugt (etwa beim Feature Engineering).

Die Spaltennamen müssen Sie nicht mehr alle im Voraus kennen! DuckDB kann Spalten per regulärem Ausdruck auswählen und sogar ändern, über die Modifier EXCLUDE oder REPLACE und über Lambda-Funktionen (siehe den Abschnitt zu Lambda-Funktionen!).

Schauen wir uns Fakten zur ersten Star-Trek-Staffel an. Mit DuckDBs httpfs-Erweiterung können wir einen CSV-Datensatz direkt von GitHub abfragen. Es gibt mehrere Spalten, also DESCRIBE:

INSTALL httpfs;
LOAD httpfs;
CREATE TABLE trek_facts AS
SELECT *
FROM 'https://blobs.duckdb.org/data/Star_Trek-Season_1.csv';
DESCRIBE trek_facts;
column_name column_type null key default extra
season_num BIGINT YES NULL NULL NULL
episode_num BIGINT YES NULL NULL NULL
aired_date DATE YES NULL NULL NULL
cnt_kirk_hookups BIGINT YES NULL NULL NULL
cnt_downed_redshirts BIGINT YES NULL NULL NULL
bool_aliens_almost_took_over_planet BIGINT YES NULL NULL NULL
bool_aliens_almost_took_over_enterprise BIGINT YES NULL NULL NULL
cnt_vulcan_nerve_pinch BIGINT YES NULL NULL NULL
cnt_warp_speed_orders BIGINT YES NULL NULL NULL
highest_warp_speed_issued BIGINT YES NULL NULL NULL
bool_hand_phasers_fired BIGINT YES NULL NULL NULL
bool_ship_phasers_fired BIGINT YES NULL NULL NULL
bool_ship_photon_torpedoes_fired BIGINT YES NULL NULL NULL
cnt_transporter_pax BIGINT YES NULL NULL NULL
cnt_damn_it_jim_quote BIGINT YES NULL NULL NULL
cnt_im_givin_her_all_shes_got_quote BIGINT YES NULL NULL NULL
cnt_highly_illogical_quote BIGINT YES NULL NULL NULL
bool_enterprise_saved_the_day BIGINT YES NULL NULL NULL

COLUMNS() mit regulären Ausdrücken

Der Ausdruck COLUMNS nimmt einen String entgegen, der ein regulärer Ausdruck ist, und liefert alle passenden Spaltennamen. Wie hat sich Warp über die erste Staffel verändert? Alle Spaltennamen mit dem Wort warp:

SELECT
episode_num,
COLUMNS('.*warp.*')
FROM trek_facts;
episode_num cnt_warp_speed_orders highest_warp_speed_issued
0 1 1
1 0 0
2 1 1
3 1 0
27 1 1
28 0 0
29 2 8

COLUMNS lässt sich von anderen Funktionen umhüllen, die dann auf jede gewählte Spalte wirken. Vereinfachen wir die Abfrage auf die Maxima über alle Folgen:

SELECT
max(COLUMNS('.*warp.*'))
FROM trek_facts;
max(trek_facts.cnt_warp_speed_orders) max(trek_facts.highest_warp_speed_issued)
5 8

Wir können auch eine WHERE-Klausel über mehrere Spalten legen. Alle Spalten müssen das Filterkriterium erfüllen, das entspricht einer Kombination mit AND. Welche Folgen hatten mindestens 2 Warp-Speed-Befehle und mindestens Warp-Stufe 2?

SELECT
episode_num,
COLUMNS('.*warp.*')
FROM trek_facts
WHERE
COLUMNS('.*warp.*') >= 2;
-- cnt_warp_speed_orders >= 2
-- AND
-- highest_warp_speed_issued >= 2
episode_num cnt_warp_speed_orders highest_warp_speed_issued
14 3 7
17 2 7
18 2 8
29 2 8

COLUMNS() mit EXCLUDE und REPLACE

Einzelne Spalten können vor Berechnungen ausgeschlossen oder ersetzt werden. Unser Datensatz enthält nur Staffel 1, das max dieser Spalte brauchen wir nicht. Das wäre höchst unlogisch.

SELECT
max(COLUMNS(* EXCLUDE season_num))
FROM trek_facts;
max(trek_facts.
episode_num)
max(trek_facts.
aired_date)
max(trek_facts.
cnt_kirk_hookups)
max(trek_facts.
bool_enterprise_saved_the_day)
29 1967-04-13 2 1

Die Syntax REPLACE ist auch bei einem dynamischen Spaltensatz nützlich. Hier wandeln wir die Daten vor dem Maximum je Spalte in Zeitstempel. Früher hätte das eine ganze Subquery oder CTE nur für diese eine Spalte gebraucht!

SELECT
max(COLUMNS(* REPLACE aired_date::timestamp AS aired_date))
FROM trek_facts;
max(trek_facts.
season_num)
max(trek_facts.
episode_num)
max(aired_date :=
CAST(aired_date AS TIMESTAMP))
max(trek_facts.
bool_enterprise_saved_the_day)
1 29 1967-04-13 00:00:00 1

COLUMNS() mit Lambda-Funktionen

Am flexibelsten lässt sich ein dynamischer Spaltensatz über eine Lambda-Funktion abfragen. Beliebige Kriterien auf Spaltennamen, nicht nur reguläre Ausdrücke. Mehr zu Lambdas weiter unten.

Ist LIKE vertrauter, können wir Spalten nach einem LIKE-Muster statt per Regex wählen.

SELECT
episode_num,
COLUMNS(lambda col: col LIKE '%warp%')
FROM trek_facts
WHERE
COLUMNS(lambda col: LIKE '%warp%') >= 2;
episode_num cnt_warp_speed_orders highest_warp_speed_issued
14 3 7
17 2 7
18 2 8
29 2 8

Automatische Umwandlung von JSON in verschachtelte Typen

Im ersten Teil der Serie stand JSON-Punktnotation noch als Zukunftsarbeit. Das Team ist weiter gegangen! Statt JSON-typisierte Spalten per Punktnotation anzusprechen, kann JSON jetzt automatisch in DuckDBs native Typen geparst werden – deutlich schneller, bessere Kompression und dazu die freundliche Punktnotation!

Zuerst die Erweiterungen httpfs und json installieren und laden, falls sie nicht im Client mitkommen. Dann eine entfernte JSON-Datei direkt wie eine Tabelle abfragen!

INSTALL httpfs;
LOAD httpfs;
INSTALL json;
LOAD json;
SELECT
starfleet[10].model AS starship
FROM 'https://raw.githubusercontent.com/vlad-saling/star-trek-ipsum/master/src/content/content.json';
starship
USS Farragut - NCC-1647 - Ship on which James Kirk served as a phaser station operator. Attacked by the Dikironium Cloud Creature, killing half the crew. ad.

Jetzt neue SQL-Fähigkeiten über die Ideen aus dem vorigen Beitrag hinaus!

FROM zuerst in SELECT-Statements

Beim Bauen einer Abfrage muss man zuerst wissen, woher die Daten kommen – FROM. Warum ist das dann die zweite Klausel in einem SELECT?? Nicht mehr! DuckDB baut SQL, wie es immer hätte sein sollen – die Klausel FROM zuerst! Das adressiert eine der ältesten Beschwerden über SQL, und das DuckDB-Team hat es in 2 Tagen umgesetzt.

FROM my_table SELECT my_column;

Nicht nur das: Das SELECT-Statement kann ganz wegfallen, DuckDB nimmt dann alle Spalten. Eine Tabelle anschauen ist jetzt so einfach:

FROM my_table;
-- SELECT * FROM my_table

Andere Statements wie COPY werden ebenfalls einfacher.

COPY (FROM trek_facts) TO 'phaser_filled_facts.parquet';

Neben weniger Tastenanschlägen und besserem Flow hat das einen weiteren Vorteil: Autocomplete hat mehr Kontext, wenn Sie Spalten wählen. Der KI eine Hilfestellung!

Die Syntax ist völlig optional, Ihre Tastenkürzel für SELECT * FROM bleiben sicher – auch wenn sie überholt sind … 🙂

Function Chaining

Viele SQL-Blogs raten zu CTEs statt Subqueries. Unter anderem sind sie lesbarer. Operationen stecken in diskreten Blöcken und lassen sich von oben nach unten lesen, statt sich von innen nach außen vorzuarbeiten.

Denselben Lesbarkeitsgewinn gibt DuckDB jeder skalaren Funktion! Mit dem Punktoperator Funktionen verketten, wie in Python. Der vorherige Ausdruck in der Kette ist das erste Argument der nächsten Funktion.

SELECT
('Make it so')
.upper()
.string_split(' ')
.list_aggr('string_agg','.')
.concat('.') AS im_not_messing_around_number_one;
im_not_messing_around_number_one
MAKE.IT.SO.

Vergleichen Sie das mit dem alten Weg …

SELECT
concat(
list_aggr(
string_split(
upper('Make it stop'),
' '),
'string_agg','.'),
'.') AS oof;
oof
MAKE.IT.STOP.

Union by Name

DuckDB will das Beste aus Datenbanken und DataFrames verbinden. Die neue Syntax ist von der concat-Funktion in Pandas inspiriert. Statt Tabellen nach Spaltenposition vertikal zu stapeln, werden Spalten nach Namen gematcht und entsprechend gestapelt. Einfach UNION durch UNION BY NAME ersetzen oder UNION ALL durch UNION ALL BY NAME.

Zum Beispiel mussten wir in The Next Generation ein paar neue Sprichwörter außerirdischer Spezies ergänzen:

CREATE TABLE proverbs AS
SELECT
'Revenge is a dish best served cold' AS klingon_proverb
UNION ALL BY NAME
SELECT
'You will be assimilated' AS borg_proverb,
'If winning is not important, why keep score?' AS klingon_proverb;
FROM proverbs;
klingon_proverb borg_proverb
Revenge is a dish best served cold NULL
If winning is not important, why keep score? You will be assimilated

Der Ansatz hat weitere Vorteile. Wie oben zu sehen: Nicht nur Tabellen mit anderer Spaltenreihenfolge lassen sich kombinieren, sondern auch mit völlig unterschiedlicher Spaltenzahl. Hilfreich bei Schema-Migrationen und besonders für DuckDBs Multi-Datei-Lesen.

Insert by Name

Eine weitere Stelle, an der SQL die Spaltenreihenfolge streng nimmt: das Einfügen in eine Tabelle. Entweder müssen die Spalten exakt passen, oder alle Spaltennamen stehen zweimal in der Abfrage.

Stattdessen nach dem Tabellennamen die Schlüsselwörter BY NAME. Beliebige Teilmengen der Tabellenspalten in beliebiger Reihenfolge lassen sich einfügen.

INSERT INTO proverbs BY NAME
SELECT 'Resistance is futile' AS borg_proverb;
SELECT * FROM proverbs;
klingon_proverb borg_proverb
Revenge is a dish best served cold NULL
If winning is not important, why keep score? You will be assimilated
NULL Resistance is futile

Dynamisches PIVOT und UNPIVOT

Historisch sind Datenbanken für Pivot-Operationen schlecht geeignet. DuckDBs Klauseln PIVOT und UNPIVOT können dynamische Spaltennamen erzeugen oder stapeln – wirklich flexibles Pivotieren! Dazu gibt es die SQL-Standard-Syntax und eine freundlichere Kurzform.

Ein Blick auf Beschaffungsprognosen, gerade als der Erd-Romulaner-Krieg begann:

CREATE TABLE purchases (item VARCHAR, year INTEGER, count INTEGER);
INSERT INTO purchases
VALUES ('phasers', 2155, 1035),
('phasers', 2156, 25039),
('phasers', 2157, 95000),
('photon torpedoes', 2155, 255),
('photon torpedoes', 2156, 17899),
('photon torpedoes', 2157, 87492);
FROM purchases;
item year count
phasers 2155 1035
phasers 2156 25039
phasers 2157 95000
photon torpedoes 2155 255
photon torpedoes 2156 17899
photon torpedoes 2157 87492

Phaser- und Photonentorpedo-Bedarf lassen sich leichter vergleichen, wenn die Jahreszahlen visuell nah beieinanderstehen. Pivotieren wir in ein freundlicheres Format! Jedes Jahr eine eigene Spalte (ohne jedes Jahr in der Abfrage anzugeben!), die Summe von count, und weiter eine eigene Gruppe (Zeile) je item.

CREATE TABLE pivoted_purchases AS
PIVOT purchases
ON year
USING sum(count)
GROUP BY item;
FROM pivoted_purchases;
item 2155 2156 2157
phasers 1035 25039 95000
photon torpedoes 255 17899 87492

Sieht aus, als wären Photonentorpedos im Angebot gewesen …

Stellen Sie sich die umgekehrte Lage vor. Scotty in Engineering hat die Beschaffungsprognose visuell analysiert und von Hand aufgebaut. Er mag alles pivotiert, weil es lesbarer ist. Jetzt muss es zurück in die Datenbank! Der Krieg kann noch eine Weile dauern, nächstes Jahr vielleicht wieder. Eine UNPIVOT-Abfrage zurück ins Originalformat, die jedes Jahr verträgt.

COLUMNS nimmt alle Spalten außer item. Nach dem Stapeln wird die Spalte mit den Namen aus pivoted_purchases in year umbenannt, die Werte darin sind count. Das Ergebnis ist derselbe Datensatz wie das Original.

UNPIVOT pivoted_purchases
ON COLUMNS(* EXCLUDE item)
INTO
NAME year
VALUE count;
item year count
phasers 2155 1035
phasers 2156 25039
phasers 2157 95000
photon torpedoes 2155 255
photon torpedoes 2156 17899
photon torpedoes 2157 87492

Weitere Beispiele stehen in unserem Ankündigungsbeitrag zu DuckDB 0.8.0; die Dokumentationsseiten zu PIVOT und UNPIVOT zeigen komplexere Abfragen.

Ein späterer Beitrag erklärt, was hinter den Kulissen passiert!

Listen-Lambda-Funktionen

Listen-Lambdas wenden Operationen auf jedes Listenelement an. Sie müssen nicht vorab definiert sein – sie entstehen on the fly in der Abfrage.

Hier verkürzt eine Lambda-Funktion zusammen mit list_transform jeden offiziellen Schiffsnamen.

SELECT
(['Enterprise NCC-1701', 'Voyager NCC-74656', 'Discovery NCC-1031'])
.list_transform(lambda x: x.string_split(' ')[1]) AS short_name;
ship_name
[Enterprise, Voyager, Discovery]

Lambdas können Listen auch filtern. Das Lambda liefert eine Liste von Booleans; list_filter wählt damit bestimmte Elemente. Die Funktion contains nutzt das oben beschriebene Function Chaining.

SELECT
(['Enterprise NCC-1701', 'Voyager NCC-74656', 'Discovery NCC-1031'])
.list_filter(lambda x: x.contains('1701')) AS the_original;
the_original
[Enterprise NCC-1701]

List Comprehensions

Was, wenn es eine einfache Syntax gäbe, um eine Liste zu ändern und zu filtern? DuckDB orientiert sich an Pythons List Comprehensions und vereinfacht die obigen Beispiele stark. List Comprehensions sind syntaktischer Zucker – hinter den Kulissen werden die Abfragen in Lambda-Ausdrücke umgeschrieben!

In Klammern zuerst die gewünschte Transformation, dann die zu iterierende Liste, zuletzt das Filterkriterium.

SELECT
[x.string_split(' ')[1]
FOR x IN ['Enterprise NCC-1701', 'Voyager NCC-74656', 'Discovery NCC-1031']
IF x.contains('1701')] AS ready_to_boldly_go;
ready_to_boldly_go
[Enterprise]

Struct.* explodieren

Ein Struct in DuckDB ist eine Menge von Schlüssel/Wert-Paaren. Hinter den Kulissen liegt je Schlüssel eine eigene Spalte. Ein Struct in getrennte Spalten zu explodieren ist daher rechnerisch einfach – und jetzt auch syntaktisch! Wieder ein Beispiel dafür, dass SQL dynamische Spaltennamen verträgt.

WITH damage_report AS (
SELECT {'gold_casualties':5, 'blue_casualties':15, 'red_casualties': 10000} AS casualties
)
FROM damage_report
SELECT
casualties.*;
gold_casualties blue_casualties red_casualties
5 15 10000

Automatische Struct-Erzeugung

DuckDB macht es leicht, jede Tabelle in einen einspaltigen Struct zu wandeln. Statt Spaltennamen zu SELECTen, SELECTen Sie den Tabellennamen selbst.

WITH officers AS (
SELECT 'Captain' AS rank, 'Jean-Luc Picard' AS name
UNION ALL
SELECT 'Lieutenant Commander', 'Data'
)
FROM officers
SELECT officers;
officers
{‘rank’: Captain, ‘name’: Jean-Luc Picard}
{‘rank’: Lieutenant Commander, ‘name’: Data}

Der Datentyp Union

DuckDB nutzt starke Typisierung für Leistung und Datenqualität. Gleichzeitig ist DuckDB so nachsichtig wie möglich, etwa durch implizites Casting, damit nicht ständig gecastet werden muss.

Eine weitere Flexibilität ist der neue Datentyp UNION. Eine Spalte kann mehrere Wertetypen enthalten. Das lässt sich als „Opt-in“ in SQLites flexible Typregeln verstehen (die Gegenrichtung zu SQLites kürzlich angekündigten strict tables).

Standardmäßig sucht DuckDB beim Kombinieren von Tabellen den gemeinsamen Nenner der Datentypen. Die folgende Abfrage ergibt eine VARCHAR-Spalte:

SELECT 'The Motion Picture' AS movie UNION ALL
SELECT 2 UNION ALL
SELECT 3 UNION ALL
SELECT 4 UNION ALL
SELECT 5 UNION ALL
SELECT 6 UNION ALL
SELECT 'First Contact';
movie
The Motion Picture
First Contact
6
5
4
3
2

Mit einem Typ UNION behält jede Zeile ihren ursprünglichen Datentyp. Ein UNION wird über Schlüssel-Wert-Paare definiert: Schlüssel als Name, Wert als Datentyp. Die einzelnen Typen lassen sich auch als eigene Spalten herausziehen:

CREATE TABLE movies (
movie UNION(num INTEGER, name VARCHAR)
);
INSERT INTO movies VALUES
('The Motion Picture'), (2), (3), (4), (5), (6), ('First Contact');
FROM movies
SELECT
movie,
union_tag(movie) AS type,
movie.name,
movie.num;
movie type name num
The Motion Picture name The Motion Picture
2 num 2
3 num 3
4 num 4
5 num 5
6 num 6
First Contact name First Contact

Weitere freundliche Features

Einige weitere freundliche Features verdienen Erwähnung, manche ein eigenes Blog.

DuckDB nimmt sich die describe-Funktion in Pandas zum Vorbild und implementiert das Schlüsselwort SUMMARIZE: diverse Statistiken je Spalte für einen schnellen Überblick. Einfach SUMMARIZE vor jede Tabelle oder jedes SELECT.

Im Beitrag zu korrelierten Subqueries steht, wie Subqueries aufeinander Bezug nehmen. DuckDBs Optimizer verbessert die Leistung korrelierter Subqueries um Größenordnungen; Abfragen lassen sich so natürlich wie möglich formulieren. Was aus Leistungsgründen ein Anti-Pattern war, kann jetzt frei genutzt werden!

DuckDB hat weitere JOIN-Arten ergänzt, die gängige Berechnungen leichter ausdrücken. Manche wie LATERAL, ASOF, SEMI und ANTI gibt es in anderen Systemen, in DuckDB mit hoher Leistung. Neu ist ein POSITIONAL-Join, der über Zeilennummern kombiniert – analog zu Pandas und Joins über Zeilenindizes. Details in der JOIN-Dokumentation; ein Beitrag zu DuckDBs State-of-the-Art-ASOF-Joins folgt!

Zusammenfassung und Ausblick

DuckDB soll die einfachste Datenbank sein. In-Process, keine Abhängigkeiten und starke Typisierung tragen dazu bei, der freundliche SQL-Dialekt ebenso. DuckDB erweitert den Branchenstandard PostgreSQL-Dialekt, damit sich die nötigen Transformationen so einfach wie möglich ausdrücken lassen. Die Änderungen reichen von der uralten Klauselreihenfolge des SELECT (jetzt mit FROM zuerst) über Function Chaining bis zu Berechnungen auf verschachtelten Typen wie List Comprehensions. All das ist im Release 0.8.1 verfügbar.

Geplante Arbeit an freundlicherem SQL:

Sagen Sie uns, wo SQL besser werden kann! Feedback gern auf Discord oder GitHub.

Leben Sie lang und in Frieden! 🖖