Komplexe Vergleichsfunktionen in Datenbanken
Von Carsten Hetzel amDatenbanken bieten heutzutage ein breites Spektrum an Funktionen zur Bearbeitung und den Vergleich von Werten. Egal ob einfache Vergleiche, komplexe Vergleiche mit regulären Ausdrücken oder komplexe mathematische Funktionen – für die verbreitetsten Anwendungsfälle haben die Datenbankhersteller eine Funktion zur Verfügung gestellt, die der Entwickler in seine Datenbankabfragen einbauen kann.
Leider ergibt sich aus dieser Vielfalt nicht immer die gewünschte Flexibilität. Zwar lassen sich im Grunde für den potentiellen Kunden solche Funktionen ohne Probleme in die Anwendung einbauen, aber sie sind dann eben statisch im Quelltext vorhanden und lassen sich naturgemäß nicht einfach ändern. Wer will schon die gesamte Anwendung neu ausliefern, wenn sich der Wertebereich für einen Parameter ändert?
Gehen wir von folgendem Datenbestand aus:
Parameter | Wert |
---|---|
A.x | 10 |
A.y | 20 |
B.x | 11 |
B.y | 21 |
C.x | |
C.z |
Sind die Einschränkungen diskrete Werte, ist die Aufgabenstellung leicht zu lösen: Wir erstellen eine Constraint-Tabelle mit dem Wert für eine änderbare Anzahl von Parametern und ein einfacher Vergleich der Werte liefert die abweichenden Datensätze.
Parameter | Wert | Beschreibung |
---|---|---|
A.x | 10 | Fester Wert |
A.y | 20 | Fester Wert |
C.x | 10 | Fester Wert |
C.z | Muss leer sein |
Das passende SQL-Statement dazu ist vergleichsweise simple - möchte man meinen.
select pv.name, pc.value, pc.description from parameter_value pv join parameter_constraint pc on pc.name = pv.name where pc.value != pv.value
Soweit so gut, der Kunde kann zumindest selber pflegen, welche Parameter welche Werte haben sollen – dass es bei dieser Anforderung bleibt ist leider nicht sehr realistisch. Betrachten wir also folgendes Szenario:
Der Kunde hat sehr konkrete Vorstellungen davon, was er prüfen will: Maximal- und Minimalwerte, Wertebereiche und ein Wert aus einer Liste.
Es werden also mindestens folgende Operatoren benötig:
- Kleiner oder gleich: LessOrEquals (le)
- Größer oder gleich: GreaterOrEquals (ge)
- Zwischen zwei Werten: Between (range)
- Einer von verschiedenen Werten: In (in)
Während nun die beiden ersten Operatoren zwei Operanden haben, sind es beim dritten schon drei und beim vierten eine beliebige Anzahl von Operanden.
SQL-Statements bekommt man für die einzelnen Abfragen natürlich schnell herunter geschrieben, aber es können ja keine festen Statements verwendet werden. Während die erste Version der Constraint-Tabelle noch trivial war, denkt der ein oder andere vielleicht schon über eine 1:N-Relation nach. Vielleicht kann man auch mit einem Interpreter arbeiten, der aus einem Ausdruck ein passendes SQL-Statement generiert.
Die Frage ist, wie der Kunde komfortabel Einschränkungen für Parameter formulieren will. Am ehesten wird dies eine kurze prägnante Schreibweise sein. Z.B. für der Parameter "B.x" soll gelten "B.x <= 12". Den linken Operanden "B.x" bekommen wir schon aus unserer vorhandenen Tabelle "parameter_constraint", es fehlt also nur noch der Operator "<=" und der zweite Operand "12".
Diese in der Form "<= 12" zu hinterlegen stellt uns aber vor ein weiteres Problem: Wie sollen Operatoren von einfachen Werten unterschieden werden?
Sicher können hier verschiedene Lösungen zum Ziel führen – z.B. ein Flag in der Constraint-Tabelle, welches anzeigt, dass der Wert interpretiert werden muss. Um eine einfache und einheitliche Lösung zu ermöglichen, lohnt es sich aber eine Notation einzuführen, die in den tatsächlichen Werten nicht vorkommen wird.
Da der Name der Anwendung in der Regel nicht in operativen Daten vorkommt, kann man also auf ein Präfix wie „MyApp“ (bzw. den Namen der Anwendung) zurückgreifen. Die aufgelisteten Einschränkung könnten also folgendermaßen hinterlegt werden:
- Kleiner oder gleich: MyApp.le([Wert])
- Größer oder gleich: MyApp.ge([Wert])
- Zwischen zwei Werten: MyApp.range([Wert1],[Wert2])
- Einer von verschiedenen Werten: MyApp.in([Wert1],[Wert2],...)
Alle Werte der Tabelle „parameter_constraint“ die nicht mit "MyApp." beginnen sind dann wie ganz normale Werte über Gleichheit zu prüfen. Alle anderen sind zu interpretieren. Und ganz ehrlich: Diese Interpretation sollte nicht in der Anwendung passieren, wenn man mehr als 10.000 Datensätze überprüft. Datenbanken sind in der Regel deutlich schneller beim Filtern von Daten als man das selber irgendwie in der Anwendung (in welcher Sprache auch immer) hinbekommen könnte.
Ok, der Plan steht: Wir interpretieren die Werte der Contraint-Tabelle und führen basierend darauf den Vergleicht mit dem Ist-Wert durch. Das können wir in SQL und einer Datenbank mit Benutzer definierbaren Funktionen folgendermaßen erledigen:
select pv.name, pc.value, pc.description from parameter_value pv join parameter_constraint pc on pc.name = pv.name where not isValid( pc.value, pv.value )
Die Datenbank-Funktion "isValid()" hat also zwei Parameter: Den Soll- bzw. Constraint-Wert und den Ist-Wert und gibt "true" (sofern die Datenbank diesen Typ unterstützt) bei Einhaltung des Constraints und andernfalls "false" zurück.
Diese Funktion ist in der Regel relativ trivial (so trivial Parser eben sein können) implementiert und prüft zuerst, ob der Constraint einem regulären Ausdruck entspricht, der zu unserer Syntax passt. Ist dies nicht der Fall, können beide Werte einfach verglichen und der jeweilige Rückgabewert ermittelt werden.
Andernfalls ist der Operand zu ermitteln - das ist bei uns alles bis zur ersten öffnenden Klammer. Über ein einfaches "if-elseif-else" oder "case" können bekannte Operatoren implementiert werden. Die Ermittlung der Operanden ist in dem jeweiligen Block durchzuführen, damit aber auch direkt an der richtigen Stelle zu finden.
Unsere Constraint-Tabelle kann nun erweitert werden:
Parameter | Wert | Beschreibung |
---|---|---|
A.x | 10 | Fester Wert |
A.y | 20 | Fester Wert |
C.x | 10 | Fester Wert |
C.z | Muss leer sein | |
B.x | MyApp.le(10) | Kleiner oder gleich 10 |
B.y | MyApp.range(10,20) | Zwischen 10 und 20 |
Es bleibt aber immer noch ein Problem übrig: Bei einer Relation wie "LessOrEquals" fehlt ein Default-Wert, der in der Ergebnismenge angezeigt werden soll - hier wollen wir sicher nicht eine Relation wie "MyApp.le(10)" stehen haben sondern einen Vorgabewert wie "8".
Auch hier kann man sich wieder verschiedene Lösungen vorstellen (z.B. eine separate Spalte für Default-Werte), eine leichte Erweiterung unserer Syntax bringt uns aber auch hier wieder ans Ziel: Z.B. kann der schließenden Klammer einfach durch einen Doppelpunkt getrennt der Vorgabewert angehängt werden: "MyApp.le(10):8".
Damit ist uns aber noch nicht geholfen, denn die Funktion "isValid()" liefert einen boolschen Wert, der uns in der Ergebnismenge nicht interessiert - wir wissen ja, dass nur die ungültigen Werte vorkommen.
Wir benötigen also eine weitere Funktion, die lediglich den Default-Wert ermittelt. Man könnte meinen, dass diese Funktion lediglich den Constraint-Wert benötigt, um diesen Wert zu ermitteln. Es sind aber Fälle vorstellbar, bei denen der Default-Wert vom aktuellen Wert abhängt, die Operation also gar keinen expliziten Default-Wert besitzt.
Ein Beispiel für eine solche Funktion ist „isUppercase()“ (übrigens ohne Parameter), deren Default-Wert natürlich die Uppercase-Version des aktuellen Werts sein soll.
Die finale Fassung unseres SQL-Statements sieht also etwa folgendermaßen aus:
select pv.name, evaluateDefault(pc.value, pv.value) as value, pc.description from parameter_value pv join parameter_constraint pc on pc.name = pv.name where not isValid(pc.value, pv.value)
Das Statement bleibt schlank, die Ausführung ist auch für große Datenmengen schnell und um die Menge an Operationen zu erweitern muss lediglich die Implementierung der Funktionen "isValid()" und "evaluateDefault()" erweitert werden.
Viel Spaß beim Programmieren, Gruß, Carsten Hetzel.