SAX: XML-Dokumente parsen in der Praxis

Im Beitrag XML-Dokumente mit SAX parsen haben wir die Grundlagen ds SAX-Parsers vorgestellt. Dabei sind wir soweit gekommen, dass wir den kompletten Inhalt einer XML-Datei im Direktbereich des VBA-Editors ausgegeben haben. Das kann natürlich nicht alles sein: Die Daten sollen ja in der Regel in den Tabellen der Datenbank landen. Den Ereignisprozeduren, welche das XML-Dokument sequenziell durchlaufen, müssen wir dabei natürlich noch die eine oder andere zusätzliche Anweisung hinzufügen, damit die Daten an der gewünschten Stelle gespeichert werden.

Das sequenzielle Einlesen einer XML-Datei und die Ausgabe der gefundenen Daten ist bereits ein guter Startpunkt. Aber wie machen wir dann weiter, um die Daten, die ja meist in mehreren Datensätzen landen sollen, in eine entsprechende Tabelle zu schreiben

Mit dem DOM-Parser war das relativ einfach: Man hat beispielsweise alle Kunde-Elemente mit der SelectNodes-Methode identifiziert und diese dann durchlaufen, wobei für jedes Element ein neuer Datensatz angelegt wurde. Die einzelnen Daten konnte man dann einfach über die entsprechenden Objekte einlesen.

Beim SAX-Parser ist dies völlig anders: Hier kann man nicht mal eben alle Kunde-Elemente durchlaufen. Genau genommen kann der SAX-Parser nur entweder alle Elemente durchlaufen €” oder gar keines.

Dabei werden dann immer die gleichen Ereignisprozeduren ausgelöst, in denen wir lediglich mit den per Parameter übermittelten Daten arbeiten können.

Genau genommen liefert jede Ereignisprozedur für sich noch nicht einmal ausreichend Informationen, um die Inhalte der Elemente den richtigen Elementen zuordnen zu können. Darum müssen wir uns selbst kümmern.

Beispieldaten

Im ersten Beispiel wollen wir die Daten aus der XML-Datei Kunden.xml einlesen (s. Listing 1).

<xml version="1.0" encoding="utf-8">
<Kunden xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                                      xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Kunde KundeID="123">
     <Vorname>André</Vorname>
     <Nachname>Minhorst</Nachname>
   </Kunde>
   <Kunde KundeID="124">
     <Vorname>Hermann</Vorname>
     <Nachname>Müller</Nachname>
   </Kunde>
   <Kunde KundeID="125">
     <Vorname>Klaus</Vorname>
     <Nachname>Meier</Nachname>
   </Kunde>
</Kunden>

Listing 1: Beispiel eines XML-Dokuments mit Kundendaten

Diese sollen in der Tabelle tblKunden landen, die lediglich ein Primärschlüsselfeld namens KundeID sowie zwei Textfelder namens Vorname und Nachname enthält (s. Bild 1).

Die Tabelle tblKunden

Bild 1: Die Tabelle tblKunden

Wir verwenden an dieser Stelle das bereits im Beitrag XML-Dokumente mit SAX parsen verwendete Formular namens frmSAX, um den Einlesevorgang zu starten. Dieses Formular bietet die Möglichkeit, eine Datei auszuwählen und per Mausklick den Einlesevorgang auszulösen (s. Bild 2).

Formular zum Starten des Einlesevorgangs

Bild 2: Formular zum Starten des Einlesevorgangs

Die Schaltfläche cmdEinlesen löst die Prozedur aus Listing 2 aus. Diese verwendet die Klasse clsSAX, welche die Ereignisprozeduren der Schnittstelle MSXML2.IVBSAXContentHandler implementiert. In diese Ereignisprozeduren schreiben wir nachfolgend den Code, der zum Einlesen der Daten aus der XML-Datei Kunden.xml benötigt wird.

Private Sub cmdEinlesen_Click()
     Dim objReader As SAXXMLReader60
     Dim objSax As clsSAX
     Set objReader = New SAXXMLReader60
     Set objSax = New clsSAX
     Set objReader.contentHandler = objSax
     objReader.parseURL Me!txtDatei
     Set objReader = Nothing
End Sub

Listing 2: Start des Einlesevorgangs

