Montag, 25. Januar 2016

Baum Abfragen mit SQL und CTE (Common Table Expression)

Ich habe eine interessante Projektanfrage bekommen. Es geht um die Erstellung von Auswertungen für Telefondaten z.B. aus einem Call Center.
Die Daten liegen in einer SQL-Datenbank vor und der aktuelle Bearbeiter kommt nicht so richtig weiter.
Ein Problem ist, dass die Weiterverbindung von Anrufen in einer Baumstruktur gespeichert wird. Zur Auswertung müssen Daten aller verbundenen Knoten (Teilnehmer) aggregiert werden.

Mit Programmlogik sicher kein Problem aber nur mit SQL? Mir fällt spontan Oracles CONNECT BY PRIOR ein aber es ist ein anderes DB-System.

Ich recherchiere dazu und stoße schließlich auf CTE. Common Table Expression sind im SQL 99 Standard definiert und werden von vielen großen Datenbanksystemen unterstützt (z.B. Oracle, DB/2, MSSQL Server, PostGre und mehr).

Die Syntax ist etwas gewöhnungsbedürftig und ich habe mir ein kleines Beispiel erstellt.


Tabelle mit Anrufen:

create table phone_call (
  id int primary key,
  delay int not null,
  duration int not null,
  parent_id int,
  agent varchar2(10)
);


  • id: Primärschlüssel des Anrufs
  • delay: Verzögerung in Sekunden bis ein Mitarbeiter abnimmt
  • duration: Dauer des Gesprächs
  • parent_id: Falls der Anruf innerhalb des Callcenters weiter verbunden wurde verweist parent_id auf den Anruf von dem weiterverbunden wurde.
  • agent: Mitarbeiter des Callcenters (vereinfacht ohne Relation)

Die folgenden Inserts erzeugen Beispieldaten:

INSERT INTO phone_call VALUES(1, 3, 20, null, 'Müller');
INSERT INTO phone_call VALUES(2, 4, 15, 1, 'Meier');
INSERT INTO phone_call VALUES(3, 3, 300, 2, 'Schulze');

INSERT INTO phone_call VALUES(4, 5, 200, null, 'Pinnau');

INSERT INTO phone_call VALUES(5, 3, 20, null, 'Schulze');
INSERT INTO phone_call VALUES(6, 5, 120, 5, 'Pinnau');


  • Die Beispieldaten loggen 3 von aussen eingehende Anrufe (erkennbar an parent_id = null). Der 1. Anruf wird von Müller weiter zu Meier und dann zu Schulze verbunden.
  • Der 2. Anruf bleibt bei Pinnau und wird nicht weiterverbunden.
  • Der 3. Anruf wird von Schulze angenommen und zu Pinnau verbunden.

Es soll jetzt eine Auswertung gemacht werden. Im Ergebnis soll jeder von aussen eingehende Anruf ausgegeben werden und folgende Statistiken geliefert werden:

  1. Gesamtzeit des Telefonats (alle Stationen: delay + duration)
  2. Gesamtzeit Warten (alle Stationen: delay)
  3. Gesamtzeit Sprechen (alle Stationen: duration)
  4. Anzahl der Weiterverbindungen

Für den Anfang starte ich mit folgendem CTE-SQL Block:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
        WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, 0
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT * from call_tree

Die entscheidenden Zeilen befinden sich innerhalb des WITH-Blocks. Um einen Baum von oben nach unten zu traversieren, startet man zunächst mit der Abfrage der Wurzel-Element. Im Beispiel sind das alle Anruf-Datesätze mit parent_id is null:

SELECT id, delay, duration, 0 FROM phone_call WHERE parent_id is null

Mit einem UNION ALL werden dann die Ergebnisse der rekursiven Abfrage (Kinder und Kindeskinder) hinzugefügt:

SELECT id, delay + c_delay, duration + c_duration, 0
    FROM phone_call, call_tree WHERE parent_id = c_id

Im rekursive Teil  werden die Tabellen phone_call und call_tree (WITH-Alias) per INNER JOIN verbunden (WHERE parent_id = c_id).

Das Ergenbnis sieht schonmal ganz gut aus. Es werden alle Anrufstationen mit den im Baum aufgelaufenen Summen für delay und duration ausgegeben.
C_ID C_DELAY C_DURATION C_HOPS
1 3 20 0
4 5 200 0
5 3 20 0
2 7 35 0
6 8 140 0
3 10 335 0

