Records – Hilfsklasse für einfaches Filtern
Für die programmatische Erstellung einer Datensatz-Filterung gab es bisher nur die Klasse SQLGenerator, welche teilweise schwierig zu bedienen war. Nun gibt es eine einfachere Möglichkeit mit besserer Typunterstüzung: die Klasse com.batix.table.Records. In Groovy-Code gibt es einen ähnlichen Helfer (findRecords), aber auch dort sollte Records vorgezogen werden.
Diese hat z. B. den Vorteil, dass die möglichen Filter für die unterschiedlichen Feldtypen ausdefiniert sind - man bekommt sie also per Code-Completion von der IDE vorgeschlagen und muss sich keine Zahlen merken. Außerdem wurden bestimmte Filter nochmals unterteilt, um beispielsweise NULL Werte zuzulassen oder nicht.
Auch in Bezug auf Sicherheit sollte der Einsatz von Records favorisiert werden: Es können aus Versehen keine Filter definiert werden, die in manchen Fällen dazu führen, dass die Filterung ausgehebelt wird (also man alle Datensätze sieht). Das war z. B. bisher der Fall, wenn man davon ausging, dass bestimmte Requestparameter immer da sind, es dann aber durch einen Fehler oder gezielten Angriff doch nicht so war.
Verfügbar ab CMS Version 2.7.3
Beispiel
Zur schnellen Übersicht ist hier ein Beispiel, in dem viele Methoden verwendet werden.
tmd.records()
.connection(conn) // optional
.activeOrInactive() // default active()
.asc("timestamp").descNullsLast("sent_at") // nach mehreren Sachen sortieren
.index(10)
.limit(5)
.exclude("123ABC") // bestimmte DS-IDs includen/excluden
.filterCheckbox("valid", "ignoreValid").checked() // mehrere Felder mit oder verknüpft, d. h. mind. eins von beiden muss angehakt sein
.filterSingleLink("Kategorie").anyId(collectionOfStrings)
.filterMultipleLink("docs").containsAny("123ABC", "456DEF")
.filterInteger("age").nullOr().greaterOrEquals(18)
.filterFloat("price").lesser(100)
.filterString("Vorname").notNullAnd().contains("test")
.filterString("Nachname").nullOr().startsWith("test")
.filterDate("Geburtsdatum").onYear(2000)
.filterDate("Geburtsdatum").onMonthDay(MonthDay.now())
.filterTime("wann").onHour(14)
.filterTime("wann").later(LocalTime.of(13, 37))
.filterDocument("doc").id("123ABC")
//.count() -> int
//.firstOrNull() -> ContainerRecord
.forEach(rec -> { /*...*/ });
Allgemeines
Um den Zusammenbau der Konfiguration zu starten, wird eine Instanz von TableMetadata benötigt (im Beispiel oben tmd). Dann kann einfach tmd.records() oder Records.from(tmd) aufgerufen werden.
Die Klasse ist im Builder-Style gehalten. Es können also mehrere Bedingungen hintereinander definiert werden. Die Reihenfolge ist egal, da noch nichts ausgeführt, sondern nur die Abfrage-Konfiguration erstellt wird. Es sollte auf sinnvolle Zeilenumbrüche zur besseren Lesbarkeit geachtet werden (wie im Beispiel oben).
Es muss nicht zwangsläufig alles hintereinander geschrieben werden. Man kann sich die `Records` Instanz auch auf eine Variable legen und die Methoden nur in bestimmten Fällen aufrufen, z. B. anhand von Request-Parametern.
final Records.RecordsBuilder recs = tmd.records()
.filterCheckbox("deleted").notChecked()
.asc("Titel")
.limit(10);
if (reqSuche != null && reqSuche.length() > 0) {
recs.filterString("Beschreibung").notNullAnd().contains(reqSuche);
}
int cnt = recs.count();
In Kotlin kann auch z. B. apply benutzt werden:
val cnt = tmd.records().apply {
filterCheckbox("deleted").notChecked()
if (!reqSuche.isNullOrEmpty()) {
filterString("Beschreibung").notNullAnd().contains(reqSuche)
}
asc("Titel")
limit(10)
}.count()
Man kann sich keinen alten Stand der Konfiguration merken, da von der `Records` Instanz, die man konfiguriert bei den einzelnen Methoden keine Kopie erzeugt wird, sondern diese direkt verändert wird.
Damit die Abfrage ausgeführt wird, ist als letztes eine der Ergebnis-Methoden aufzurufen. Diese können auch mehrfach aufgerufen werden, die Abfrage wird dann immer wieder mit den aktuellen Einstellungen neu gestartet.
Bei Methoden, die mehrere Testwerte entgegennehmen, kann auch eine Collection des entsprechenden Typs anstatt einzelner Parameter übergeben werden.
Connection
.connection(/* Connection */)
.connection(request)
Mittels .connection() kann eine Datenbank-Connection übergeben werden. Dies ist nicht unbedingt nötig, da Records ansonsten selbst eine Connection im Hintergrund aufmacht. Das kann aber bei vielen Abfragen zu Performance-Problemen führen, da immer wieder eine neue Connection aufgemacht wird. Daher sollte man sich angewöhnen, eine Connection zu übergeben. Das geht direkt mit .connection(conn) oder, falls man nur einen Request zur Hand hat, mit .connection(request).
Filtern
Eine Suche nach Feldwerten kann mit den .filter...() Methoden definiert werden. Für jeden Feldtyp gibt es eine entsprechende Methode.
Der .filter...() Methode wird dann der Name des zu filternden Feldes übergeben.
Einer `.filter...()` Methode können auch mehrere Feldnamen übergeben werden (als einzelne Parameter). Das führt zu einer ODER-Verknüpfung der Felder: Mindestens eins dieser Felder muss also die Bedingung erfüllen.
Das entspricht im XML-Filter der Angabe mehrerer `<field>` Elemente.
Beispiel
.filterString("Vorname", "Nachname").notNullAnd().contains("muster")
Alle Filtermethoden prüfen ihre Argumente auf `null` bzw. leer (z. B. bei Strings) und lehnen solche Werte mit einer Exception ab.
Das bedeutet man kann weder explizit, noch aus Versehen, einen Testwert übergeben, der nicht filtert (beispielsweise einen Leerstring beim Filtern einer "Besitzer" Verknüpfung).
Somit werden bestimmte Bugs und Angriffe zur Laufzeit verhindert. Soll ein Feld nur in manchen Fällen gefiltert werden, ist das obige Beispiel unter [Allgemeines](#bkmrk-allgemeines) anzuwenden.
Nachfolgend sind die Feldtypen mit ihren zugehörigen Filter-Methoden aufgeführt.
Text
.filterString("Feldname")...
Leer abfragen
.filterString("Feldname").empty()
.filterString("Feldname").notEmpty()
Leer erlaubt oder nicht erlaubt
.filterString("Feldname").notNullAnd()...
.filterString("Feldname").nullOr()...
Der Text-Filter teilt sich in zwei Unterbereiche, die aber im Prinzip dieselben Methoden unterstützen. Sie unterscheiden sich dadurch, dass leere Felder entweder von vornherein ausgeschlossen werden, oder alternativ, dass leere Felder auch die Bedingung erfüllen (diese Variante wird z. B. angewendet, falls es ein einschränkendes Feld gibt, das aber auch leer sein kann, was dann bedeutet, dass die Einschränkung nicht greift und demzufolge der Datensatz auch mit gelistet werden soll).
Gleichheit
.filterString("Feldname").notNullAnd().value("Testwert")
.filterString("Feldname").notNullAnd().valueAny("Testwert 1", "Testwert 2")
.filterString("Feldname").notNullAnd().notValue("Testwert")
.filterString("Feldname").notNullAnd().notValueAny("Testwert 1", "Testwert 2")
.filterString("Feldname").nullOr().value("Testwert")
.filterString("Feldname").nullOr().valueAny("Testwert 1", "Testwert 2")
.filterString("Feldname").nullOr().notValue("Testwert")
.filterString("Feldname").nullOr().notValueAny("Testwert 1", "Testwert 2")
Um den kompletten Feldinhalt zu prüfen, wird eine der value Methoden verwendet.
Da die `...Any()` Methoden intern die multiplen Testwerte in einen kommagetrennten String schreiben, ist die Verwendung eines Kommas in Testwerten für diese Methoden nicht möglich bzw. führt zu fehlerhaften Ergebnissen!
Partielle Übereinstimmung
.filterString("Feldname").notNullAnd().contains("Testwert")
.filterString("Feldname").notNullAnd().containsAny("Testwert 1", "Testwert 2")
.filterString("Feldname").notNullAnd().containsAll("Testwert 1", "Testwert 2")
.filterString("Feldname").notNullAnd().startsWith("Testwert")
.filterString("Feldname").notNullAnd().endsWith("Testwert")
.filterString("Feldname").notNullAnd().regex("Pattern")
.filterString("Feldname").notNullAnd().notContains("Testwert")
.filterString("Feldname").notNullAnd().notContainsAny("Testwert 1", "Testwert 2")
.filterString("Feldname").notNullAnd().notContainsAll("Testwert 1", "Testwert 2")
.filterString("Feldname").notNullAnd().notStartsWith("Testwert")
.filterString("Feldname").notNullAnd().notEndsWith("Testwert")
.filterString("Feldname").notNullAnd().notRegex("Pattern")
.filterString("Feldname").nullOr().contains("Testwert")
.filterString("Feldname").nullOr().containsAny("Testwert 1", "Testwert 2")
.filterString("Feldname").nullOr().containsAll("Testwert 1", "Testwert 2")
.filterString("Feldname").nullOr().startsWith("Testwert")
.filterString("Feldname").nullOr().endsWith("Testwert")
.filterString("Feldname").nullOr().regex("Pattern")
.filterString("Feldname").nullOr().notContains("Testwert")
.filterString("Feldname").nullOr().notContainsAny("Testwert 1", "Testwert 2")
.filterString("Feldname").nullOr().notContainsAll("Testwert 1", "Testwert 2")
.filterString("Feldname").nullOr().notStartsWith("Testwert")
.filterString("Feldname").nullOr().notEndsWith("Testwert")
.filterString("Feldname").nullOr().notRegex("Pattern")
Teile des Feldinhaltes können mit diesen Methoden überprüft werden.
Da die `...Any()` und `...All()` Methoden intern die multiplen Testwerte in einen kommagetrennten String schreiben, ist die Verwendung eines Kommas in Testwerten für diese Methoden nicht möglich bzw. führt zu fehlerhaften Ergebnissen!
Bild
.filterPicture("Feldname")...
Leer abfragen
.filterPicture("Feldname").empty()
.filterPicture("Feldname").notEmpty()
Ziel-Datensatz existiert
.filterPicture("Feldname").exists()
.filterPicture("Feldname").notExists()
Diese Filter überprüfen, ob es ein Bild mit der im Feld hinterlegten ID auch wirklich gibt.
ID abfragen
.filterPicture("Feldname").id("123456ABCDEF")
Datei
.filterDocument("Feldname")...
Leer abfragen
.filterDocument("Feldname").empty()
.filterDocument("Feldname").notEmpty()
ID abfragen
.filterDocument("Feldname").id("123456ABCDEF")
Datum mit oder ohne Uhrzeit
.filterDate("Feldname")...
Die Filtermethoden nehmen aus Sicherheitsgründen keinen String entgegen, da dieser evtl. nicht im vom Filter erwarteten Format ist. Es muss also eine Instanz von `Date | LocalDate | LocalDateTime` übergeben werden. Bei Feldern ohne Uhrzeit wird nur der Datumsteil des Testwertes gelesen.
Falls man nur einen String hat, muss man diesen vorher in ein `LocalDate(Time)` Objekt umwandeln. Man erstellt dazu einen Formatter mit dem passenden Pattern und parst den String zum gewünschten Objekt.
Beispiele
LocalDateTime.parse("zu parsender String", DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))
LocalDate.parse("zu parsender String", DateTimeFormatter.ofPattern("dd.MM.yyyy"))
Leer abfragen
.filterDate("Feldname").empty()
.filterDate("Feldname").notEmpty()
(Teil)Gleichheit
.filterDate("Feldname").onDate(/* Date | LocalDate | LocalDateTime */)
.filterDate("Feldname").onDay(/* int (1-31) */)
.filterDate("Feldname").onMonth(/* int (1-12) */)
.filterDate("Feldname").onMonthDay(/* MonthDay */)
.filterDate("Feldname").onYear(/* int */)
.filterDate("Feldname").onYearMonth(/* YearMonth */)
.filterDate("Feldname").notOnDate(/* Date | LocalDate | LocalDateTime */)
Abfrage neuer / älter bzw. gleich
.filterDate("Feldname").newer(/* Date | LocalDate | LocalDateTime */) // Feld > Testwert
.filterDate("Feldname").newerEquals(/* Date | LocalDate | LocalDateTime */) // Feld >= Testwert
.filterDate("Feldname").older(/* Date | LocalDate | LocalDateTime */) // Feld < Testwert
.filterDate("Feldname").olderEquals(/* Date | LocalDate | LocalDateTime */) // Feld <= Testwert
Abfrage neuer / älter oder Leer
.filterDate("Feldname").nullOr().newerEquals(/* Date | LocalDate | LocalDateTime */)
.filterDate("Feldname").nullOr().olderEquals(/* Date | LocalDate | LocalDateTime */)
Uhrzeit
.filterTime("Feldname")...
Auch hier nehmen die Methoden - wie oben bei Datum mit oder ohne Uhrzeit - keinen String entgegen. Dieser muss zunächst in ein passendes Objekt umgewandelt werden. Bei Objekten vom Typ `Date` oder `LocalDateTime` wird nur der Uhrzeitteil gelesen.
Beispiele
LocalTime.parse("zu parsender String", DateTimeFormatter.ofPattern("HH:mm:ss"))
LocalDateTime.parse("zu parsender String", DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss"))
Leer abfragen
.filterTime("Feldname").empty()
.filterTime("Feldname").notEmpty()
(Teil)Gleichheit
.filterTime("Feldname").onTime(/* Date | LocalTime | LocalDateTime */)
.filterTime("Feldname").onHour(/* int (0-23) */)
.filterTime("Feldname").onMinute(/* int (0-59) */)
.filterTime("Feldname").onSecond(/* int (0-59) */)
.filterTime("Feldname").notOnTime(/* Date | LocalTime | LocalDateTime */)
Abfrage neuer / älter bzw. gleich
.filterTime("Feldname").later(/* Date | LocalTime | LocalDateTime */) // Feld > Testwert
.filterTime("Feldname").laterEquals(/* Date | LocalTime | LocalDateTime */) // Feld >= Testwert
.filterTime("Feldname").earlier(/* Date | LocalTime | LocalDateTime */) // Feld < Testwert
.filterTime("Feldname").earlierEquals(/* Date | LocalTime | LocalDateTime */) // Feld <= Testwert
Abfrage neuer / älter oder Leer
.filterTime("Feldname").nullOr().laterEquals(/* Date | LocalTime | LocalDateTime */)
.filterTime("Feldname").nullOr().earlierEquals(/* Date | LocalTime | LocalDateTime */)
Ganzzahl
.filterInteger("Feldname")...
Leer abfragen
.filterInteger("Feldname").empty()
.filterInteger("Feldname").notEmpty()
Gleichheit
.filterInteger("Feldname").value(/* Number */)
.filterInteger("Feldname").notValue(/* Number */)
.filterInteger("Feldname").nullOr().value(/* Number */)
.filterInteger("Feldname").nullOr().notValue(/* Number */)
Abfrage größer / kleiner
.filterInteger("Feldname").greater(/* Number */)
.filterInteger("Feldname").greaterOrEquals(/* Number */)
.filterInteger("Feldname").lesser(/* Number */)
.filterInteger("Feldname").lesserOrEquals(/* Number */)
.filterInteger("Feldname").nullOr().greater(/* Number */)
.filterInteger("Feldname").nullOr().greaterOrEquals(/* Number */)
.filterInteger("Feldname").nullOr().lesser(/* Number */)
.filterInteger("Feldname").nullOr().lesserOrEquals(/* Number */)
Dezimalzahl, Preis
.filterFloat("Feldname")...
Die Methoden sind identisch zu Ganzzahl, es muss lediglich filterFloat als Methode verwendet werden.
Alle Vergleiche auf Gleichheit bzw. Ungleichheit verwenden eine maximale Genauigkeit von 0,00001.
Das bedeutet, dass z. B. 0,000004 und 0,000005 für den Filter gleich sind.
Wahrheitswert (Checkbox)
.filterCheckbox("Feldname")...
Prüfen ob angehakt
.filterCheckbox("Feldname").checked()
.filterCheckbox("Feldname").notChecked()
Prüfen ob leer
.filterCheckbox("Feldname").empty()
.filterCheckbox("Feldname").notEmpty()
Dies ist in den meisten Fällen nicht der richtige Filter und es sollte "Prüfen ob angehakt" benutzt werden.
Hier wird nur geprüft, ob noch kein expliziter Wert im Feld steht, egal ob angehakt oder nicht angehakt.
Wurde die Checkbox also als **nicht angehakt** gespeichert, ist das Feld **nicht leer** (da "n" oder 0 drin steht).
Koordinaten
.filterCoordinate("Feldname")...
Dieser Feldtyp ist veraltet und wird nicht mehr benutzt. Er wird hier nur der Vollständigkeit halber erwähnt.
Prüfen ob leer
.filterCoordinate("Feldname").empty()
.filterCoordinate("Feldname").notEmpty()
Gleichheit der Koordinaten
.filterCoordinate("Feldname").x(/* Number */)
.filterCoordinate("Feldname").y(/* Number */)
Einzelverknüpfung
.filterSingleLink("Feldname")...
Prüfen ob leer
.filterSingleLink("Feldname").empty()
.filterSingleLink("Feldname").notEmpty()
Prüfen ob Datensatz existiert
.filterSingleLink("Feldname").exists()
.filterSingleLink("Feldname").notExists()
Es wird geprüft, ob es auch einen Datensatz mit der hinterlegten ID gibt bzw. nicht gibt.
Prüfen ob Datensatz aktiv
.filterSingleLink("Feldname").active()
.filterSingleLink("Feldname").notActive()
Es wird geprüft, ob es den Datensatz gibt. Dieser muss außerdem aktiviert bzw. deaktiviert sein.
Nach ID suchen
.filterSingleLink("Feldname").id("123456ABCDEF")
.filterSingleLink("Feldname").notId("123456ABCDEF")
.filterSingleLink("Feldname").anyId("123456ABCDEF", "ABCDEF123456")
Mehrfachverknüpfung
.filterMultipleLink("Feldname")...
Prüfen ob leer
.filterMultipleLink("Feldname").empty()
.filterMultipleLink("Feldname").notEmpty()
Nach ID suchen
.filterMultipleLink("Feldname").contains("123456ABCDEF")
.filterMultipleLink("Feldname").notContains("123456ABCDEF")
.filterMultipleLink("Feldname").containsAny("123456ABCDEF", "ABCDEF123456")
.filterMultipleLink("Feldname").containsAll("123456ABCDEF", "ABCDEF123456")
Bei containsAny reicht es, wenn eine der übergebenen IDs im Feld verknüpft ist. Für containsAll müssen alle übergebenen IDs verknüpft sein. In beiden Fällen können aber auch noch andere IDs verknüpft sein.
Untertabelle (Untercontainer)
.filterSubList("Feldname")...
Prüfen ob leer
.filterSubList("Feldname").empty()
.filterSubList("Feldname").notEmpty()
Aktiv / Inaktiv
.active()
.activeOrInactive()
.inactive()
Standardmäßig werden nur aktive Datensätze zurückgegeben. Dies lässt sich aber anpassen.
IDs inkludieren / exkludieren
.include("123456ABCDEF", "ABCDEF123456")
.exclude("123456ABCDEF", "ABCDEF123456")
Zusätzlich zu den Feldfiltern besteht die Möglichkeit nur bestimmte Datensätze anhand ihrer ID zuzulassen oder auszuschließen.
Werden Feldfilter und ID-Filter benutzt, müssen beide zutreffen. Ein mittels include angegebener Datensatz taucht also nicht zwangsläufig auf, sondern nur wenn alle anderen Bedingungen auch passen (sofern welche definiert wurden).
Paginierung
.limit(/* Integer */)
.index(/* Integer */) // 0-basiert
Die Anzahl der Ergebnisse kann mit limit begrenzt werden. Ebenso kann die Startposition mit index festgelegt werden.
Da `index` nullbasiert ist, startet man bei einer beispielhaften Seitenanzahl von zehn Ergebnissen mit dem elften Ergebnis (also Seite zwei), indem man `index(10)` angibt und nicht etwa `11` .
Sortierung
.asc("Feldname") // aufsteigend
.desc("Feldname") // absteigend
.ascNullsLast("Feldname")
.descNullsLast("Feldname")
Es kann nach mehreren Feldern sortiert werden. Dabei ist die Reihenfolge entscheidend. Es wird zuerst nach dem ersten angegebenen Sortierfeld sortiert, dann nach dem zweiten, usw. für alle angegebenen Sortierfelder.
Standardmäßig werden NULL Werte an den Anfang sortiert. Dies kann mit den ...NullsLast() Methoden geändert werden.
Im folgenden Beispiel wird also zunächst nach timestamp aufsteigend sortiert. Datensätze mit leerem timestamp Feld werden zuerst zurückgegeben.
Danach werden Datensätze mit gleichem Wert bei timestamp nochmals anhand sent_at absteigend sortiert. Dabei werden solche mit leerem sent_at ans Ende der Untersortierung gesetzt.
.asc("timestamp")
.descNullsLast("sent_at")
Ergebnisse laden
Es gibt verschiedene Möglichkeiten die Ergebnisse abzurufen. Wie bereits erwähnt kann dies auch mehrfach geschehen, indem man sich den Records Builder auf eine Variable legt und dessen Ergebnismethoden mehrfach aufruft.
Anzahl
.count() // int
Diese Methode gibt lediglich die Anzahl der gefundenen Datensätze zurück.
Erster Datensatz
.firstOrNull() // ContainerRecord oder null
Um nur den ersten Datensatz zu laden, kann diese Methode benutzt werden. Entspricht kein Datensatz dem Filter, wird null zurückgegeben, ansonsten ein ContainerRecord.
Datensätze durchgehen
.forEach((ContainerRecord record) -> {
// ...
});
Hier werden die Datensätze aus der Datenbank gestreamt und dem Lambda (der Closure) einzeln übergeben. Die Methode forEach blockiert solange, bis alle Datensätze vom Lambda verarbeitet wurden.
Es werden dabei nicht zunächst alle Datensätze in den Speicher geladen, was der Performance zuträglich ist. Daher ist dies die präferierte Methode um Ergebnisse zu durchlaufen.
Stream
.stream() // Stream<ContainerRecord>
Genau wie bei forEach werden hier die Datensätze aus der Datenbank gestreamt. Man erhält allerdings direkten Zugriff auf den Stream.
Der Stream muss geschlossen werden, da sonst Leaks entstehen, da die Datenbankobjekte nicht aufgeräumt werden (weil der Stream ja nicht weiß, dass er fertig ist).
Das geschieht z. B. automatisch durch terminale Stream-Methoden oder manuell mittels `close()`.
Alle Datensätze laden
.loadAll() // ContainerRecord[]
Es wird ein Array zurückgegeben. Falls keine Elemente gefunden wurden, ist das Array leer (nicht null).
Diese Methode lädt alle gefundenen Datensätze in den Speicher, was zu Abstürzen fehlen kann. Daher sind die anderen Methoden vorzuziehen.