Dazu benötigen wir prinzipiell die folgenden Ereignisprozeduren:

  • IVBSAXContentHandler_startDocument(): Wird beim Start des Einlesevorgangs ausgelöst. Hier bringen wir Anweisungen unter, die das Database-Objekt und das Recordset zum Hinzufügen der Daten vorbereiten.
  • IVBSAX-Content-Handler_start-Ele-ment(strName-spaceURI As String, strLocalName As String, strQName As String, ByVal oAttributes As MSXML2.IVBSAXAttributes): Diese Prozedur wird für jedes Element ausgelöst. Hier prüfen wir per Select Case-Anweisung, welches Element gerade durchlaufen wird. Beim Kunde-Element etwa müssen wir einen neuen Datensatz im Recordset anlegen. In unserem Beispiel müssen wir hier auch das Attribut KundeID auslesen und den Wert gleich in das Feld KundeID des neuen Datensatzes eintragen. Bei den Elementen Vorname oder Nachname müssen wir uns merken, dass nun wohl Inhalte für diese Elemente über die Ereignisprozedur IVBSAXContentHandler_characters geliefert werden.
  • IVBSAXContentHandler_characters(strChars As String): Hier werden die eigentlichen Inhalte der Elemente geliefert. Zusammen mit dem zuvor gemerkten Elementnamen können wir diese nun in die entsprechenden Felder eintragen.
  • IVBSAXContentHandler_endElement(strNamespaceURI As String, strLocalName As String, strQName As String): Auch beim Ende eines Elements (etwa ) müssen wir per Select Case verschiedene Fälle untersuchen. Beim Element Vorname oder Nachname brauchen wir nichts zu tun. Beim Element Kunde müssen wir hingegen den Datensatz speichern, den wir zuvor mit den Werten für die Felder KundeID, Vorname und Nachname gefüllt haben.
  • IVBSAXContentHandler_endDocument(): Dies bedeutet das Ende des Einlesevorgangs. Recordset– und Database-Objekt können nun geschlossen beziehungsweise die Objektvariablen geleert werden.

Damit begeben wir uns an die Arbeit. Beachten Sie, dass Sie €” auch wenn wir nur einige der verfügbaren Ereignisse verwenden €” alle Ereignisse der Schnittstelle implementieren müssen.

Deklarationen

Zunächst benötigen wir einige Deklarationen für Objekte, die von mehreren der Ereignisprozeduren verwendet werden. Da wäre zum Beispiel das Database-Objekt, das in der Prozedur IVBSAXContentHandler_startDocument gefüllt und in IVBSAXContentHandler_endDocument wieder geleert werden soll. Daher landet die Deklaration im Kopf des Klassenmoduls clsSAX:

Dim db As DAO.Database

Desweiteren benötigen wir ein Recordset, das wir in startDocument erstellen, in startElement mit einem neuen Datensatz füllen, dessen Felder in IVBSAXContentHandler_characters ihre Werte erhalten, das in IVBSAXContentHandler_endElement gespeichert und das in IVBSAXContentHandler_endDocument geschlossen wird:

Dim rstKunden As DAO.Recordset

Schließlich fehlt noch eine Variable, mit der wir uns merken können, welches Element gerade in IVBSAXContentHandler_startElement durchlaufen wurde:

Dim strFeld As String

Start des Dokuments

Das Ereignis IVBSAXContentHandler_startDocument implementieren wir wie in Listing 3. Diese Prozedur füllt zunächst die Variable db mit einem Verweis auf die aktuelle Datenbank. Die Recordset-Variable rstKunden füllen wir mit der OpenRecordset-Methode, die ein Recordset mit allen Datensätzen der Tabelle tblKunden zurückliefert. Dies alles fassen wir in eine rudimentäre Fehlerbehandlung ein, damit wir Fehler gleich an Ort und Stelle untersuchen können.

Private Sub IVBSAXContentHandler_startDocument()
     On Error Resume Next
     Set db = CurrentDb
     Set rstKunden = db.OpenRecordset("SELECT * FROM tblKunden", dbOpenDynaset)
     If Not Err.Number = 0 Then
         MsgBox Err.Number & " " & Err.Description
     End If
     On Error GoTo 0
End Sub

Listing 3: Start des Dokuments

Start eines Elements