Es gibt noch 3 Probleme:

  1. c_hops ist immer 0 (Anzahl der Weiterverbindungen)
  2. Die Ergebnismenge enthält für jeden Datensatz aus phone_call eine Zeile. Es soll nur eine Zeile pro eingehendem Anruf kommen.
  3. Gesamtzeit aus duration + delay fehlt noch

Um die Hops zu ermitteln muss im rekursiven Teil die 0 nur durch c_hops+1 ersetzt werden. Im Initialteil bleibt die 0 stehen, damit der Hop-Zähler bei 0 anfängt:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
        WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, c_hops+1
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT * from call_tree

Jetzt sollen nur noch die letzten Datensätze im Baum (also die Blätter) angezeigt werden. Das ist etwas schwierig. Mir ist erstmal nichts besseres eingefallen als in der äußeren Abfrage mit einem COUNT()-Subquery zu filtern:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
        WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, c_hops+1
       FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT * from call_tree

-- Only leafs: children count = 0
WHERE ( SELECT count(*) from phone_call WHERE parent_id = c_id ) = 0

Im letzten Schritt muss noch die Summe aus delay und duration eingebaut werden:

WITH call_tree (c_id, c_delay, c_duration, c_hops) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, 0 FROM phone_call
       WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay + c_delay, duration + c_duration, c_hops+1
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT c_id, c_delay + c_duration as c_total, c_delay,
    c_duration from call_tree
WHERE (SELECT count(*) from phone_call WHERE parent_id = c_id) = 0

Ergebnis:
C_ID C_TOTAL C_DELAY C_DURATION
6 148 8 140
4 205 5 200
3 345 10 335



Man könnte noch mehr machen und z.B. auch die Namen der an der Anrufkette beteiligten Mitarbeiter als verketteten VARCHAR ausgeben usw.

Verbesserung


Für das konkrete Beispiel ist mir noch eine wesentliche Verbesserung eingefallen mit der auch das COUNT Subquery entfallen kann.
Dazu wird die Root-ID in alle Datensätze aufgenommen. Diese wird im Initial-Query selektiert und in den rekursiven Abfragen unverändert durchgeleitet. Damit kann man zum Schluß nach der Root-ID gruppieren und die Aggregate für jeden Ast des Baumes bilden:


WITH call_tree (c_id, c_delay, c_duration, c_root_id) AS
(
    -- Initial query (root elements, parent_id is null)
    SELECT id, delay, duration, id FROM phone_call
       WHERE parent_id is null
    
    UNION ALL
    
    -- children (recursive): JOIN phone_call with call_tree ALIAS
    SELECT id, delay, duration, c_root_id
      FROM phone_call, call_tree WHERE parent_id = c_id
)
SELECT c_root_id, sum(c_delay + c_duration) as c_total,
    sum(c_delay) as c_delay, sum(c_duration) as c_duration,
    count(c_root_id) - 1 as hops from call_tree
GROUP BY c_root_id

Die Hops ermittelt man mit COUNT(c_root_id) - 1.

Freitag, 8. Januar 2016

DMS-spezifische Berechtigungen in Dateisystemen (NTFS)

Digitales Document Management ist eines der aktuellen Themen in der IT. Am Markt gibt es viele Systeme die aber alle ihre eigene Suppe kochen.

Ich arbeite intensiv an einer anderen Lösung und ich möchte bei der Speicherung der Dokumente auf vorhandenes setzen.
Im einfachsten Fall werden die Dokumente (PDF) also in einer Ordnerstruktur im Dateisystem abgelegt.
Ein solcher Dokumentbestand wird auch in 20 Jahren problemlos lesbar sein ohne das man spezielle Software aus einem Museum beschaffen und auf der dann vorhandenen Technik zum Laufen bringen muss.

Auf meinem Wunschzettel stehen allerdings ein paar spezielle Berechtigungseinstellungen, die ich bisher noch nicht mit den gebräuchlichen Dateisystemen realisiert habe:


  1. Lese- und Schreibzugriff auf Dateien, jedoch kein Recht zum Löschen von Dateien.
  2. Löschen von leeren Ordnern ohne Inhalt. Jedoch kein Löschen von Ordnern, die Dateien enthalten.
  3. Verschieben von Ordnern soll unterbunden werden.
  4. Nutzer können Dateien in einen Ordner schreiben (Kopieren oder Verschieben), haben danach aber keinen Zugriff mehr darauf. Man muss sich das wie einen Briefkasten vorstellen: Man kann einen Brief für den Besitzer des Briefkastens einwerfen. Man kann aber weder den Inhalt des Briefkastens sehen noch den eingeworfenen Brief oder andere Briefe lesen oder herausnehmen.

