Komplexe Vergleichsfunktionen in Datenbanken

Von am

Datenbanken 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?

Der Kunde möchte Parameter seiner Anwendung bzw. seines Datenbestandes gegen Vorgabewerte vergleichen.

Gehen wir von folgendem Datenbestand aus:

Datentabelle (parameter_value)
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.

Constraint-Tabelle (parameter_constraint)
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
					
Vorsicht: Man sollte sich bewusst sein, dass der Vergleich von Null-Werten von Datenbank zu Datenbank unterschiedlich behandelt werden muss. Das gezeigte SQL-Statement ist also bereits zumindest in diesem Punkt als problematisch zu betrachten. Der Vergleich ist z.B. mit Funktionen wie "nvl()" (Oracle) oder "coalesce()" (PostgreSQL) abzusichern, so dass Parameter ohne Wert bei Vergleichen ebenfalls als Abweichung erkannt werden.

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 eine sich verändernde Menge von Parametern, deren Werte mit Einschränkungen verbunden sein können. Art und Umfang der Einschränkungen soll (in gewissem Rahmen) im laufenden Betrieb verändert werden können.

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.

Achtung: Bei der Implementierung von Operationen wie "LessOrEqual" sind natürlich Vergleiche von Zahlen und Vergleiche von Zeichenketten unterschiedlich zu implementieren, damit das korrekte Ergebnis der Relation heraus kommt. Bei Strings ist eben "2" in der Regel größer als "12"!

Unsere Constraint-Tabelle kann nun erweitert werden:

Constraint-Tabelle (parameter_constraint)
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.