Danach wird als nächste für uns interessante Ereignisprozedur IVBSAXContentHandler_startElement ausgelöst (s. Listing 4).

Private Sub IVBSAXContentHandler_startElement(strNamespaceURI As String, _
         strLocalName As String, strQName As String, _
         ByVal oAttributes As MSXML2.IVBSAXAttributes)
     Dim lngKundeID As Long
     On Error Resume Next
     Select Case strLocalName
         Case "Kunde"
             lngKundeID = oAttributes.getValueFromName("", "KundeID")
             db.Execute "DELETE FROM tblKunden WHERE KundeID = " & lngKundeID, _
                 dbFailOnError
             rstKunden.AddNew
             rstKunden!KundeID = lngKundeID
             strFeld = ""
         Case "Vorname", "Nachname"
             strFeld = strLocalName
         Case Else
             strFeld = ""
     End Select
     If Not Err.Number = 0 Then
         MsgBox Err.Number & " " & Err.Description
     End If
     On Error GoTo 0
End Sub

Listing 4: Start eines Elements

Hier prüfen wir zunächst in einer Select Case-Bedingung, welches Element an der Reihe ist. Als Erstes wird hier das Element Kunden erscheinen, um das wir uns aber an dieser Stelle nicht zu kümmern brauchen €” dies wird erst interessant, wenn ein XML-Dokument mehrerer solcher Hauptelemente enthält (wie Kunden, Artikel, Bestellungen et cetera).

Danach folgt das Element Kunde und wir beginnen, den ersten Kunden in der Tabelle tblKunden einzufügen.

Der entsprechende Zweig der Select Case-Bedingung ermittelt zunächst den Wert des Attributs KundeID €” das ja in diesem Element enthalten ist:

  <Kunde KundeID="123">

Dazu verwendet es die Funktion getValue-FromName des mitgelieferten Parameters oAttributes und übergibt den Namen des Attributs (hier KundeID) als zweiten Parameter. Wir wollen Kundendatensätze, deren KundeID bereits vergeben ist, einfach löschen und neu füllen. Je nach Anforderungen werden Sie diesen Sachverhalt anders programmieren wollen. Dann legt die Prozedur mit der AddNew-Methode einen neuen Datensatz in der Tabelle tblKunden an. Da wir den Primärschlüsselwert bereits kennen, fügen wir diesen für das Feld KundeID ein (dieses darf zu diesem Zweck nicht als Autowert definiert sein).

Außerdem stellen wir die Variable strFeld auf eine leere Zeichenkette ein.

Später wird diese Prozedur für die Elemente Vorname und Nachname ausgelöst. In diesem Fall müssen wir natürlich keinen neuen Datensatz anlegen.

Wir können auch die Werte noch nicht eintragen, da diese ja erst in der Ereignisprozedur IVBSAXContentHandler_characters übermittelt werden.

Aber damit wir später beim Auslösen der Prozedur IVBSAXContentHandler_characters wissen, zu welchem Feld die enthaltenen Werte gehören, merken wir uns nun mithilfe der Variablen strFeld den Namen des Elements €” also entweder Vorname oder Nachname.

Inhalt eines Elements einlesen

Damit geht es gleich weiter mit der Ereignisprozedur IVBSAXContentHandler_characters (s. Listing 5). Diese Prozedur erhält mit dem Parameter strChars den Inhalt des Elements.

Private Sub IVBSAXContentHandler_characters(strChars As String)
     On Error Resume Next
     Select Case strFeld
         Case "Vorname"
             rstKunden!Vorname = strChars
         Case "Nachname"
             rstKunden!Nachname = strChars
     End Select
     If Not Err.Number = 0 Then
         MsgBox Err.Number & " " & Err.Description
     End If
     On Error GoTo 0
End Sub

Listing 5: Einlesen des Wertes eines Elements

Dabei kann es sich auch um Zeilenumbrüche oder Einschübe handeln, wenn das aktuelle Element weitere Unterelemente enthält.

Angenommen, das XML-Dokument ist an dieser Stelle wie folgt aufgebaut:

   <Kunde KundeID="123">
     <Vorname>André</Vorname>

Dann folgt nach dem Element Kunde zuerst ein Aufruf der Prozedur IVBSAXContentHandler_characters, bei der strChars den Zeilenumbruch von der Zeile <Kunde… zur Zeile <Vorname enthält.

