Wat ik wil in een moderne relationele querytaal
Ik denk dat een van de grootste oorzaken van de opkomst van NoSQL is dat SQL, hoewel het een krachtige taal is vanwege de achterliggende ideeën, vaak op onhandige en archaïsche manieren is geïmplementeerd. Een taal die leert van SQL zou relationele data makkelijker manipuleerbaar maken voor programmeurs. Ik zal proberen te denken aan zaken waar ik in praktijksituaties mee te maken heb gehad en hoe een betere querytaal daarbij had kunnen helpen.
Ter referentie: mijn ervaring met RDBMS-systemen ligt voornamelijk bij MySQL en Db2, maar ik heb ook SQLite, SQL Server, Oracle en Postgres intensief gebruikt (in aflopende volgorde van bekendheid).
Betere syntaxis
Ik ben zelf niet veeleisend wat betreft esthetiek, maar velen anderen wel. Programmeurs zijn als peuters: ze willen hun Kraft Dinner en geen broccoli. De keuze om de syntaxis te baseren op PL/I was een IBM-beslissing uit de jaren 70 die vandaag de dag waarschijnlijk niet meer zou werken. Bij grote vraag zou een moderne taal waarschijnlijk kiezen voor de esthetiek van C of Python, eventueel met invloeden uit ML of Prolog (zoals we bijvoorbeeld bij Rust zien).
Met een betere syntaxis hoop ik dat er ook betere parsers komen. Ik heb een speciale hekel aan de parser van MySQL, die nooit echt aangeeft waar de problemen liggen of wat er mis is, tenzij het gaat om syntactische absurditeiten zoals DELIMITER. Er bestaan wel betere SQL-parsers in conventionele implementaties; Oracle is verrassend goed in het rapporteren van fouten door aan te geven wat het systeem verwacht.
De voorbeelden in dit artikel zijn ter illustratie; ik ben niet gebonden aan een specifieke syntaxis. Mijn invloeden in deze voorbeelden komen voornamelijk uit F# (ML-familie), Erlang (Prolog-achtig) en Elixir (vergelijkbaar met Erlang en Ruby).
Een functionele programmeertaal die niet vijandig staat tegenover functioneel programmeren
De 4GL-eigenschappen van SQL, waarbij je beschrijft hoe je je data wilt in plaats van handmatig door deze heen te loopen, vormen het krachtigste wapen van de taal. Dit ligt dicht bij veel functionele programmeerparadigma's, zoals lazy evaluation (denk aan Haskell).
Helaas is de standaardbibliotheek van de meeste SQL-dialecten op dit vlak vrij armoedig, omdat ze geoptimaliseerd zijn voor procedurele programma's uit de jaren 80. De meeste SQL-dialecten zijn uiteindelijk stored procedures gaan ondersteunen, die inherent procedureel zijn en daarmee ingaan tegen de declaratieve aard van SQL. Dit vertaalt zich in de code van veel gebruikers, die de stijl imiteren die de taal en standaardbibliotheek gemakkelijk maken: veel omgang met mutabele status (cursors) en procedures in plaats van functies. Defaults zijn bepalend.
Minder ondoorzichtige query-planners
Hoewel het 4GL-karakter een kracht is vanwege de krachtige compilers en optimizers, is het makkelijk om een fout te maken waardoor een query veel duurder wordt. Query-planners kunnen cryptisch zijn, tenzij je al een expert bent in SQL-optimalisatie. (Opnieuw: een speciale vermelding voor hoe slecht de "explain"-tools van MySQL hiervoor zijn). Hoewel dit niet strikt aan de taal zelf (PLT) ligt, is het een zwak punt in huidige SQL-implementaties waar computerwetenschappers veel over hebben geleerd.
Betere door de gebruiker gedefinieerde typen (User Defined Types)
Hoewel sommige RDBMS-systemen het concept 'domains' bieden voor het specificeren van door de gebruiker gedefinieerde datatypen (een optioneel onderdeel van de SQL-specificatie), zijn deze vaak beperkt in wat ze kunnen (meestal slechts een eenvoudige laag over ranges of checks).
Postgres lijkt de enige te zijn die dit goed ondersteunt; Oracle heeft dit blijkbaar pas onlangs toegevoegd (hoewel het wellicht flexibeler is dan bij Postgres). Ik heb beide systemen echter niet genoeg gebruikt om precies te weten hoe dit in de praktijk werkt. Domains worden wel behandeld in The Relational Model van Codd, de fundamentele tekst voor RDBMS-systemen. Gezien de afkomst van Postgres uit Ingres (gebaseerd op QUEL, wat dichter bij de visie van Codd lag dan SQL), is het logisch dat Postgres dit heeft gevolgd.
Sum types, discriminated unions en pattern matching
Een schema dat illustreert hoe moderne functionele programmeertechnieken hier toegepast zouden kunnen worden, is een functie die informatie over stack-frames teruggeeft. Ter context: IBM i, het hier genoemde besturingssysteem, biedt veel SQL-functies voor systeembeheer onder de paraplu van "Services". Hoewel dit zeer nuttig is voor databasebeheerders die systeembeheer doen tijdens het debuggen, is het onhandig. Er zijn namelijk "groepen" kolommen die effectief wederzijds uitsluitend zijn, wat leidt tot veel nullable velden en string-velden die eigenlijk enums zijn.
Sommige van deze problemen zijn simpelweg slecht schema-ontwerp (mede veroorzaakt door het feit dat alles in één enkele tabel moet worden teruggegeven; het teruggeven van meerdere tabellen zou een interessante richting zijn). De string-enums kunnen worden opgelost met een foreign key constraint op een tabel die als enum dient. Andere problemen liggen echter aan de expressiviteit van de taalimplementaties.
Gebruikmakend van dit idee, heb ik een voorbeeld bedacht dat queries minder verbose en minder foutgevoelig zou maken:
// Sterk vereenvoudigd; we laten zaken als displacement of extra enum-cases weg,
// en definiëren enums ad-hoc (ze zouden ook buiten het type gedeclareerd kunnen worden).
// Elk frame-type is vergelijkbaar, maar niet identiek,
// en heeft verschillende semantiek of kwalificaties.
type MachineInterfaceInfo =
{
ActivationGroup: long;
ASP: long;
Library: string;
}
// IBM i ondersteunt meerdere programmamodellen:
// - Java-programma's
// - OPM-programma's (oud managed runtime program ABI)
// - ILE-programma's (nieuw managed runtime program ABI)
// - AIX-programma's (via syscall emulatie)
// - LIC (de IBM i kernel)
type FrameType =
// Erf velden van een ander record-type.
| ILE { MachineInterfaceInfo | ServiceProgram: string; Module: string; }
| OPM { MachineInterfaceInfo | Program: string; }
| AIX { Bitness: enum(32 | 64); LibArchive: Option(string); Module: string; Syscall: bool; }
| Java { MethodType: enum(DirectExecution | Glue | Interp | JIT | MMI); ClassName: string; Signature: Option(string); }
table Frame =
{
ThreadID: long;
FrameType: FrameType;
Function: Option(string);
}
function StackInfo(JobID: string) : Frame;
// Een SQL-achtige select met pattern matching om te filteren.
select Function from StackInfo("1234/JOB/5678") where AIX { Bitness: 64 } = FrameType;
// Dit zou FrameType van ILE en OPM teruggeven
select Function from StackInfo("1234/JOB/5678") where MachineInterfaceInfo { Library: "QSYS" } = FrameType;
select Function from StackInfo("1234/JOB/5678") where AIX { LibArchive: "libc.a" } = FrameType;
select Function from StackInfo("1234/JOB/5678") where AIX { LibArchive: None } = FrameType;
// Een functie die informatie print met pattern matching erin.
function FrameFullySpecifiedProgramName(frame : Frame) : string =
match frame.FrameInfo with
| OPM { Program: program } -> program
| ILE { ServiceProgram: srvpgm, Module: module } -> "#{srvpgm}/#{module}"
| AIX { LibArchive: None, Module: module } -> module
| AIX { LibArchive: lib, Module: module } -> "#{lib}(#{module})"
| Java { ClassName: class } -> class
// we moeten alle mogelijke typen matchen, of we gebruiken de discard _
| _ -> "?"
// Een functie die gebruikmaakt van pattern matching gebaseerde overloads en destructuring.
function FrameJavaFunctionDef(frame : Frame { Java { Signature: None } = .FrameInfo }) : string =
"#{frame.Function}()"
function FrameJavaFunctionDef(frame : Frame { Java { Signature: signature } = .FrameInfo }) : string =
"#{frame.Function}(#{signature})"
// Een aanroep hiervan met een non-Java frame is een fout, omdat geen enkel pattern matcht.
Als we de wederzijds uitsluitende set kolommen kunnen samenvoegen, wordt het ook veel makkelijker om te visualiseren. Er is veel minder horizontaal scrollen nodig als deze bijvoorbeeld kunnen worden omgezet in subkolommen per rij in een grotere kolom, of als strings die per type anders worden weergegeven.
Vreemde sleutels die overeenkomen met meerdere typen
Stel dat ik tabellen heb voor "Software", "Versie" en "Download" (een soort WEMI-hiërarchie), en dat elk van deze afbeeldingen kan hebben via een "Picture"-tabel. (Omdat afbeeldingen zelf metadata hebben, zijn ze een tabel in plaats van een kolom).
Normaal gesproken zou je een many-to-many tabel gebruiken voor elk type relatie, zoals "SoftwarePicture", "VersionPicture", etc. Dit lijkt overbodige duplicatie. In plaats daarvan zouden we een many-to-many tabel kunnen hebben met een discriminated union op de vreemde sleutels:
table ObjectPictures =
{
// een vreemde sleutel wordt verondersteld hetzelfde type te hebben als waar hij naar verwijst
PictureID: key relates to (Picture.PictureID);
ObjectID: key relates to (Software.SoftwareID | Version.VersionID | Download.DownloadID);
}
insert into ObjectPictures (PictureID, ObjectID) values (0x1234, DownloadID { 0x1234 });
insert into ObjectPictures (PictureID, ObjectID) values (0x1234, SoftwareID { 0x1234 });
select SoftwareID { software_id } from ObjectPictures where PictureID = 0x1234;
select PictureID from ObjectPictures where ObjectID = DownloadID { 0x1234 };
Groetjes,