Die genannten Szenarios werden relevant sobald mehrere Nutzer an einem digitalen Dokumentbestand arbeiten:


  1. Löschen ist in der Regel unerwünscht und sollte nur durch spezielle Mitarbeiter möglich sein. Ein versehentliches Löschen von Dateien (z.B. durch Drücken der Entfernen-Taste) muss unbedingt verhindert werden.
  2. Da die Ordnerstruktur automatisch aufgebaut wird ist das Verschieben von kompletten Ordnern nicht sinnvoll.
  3. Einzelne Nutzer sollen ggf. Dokumente in den Bestand einstellen danach aber keine Änderungen daran machen können und ggf. auch keinen Lesezugriff haben (Briefkasten).

Ich habe mir zunächst NTFS genauer angesehen. Über die erweiterten Einstellungen stehen insgesamt 14 Berechtigungen zur Verfügung.

Lese- und Schreibzugriff, jedoch kein Recht zum Löschen


Dies lässt sich mit NTFS grundsätzlich realisieren. Der entscheidende Punkt ist der Unterschied zwischen den Berechtigungen Schreiben und Ändern:

  1. Schreiben: Dateiinhalt kann geändert werden, d.h. eine Textdatei kann z.B. über einen Editor bearbeitet werden.
  2. Ändern: Datei kann gelöscht, verschoben oder umbenannt werden.


Damit hat die Sache aber auch schon einen gravierenden Haken. Sobald das Recht Ändern verwehrt wird, kann die Datei auch nicht mehr umbenannt oder verschoben werden.
Technisch laufen Löschen, Verschieben und Umbenennen in nahezu allen Dateisystemen auf dieselbe Operation hinaus.

Für Document Managment bedeutet dies leider, dass die NTFS-Berechtigungen nicht zur Umsetzung der Anforderung geeignet sind.
Das Umbenennen oder Verschieben der Datei innerhalb der Archivstruktur soll möglich sein - nur das Löschen nicht.


Ich bin nicht der erste, der diese Anforderungen hat. Das Netz ist voll von Fragen dazu und es kommen immer die gleichen ernüchternden Antworten.
Zumindest lässt sich die Anforderung teilweise umsetzen. Der Nutzer wäre in der Lage, ein Dokument in den Archivbestand zu übernehmen. Der Inhalt des Dokuments bleibt änderbar, d.h. es können z.B. Seiten angehängt oder Notizen eingefügt werden.
Das Dokument kann nicht gelöscht werden. Allerdings kann er das Dokument auch nicht mehr umbenennen oder innerhalb des Archivs verschieben.

Briefkasten


Der Briefkasten kann grundsätzlich mit NTFS realisiert werden. Dazu muss man die speziellen Berechtigungen setzen:


  • Entziehen: Recht Dateien auflisten/ Daten lesen
  • Entziehen: Recht Ordner durchsuchen / Dateien ausführen
  • Erlauben: Dateien erstellen / Daten schreiben
  • Erlauben: Attribute schreiben
  • Erlauben: Erweiterte Attribute schreiben


Damit kann ein Nutzer Dokumente im entsprechenden Verzeichnis ablegen hat aber weder Lesezugriff auf den Ordner noch auf die Dateien, die sich in diesem Ordner befinden.

Die Sache hat aber wieder einen Haken: Der Nutzer kann im Ordner vorhandene Dateien überschreiben.
Dies widerspricht dem Briefkasten Prinzip. Es ist dabei auch irrelevant ob die zu überschreibende Datei vom Nutzer selbst oder einem anderen Nutzer stammt.




FAZIT


Leider lassen sich trotz der komplexen Einstellungsmöglichkeiten im NFTS die Anforderungen nur begrenzt umsetzen.

Welche Schlussfolgerungen ich daraus ziehe weiss ich noch nicht.


Dienstag, 5. Januar 2016