Dann folgt ein weiterer Aufruf der Prozedur IVBSAXContentHandler_characters, welcher mit dem Parameter strChars die Einrückung des Textes <Vorname… enthält (also etwa einige Leerzeichen oder ein Tabulator-Zeichen).

Wenn Sie ein XML-Dokument verwenden, das um Zeilenumbrüche und Einschübe bereinigt ist, entfallen diese zahlreichen Aufrufe der Ereignisporzedur IVBSAXContentHandler_characters jedoch €” dies dürfte sich positiv auf die Performance auswirken.

Solche Zeichen ignorieren wir jedoch, indem wir in einer Select Case-Bedingung den Wert der Variablen strFeld prüfen. Dabei wird die Prozedur nur tätig, wenn strFeld entweder den Wert Vorname oder Nachname enthält. In diesem Fall füllt die Prozedur das jeweilige Feld des Recordsets rstKunden mit dem per strChars übergebenen Wert.

Ende eines Elements

Die Ereignisprozedur IVBSAXContentHandler_endElement wird jeweils ausgelöst, wenn der SAX-Parser auf ein Element mit </… stößt (s. Listing 6). In unserem Beispiel geschieht dies zum ersten Mal mit dem Element </Vorname>, dann mit </Nachname>. Hier geschieht nichts, da die Prozedur in einer Select Case-Bedingung lediglich den Fall des Elements Kunde berücksichtigt.

Private Sub IVBSAXContentHandler_endElement(strNamespaceURI As String, _
         strLocalName As String, strQName As String)
     On Error Resume Next
     Select Case strLocalName
         Case "Kunde"
             rstKunden.Update
     End Select
     strFeld = ""
     If Not Err.Number = 0 Then
         MsgBox Err.Number & " " & Err.Description
     End If
     On Error GoTo 0
End Sub

Listing 6: Bearbeitung des Ende-Tags eines Elements

Dies bedeutet, dass das komplette Kunde-Element samt enthaltenen Feldern abgearbeitet wurde und der Datensatz gespeichert werden kann. Dies erledigt die Prozedur mit der Update-Methode des Recordset-Objekts.

Ende des Dokuments

Nachdem alle Elemente eingelesen wurden, löst der SAX-Parser schließlich noch die Ereignisprozedur IVBSAXContentHandler_endDocument aus (s. Listing 7). Hier brauchen wir nur noch das geöffnete Recordset zu schließen und die Variablen db und rstKunden zu leeren.

Private Sub IVBSAXContentHandler_endDocument()
     On Error Resume Next
     rstKunden.Close
     Set rstKunden = Nothing
     Set db = Nothing
     If Not Err.Number = 0 Then
         MsgBox Err.Number & " " & Err.Description
     End If
     On Error GoTo 0
End Sub

Listing 7: Abschluss des Einlesevorgangs

Ein Blick in die Tabelle tblKunden zeigt, dass die Daten erfolgreich gespeichert wurden (s. Bild 3).

Die Tabelle tblKunden mit einigen Beispieldaten

Bild 3: Die Tabelle tblKunden mit einigen Beispieldaten

Speichern in eine Tabelle mit Autowert-Feld

Nun hat man nicht immer die Gelegenheit, die Zieltabelle selbst zu definieren. Während wir hier also der Einfachheit halber den Primärschlüsselwert nicht als Autowert definiert haben, ist dies normalerweise nicht der Fall.

Wie aber bekommen wir den Primärschlüsselwert, hier aus dem Attribut KundeID der Kunde-Elemente, in das Primärschlüsselfeld Legen wir doch als Erstes eine Kopie der Tabelle tblKunden unter dem Namen tblKundenMitAutowert an und ändern dort den Felddatentyp des Feldes KundeID auf Autowert (s. Bild 4).

Die Tabelle tblKunden mit Autowert als Primärschlüssel

Bild 4: Die Tabelle tblKunden mit Autowert als Primärschlüssel

Außerdem fügen wir dem Formular frmSAX eine weitere Schaltfläche namens cmdEinlesenMitAutowert hinzu. Diese löst die Prozedur aus Listing 8 aus. Der Unterschied zu der Prozedur der anderen Schaltfläche ist, dass wir hier die neu erstellte Klasse clsSAXMitAutowert referenzieren. Schließlich kopieren wir die Klasse clsSAX in eine neue Klasse namens clsSAXMitAutowert.

Private Sub cmdEinlesenMitAutowert_Click()
     Dim objReader As SAXXMLReader60
     Dim objSax As clsSAXMitAutowert
     Set objReader = New SAXXMLReader60
     Set objSax = New clsSAXMitAutowert
     Set objReader.contentHandler = objSax
     objReader.parseURL Me!txtDatei
     Set objReader = Nothing
End Sub

Listing 8: Einlesen der gleichen Datei, diesmal in eine Tabelle mit Autowert-Feld

Danach wollen wir uns ansehen, ob wir die Tabelle mit dem Autowert im Primärschlüsselfeld nicht einfach genauso füllen können wie im vorherigen Beispiel (s. Listing 9). Und siehe da: Es klappt ohne Probleme! Sie müssen bei einem Autowert-Feld also nicht unbedingt den Autowert nutzen, sondern können auch neue Datensätze mit eigenem Primärschlüsselwert einfügen.

Private Sub IVBSAXContentHandler_startDocument()
     ...
     Set rstKunden = db.OpenRecordset("SELECT * FROM tblKundenMitAutowert", dbOpenDynaset)
     ...
End Sub
Private Sub IVBSAXContentHandler_startElement(strNamespaceURI As String, strLocalName As String, strQName As String, _
     ByVal oAttributes As MSXML2.IVBSAXAttributes)
     ...
             db.Execute "DELETE FROM tblKundenMitAutowert WHERE KundeID = " & lngKundeID, dbFailOnError
     ...
End Sub

Listing 9: änderungen in der Klasse clsSAXMitAutowert, um die Daten in eine andere Tabelle einzulesen

Einlesen von XML-Dateien mit Lookup-Daten

Im folgenden Beispiel sehen wir uns an, wie wir mit dem SAX-Parser arbeiten, um Daten aus einem XML-Dokument in Tabellen mit Lookup-Tabelle zu importieren. Dazu haben wir die Beispieldatei um das Element Anrede erweitert, das zum Beispiel die Werte Herr oder Frau aufnimmt. Das Dokument haben wir unter dem Namen KundeMitAnrede.xml gespeichert (s. Listing 10).

<xml version="1.0" encoding="utf-8">
<Kunden xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                                      xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Kunde KundeID="123">
     <Anrede>Herr</Anrede>
     <Vorname>André</Vorname>
     <Nachname>Minhorst</Nachname>
   </Kunde>
   <Kunde KundeID="124">
     <Anrede>Herr</Anrede>
     <Vorname>Hermann</Vorname>
     <Nachname>Müller</Nachname>
   </Kunde>
   <Kunde KundeID="125">
     <Anrede>Frau</Anrede>
     <Vorname>Claudia</Vorname>
     <Nachname>Meier</Nachname>
   </Kunde>
</Kunden>

Listing 10: Die Anrede soll in einem Fremdschlüsselfeld landen.

Die Zieltabellen sehen wie in Bild 5 aus. Die Tabelle tblKundenMitAnrede ist dabei über das Fremdschlüsselfeld AnredeID mit dem gleichnamigen Primärschlüsselfeld der Tabelle tblAnreden verknüpft. Ziel ist es nun, eine neue Klasse clsSAXMitLookupTabelle zu programmieren, welche die Anreden aus dem Element Anrede direkt in die Tabelle einträgt und den neuen Primärschlüsselwert nutzt. Dies soll natürlich nur geschehen, wenn der Wert noch nicht in der Tabelle tblAnreden enthalten ist. Anderenfalls soll die Prozedur einfach den Primärschlüsselwert des entsprechenden Datensatzes der Tabelle tblAnreden ermitteln und in das Fremdschlüsselfeld der Tabelle tblKundenMitAnrede eintragen.

Kundentabelle mit Anreden in einer Lookup-Tabelle

Bild 5: Kundentabelle mit Anreden in einer Lookup-Tabelle

Um dies zu erreichen, benötigen wir zwei änderungen in den Prozeduren IVBSAXContentHandler_startElement und IVBSAXContentHandler_characters. Die änderungen finden Sie in Listing 11 in fetter Schrift.