Umgang mit verschiedenen Rastern und Maßstäben mit Hilfe von Promille Koordinaten

Heute habe ich Quellcode in meinem java-basierten DMS verbessert. Über die betroffenen Funktionen wird dem Nutzer eine Miniaturansicht von PDF-Seiten angezeigt. Mit der Maus kann eine Position bzw. ein Bereich auf einer Dokumentseite festgelegt werden.
Die grafische Darstellung ist mit Hilfe von SWT und Apache PDFBox umgesetzt. Grundsätzlich ist die PDF-Rendering Library austauschbar. Zwischendurch hatte ich JPod im Einsatz. Zur Darstellung wird ein bestimmter Maßstab (Zoomlevel) verwendet.

Die Auswahl des Bereichs ist erforderlich, um eine der folgenden Operationen durchzuführen:

  • Einfärben des Bereichs (schwärzen)
  • Aufkleben einer digitalen Haftnotiz
  • Digital stempeln

Bei der Umsetzung entsteht schnell ein Durcheinander, wenn man mit verschiedenen Koordinatensystemen, Maßstäben usw. arbeitet. Fest kodierte Umrechnungsfaktoren erschweren das Verständnis und die Wartung.
Im Beispiel kommt hinzu, dass die Y-Achse innerhalb des PDF invers ist, d.h. 0 ist unten und nicht oben.

Relative Koordinaten


Um die verschiedenen Koordinatensysteme voneinander zu entkoppeln bin ich dazu übergegangen, alle Angaben (x,y,width,height) relativ in Promille zu verarbeiten:

x(relativ) = x(absolut) / Seitenbreite * 1000
y(relativ) = y(absolut) / Seitenhöhe * 1000

width(relativ) = width(absolut) / Seitenbreite * 1000
height(relativ) = height(absolut) / Seitenhöhe * 1000

Promille bringt gegenüber Prozent einen entscheidenden Vorteil: Für die berechneten Werte reichen in der Regel Ganzzahlen (Integer) aus.

Die relativen Promille-Koordinaten werden im Zielkoordinatensystem wieder in absolute Koordinaten umgerechnet.

Das bringt folgende Vorteile:

  1. Keine Kodierung von Umrechnungsfaktoren zwischen den Koordinatensystemen
  2. Entkoppelung der Koordinatensysteme
  3. In der Darstellung verwendete Maßstäbe (Zoom) sind nicht relevant, da die relativen Verhältnisse immer gleich bleiben.
  4. Zur Transformation in relative bzw. absolute Koordinaten kann ein einfaches API verwendet werden (dazu später mehr)

Transform API


Zur Übergabe von ganzzahligen Promille Koordinaten habe ich zunächst eine ganze einfache Klasse erstellt:

/**
 * Ganzzahlige Koordinaten
 * 
 * @author Peter Pinnau (peter@pinnau.biz)
 *
 */
public class Position {
 
  public int x;
 
  public int y;
 
  public int width;
 
  public int height;

}

Für die Transformation und Berechnung der absoluten Koordinaten in den einzelnen Koordinatensystemen ist bei Apache PDFBox eine höhere Genauigkeit erforderlich. Dafür habe ich die einfache Klasse FloatPosition erstellt. Diese wird aber nur innerhalb eines Koordinatensystems verwendet.

/**
 * Float Koordinaten
 * 
 * @author Peter Pinnau (peter@pinnau.biz)
 *
 */
public class FloatPosition {

 public float x;
 
 public float y;
 
 public float width;
 
 public float height;
  
}

Zur Transformation von ganzzahligen Koordinaten (Position) in Fließkommakoordinaten (FloatPosition) und zur Berechnung von relativen, absoluten und inversen Koordinaten habe ich eine statische Klasse Transform erstellt:


1:  /**  
2:   * Statische Klasse zum Druchführen von Koordinaten- und Größentransformationen  
3:   *   
4:   * @author Peter Pinnau (peter@pinnau.biz)  
5:   *  
6:   */  
7:  public class Transform {  
8:    
9:     /**  
10:      * Erzeugt eine FloatPosition aus einer Position  
11:      * @param p  
12:      * @return  
13:      */  
14:     public static FloatPosition fromPosition(Position p) {  
15:        FloatPosition pos = new FloatPosition();  
16:          
17:        pos.x = p.x;  
18:        pos.y = p.y;  
19:          
20:        pos.height = p.height;  
21:        pos.width = p.width;  
22:          
23:        return pos;  
24:     }  
25:       
26:     /**  
27:      * Erzeugt eine FloatPosition aus x,y,width und height  
28:      *   
29:      * @param x  
30:      * @param y  
31:      * @param width  
32:      * @param height  
33:      * @return  
34:      */  
35:     public static FloatPosition create(float x, float y, float width, float height) {  
36:        FloatPosition pos = new FloatPosition();  
37:          
38:        pos.x = x;  
39:        pos.y = y;  
40:        pos.width = width;  
41:        pos.height = height;  
42:          
43:        return pos;  
44:     }  
45:       
46:     /**  
47:      * Berechnet eine absolute FloatPosition aus Promillewerten und einem Bezugsbereich  
48:      *   
49:      * @param promille  
50:      * @param area  
51:      * @return  
52:      */  
53:     public static FloatPosition fromPromille(FloatPosition promille, FloatPosition area) {  
54:        FloatPosition p = new FloatPosition();  
55:          
56:        float widthFactor = area.width / 1000;  
57:        float heightFactor = area.height / 1000;  
58:          
59:        p.x = promille.x * widthFactor;  
60:        p.y = promille.y * heightFactor;  
61:          
62:        p.width = promille.width * widthFactor;  
63:        p.height = promille.height * heightFactor;  
64:          
65:        return p;  
66:     }  
67:       
68:     /**  
69:      * Berechnet Promillewerte aus absoluten Werten und einem Bezugsbereich  
70:      *   
71:      * @param pos  
72:      * @param area  
73:      * @return  
74:      */  
75:     public static FloatPosition toPromille(FloatPosition pos, FloatPosition area) {  
76:        FloatPosition p = new FloatPosition();  
77:                
78:        p.x = pos.x * 1000 / area.width;        
79:        p.y = pos.y * 1000 / area.height;  
80:          
81:        p.width = pos.width * 1000 / area.width;  
82:        p.height = pos.height * 1000 / area.height;  
83:          
84:        return p;              
85:     }  
86:       
87:     /**  
88:      * Invertiert die Y-Achse  
89:      *   
90:      * y(neu) = area.height - y  
91:      *   
92:      * @param pos  
93:      * @param area  
94:      */  
95:     public static void invertY(FloatPosition pos, FloatPosition area) {        
96:        pos.y = area.height - pos.y;  
97:     }  
98:       
99:  }  
100:    


Alle Methoden erhalten als Übergabeparameter eine Position-Instanz (ganzzahlige, relative Promille-Koordinaten).
Die absoluten Koordinaten im benötigten Koordinatensystem werden dann mit Hilfe der statischen Methoden aus Transform berechnet:



// Als Parameter übergebene relative Koordinaten
Position position ...

// Seite des PDF
PDPage page = pdDocument.getPage(0);
    
// CROPbox (kompletter Seitenbereich)  
PDRectangle pageSize = page.getCropBox();

// FloatPosition für komplette Seite erstellen (Bezugsbereich)
FloatPosition area = Transform.create(0, 0,
    pageSize.width, pageSize.height);   

// Transform Promille Position to absolute FloatPosition
FloatPosition transformed = Transform.fromPromille(
    Transform.fromPosition(position), area);

// Y Achse invertieren (PDF Koordinaten)
Transform.invertY(transformed, area);

// => transformed enthält jetzt die absoluten Koordinaten im Zielsystem


Nachteil


Ich möchte nicht verschweigen, dass die Performance theoretisch schlechter wird. Der Grund dafür ist, dass keine direkte Umrechnung zwischen den Systemen erfolgt (fester Faktor).
Es müssen zunächst relative Koordinaten aus dem Quellsystem ermittelt werden und diese dann wieder in absolute Koordinaten des Zielsystems umgerechnet werden.

Insgesamt ergibt sich logischerweise wieder der feste Faktor, jedoch wird dieser nicht kodiert und ergibt sich immer automatisch anhand der verwendeten Koordinatensysteme.

Diesen geringen Performancenachteil kann ich in meinem System in Kauf nehmen, da keine Massenoperationen durchgeführt werden.
Die Lesbarkeit des Quelltextes hat sich erheblich verbessert und vor Allem sind die unterschiedlichen Koordinatensystem entkoppelt.