Private Sub IVBSAXContentHandler_startElement(strNamespaceURI As String, strLocalName As String, strQName As String, _
          ByVal oAttributes As MSXML2.IVBSAXAttributes)
     ...
         Case "Vorname", "Nachname", "Anrede"
             strFeld = strLocalName
     ...
End Sub
Private Sub IVBSAXContentHandler_characters(strChars As String)
     ...
     Select Case strFeld
         ...
         Case "Anrede"
             lngAnredeID = Nz(DLookup("AnredeID", "tblAnreden", "Anrede = ''" & strChars & "''"), 0)
             If lngAnredeID = 0 Then
                 db.Execute "INSERT INTO tblAnreden(Anrede) VALUES(''" & strChars & "'')", dbFailOnError
                 lngAnredeID = db.OpenRecordset("SELECT @@IDENTITY").Fields(0)
             End If
             rstKunden!AnredeID = lngAnredeID
     End Select
     ...
End Sub

Listing 11: änderungen, um den Inhalt eines Elements in einem Lookup-Feld abzulegen

Zunächst müssen wir beim Start des Elements den Case-Zweig mit dem Vornamen und dem Nachnamen um die Anrede erweitern, damit der Wert Anrede in der Variablen strFeld gespeichert wird.

Außerdem müssen wir in der Prozedur IVBSAXContentHandler_characters einen Case-Zweig hinzufügen. Dieser behandelt eben den Fall, dass strFeld den Wert Anrede enthält. Dann soll die Prozedur zunächst versuchen, den Primärschlüsselwert des Datensatzes der Tabelle tblAnreden zu ermitteln, der den mit strChars gelieferten Wert im Feld Anrede enthält. Ist ein solcher Datensatz noch nicht vorhanden, enthält lngAnredeID danach den Wert 0, sonst den entsprechenden Primärschlüsselwert. Ist lngAnredeID gleich 0, muss die Anrede erst noch in der Tabelle tblAnreden angelegt werden. Dies erledigt die erste Anweisung innerhalb der folgenden If…Then-Bedingung. Nachdem dies erledigt ist, ermittelt die Prozedur mit der Abfrage SELECT @@IDENTITY den Wert des Primärschlüsselfeldes des neuen Datensatzes und speichert diesen in der Variablen lngAnredeID. Diesen Wert trägt die Prozedur dann für das Feld AnredeID des soeben neu angelegten Datensatzes ein.

Verschachtelte Daten einlesen

Gelegentlich werden Informationen in XML-Dokumenten nochmals in weiteren Elementen verschachtelt, um diese besser strukturieren zu können. Ein Beispiel zeigt das XML-Dokument aus Listing 12, das die Straße, die PLZ und den Ort der Lieferadresse und der Rechnungsadresse in zwei entsprechend bezeichneten Unterelementen speichert.

<xml version="1.0" encoding="utf-8">
<Kunden xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
                                      xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Kunde KundeID="123">
     <Anrede>Herr</Anrede>
     <Vorname>André</Vorname>
     <Nachname>Minhorst</Nachname>
     <Lieferadresse>
       <Strasse>Borkhofer Str. 17</Strasse>
       <PLZ>47137</PLZ>
       <Ort>Duisburg</Ort>
     </Lieferadresse>
    <Rechnungsadresse>
       <Strasse>Postfach 12345</Strasse>
       <PLZ>47137</PLZ>
       <Ort>Duisburg</Ort>
     </Rechnungsadresse>
   </Kunde>
   ...
</Kunden>

Listing 12: XML-Dokument mit verschachtelten Daten

Natürlich können wir dies auch unter Access ähnlich abbilden €” zunächst wollen wir jedoch die üblichen Felder wie LieferStrasse, LieferPLZ und LieferOrt sowie RechnungStrasse, RechnungPLZ und RechnungOrt bestücken. Die Zieltabelle haben wir dazu wie in Bild 6 gestaltet.

Tabelle mit Rechnungs- und Lieferanschrift

Ende des frei verfügbaren Teil. Wenn Du mehr lesen möchtest, hole Dir ...

den kompletten Artikel im PDF-Format mit Beispieldatenbank

diesen und alle anderen Artikel mit dem Jahresabo

Schreibe einen Kommentar