Business App bietet neben dem bereits etablierten Datenimport über Importkonfigurationen die Möglichkeit, Daten aus CSV-Listen zu importieren, die nicht direkt mit dem Datenmodell von Business App übereinstimmen.

Für diese Funktionalität, den „CSV-Import“, wird durch einen Anwender eine einzelne CSV-Datei bereitgestellt, welche Informationen unterschiedlicher Teile des Datenmodells beinhalten darf.

Da aber die Importfunktion nicht neu erfunden und die bereits existierenden, umfangreichen Möglichkeiten des Datenimports wiederverwendet werden sollen, benötigen wir eine Schnittstelle zwischen der Importfunktion für diese einzelne Liste und dem bereits integrierten Datenimport.

Ein gutes Beispiel für eine entsprechende Anwendung ist eine Datei mit einer Liste von Kontakten, deren Anschriften und E-Mail-Adressen (dieses Beispiel wird in diesem Dokument immer wieder herangezogen, da diese Funktionalität mit ausgeliefert wird).

In einer minimalisierten Version einer Kontaktliste beinhaltet diese beispielsweise folgende Daten:

Vorname Nachname Straße PLZ Ort E-Mail
Lars Weis Brückenmühle 93 36100 Petersberg info@gedys-intraware.de

Der Standarddatenimport kann mit dieser Datei nichts anfangen, er ist zwar sehr mächtig, es müssen aber bestimmte Vorgaben erfüllt sein: So zum Beispiel muss es für den Standarddatenimport immer eine Spalte „MigrationID“ geben, und Teildaten wie Anschriften oder E-Mail-Adressen müssen in gesonderte Dateien ausgelagert werden.

CSV-Konverter dienen dazu, diese oben beschriebene „einfache“ Eingabedatei so in neue CSV-Dateien umzuwandeln, dass der Standarddatenimport diese verstehen und importieren kann.

Der Job des CSV-Konverters in diesem Fall wäre also, aus der einen Datei oben drei Dateien zu machen und diese in einem vorgegebenen Verzeichnis abzulegen:

001 OrmContact.csv

MigrationID FirstName LastName
CONT001 Lars Weis

001 OrmContact.Addresses.csv

Parent Address PostalCode City AddressType
CONT001 Brückenmühle 93 36100 Petersberg Main Address

001 OrmContact.EmailAddresses.csv

Parent EmailAddress EmailAddressType
CONT001 info@gedys-intraware.de Primary

CSV-Konverter sind zweckgebunden und können jeweils nur Dateien für einen bestimmten Zieltyp erstellen (im obigen Beispiel wäre der Zieltyp der über die Dependency Injection festgelegte Typ für Kontakte, der Einfachheit halber verwenden wir hier OrmContact). Im Umkehrschluss heißt das, dass es in der aktuellen Ausbaustufe des Features nicht möglich ist, einen Konverter zu entwickeln, der eine Liste mit unterschiedlichen Datensatztypen bearbeiten kann (zum Beispiel Firmen und Kontakte in einer CSV-Datei).

Erweiterung per Attribut

CSV-Konverter können in Projekten per Attribut erweitert oder gar ausgetauscht werden. Wenn der mitgelieferte Konverter für Kontakte nicht den Projektanforderungen entspricht, so kann er durch einen eigenen CSV-Konverter ersetzt werden.

Die Deklaration hierzu lautet:

[ImportFileConverter(ConstantsContact.ImportFileConverter.Contact, "Meine Kontakte")]
public class ProjectContactCSVConverter : ImportFileConverterBase { … }

Das Attribut ImportFileConverter nimmt zwei Parameter an, der erste ist die ID des Konverters, der zweite ein optional übersetzbarer Anzeigewert für die Konverterauswahl des CSV-Import Navigationssteuerelements und zur Anzeige und Auswahl im Dialog der Aktion.

Die gezeigte Konstante sollte verwendet werden, wenn der Konverter ausgetauscht werden soll, ansonsten ist die ID des Konverters aber ein frei wählbarer String. Es wird allerdings empfohlen hier entweder einen Guid-String oder einen String mit Namespaces zu verwenden um unbeabsichtigte Kollisionen zu vermeiden.

Die Klasse muss nicht zwingend von der Basisklasse ImportFileConverterBase ableiten, muss aber das Interface IImportFileConverter implementieren, so dass eine Erweiterung der Basisklasse einfacher ist.

Interface und Basisklasse

Dreh- und Angelpunkt der CSV-Konverter ist neben dem Attribut zur Deklaration eines solchen die Implementierung der Schnittstelle IImportFileConverter.

Folgende Eigenschaften müssen implementiert werden:

  • Id Dient dem Abruf der über das Attribut angegebenen Konverter-ID.
  • DisplayName Dient dem Abruf des über das Attribut angegebenen Anzeigenamen.

Folgende Methoden müssen implementiert werden:

void Convert(ImportFileConverterParameterModel parameters)	

Hauptmethode, welche für die eigentliche Umwandlung zuständig ist.

string GetAdditionalInformationDialogId()

Liefert optional eine Dialog-ID zurück, welche in der UI nach der Anzeige des Spaltenzuordnungsdialogs aufgerufen und das Ergebnis an die Convert-Methode gegeben wird.

Dictionary<string, string> GetFieldMappings(FileInfo importFile)

Liefert optional eine vorgefertigte automatische Zuordnung der CSV-Spalten der gegebenen Datei zu Zielspalten/-eigenschaften auf dem Zieltyp des Konverters.

Type GetTargetType()

Liefert den Datensatztypen, der durch diesen Konverter behandelt wird (z.B. OrmContact). Dies muss ein Orm-Typ sein.

Type GetOptionalParentType()

Liefert optional den Datensatztypen, zu dem der Zieltyp beim Import zugeordnet werden kann (z.B. OrmCompany)

string[] GetFieldMappingDialogDescriptions()

Liefert optional eine Reihe von Erklärungen zur Funktion des CSV-Konverters (übersetzbar), die ganz oben im Spaltenzuordnungsdialog angezeigt werden.

Erwähnenswert ist hierbei auch das Übergabe-Model der Convert-Methode ImportFileConverterParameterModel, welches folgende Eigenschaften beinhaltet:

  • ImportTag Ein Kennzeichen, welches den zu erstellenden Datensätzen in irgendeiner Art und Weise zugeordnet werden soll (z.B. OrmContact -> AddressTags).
  • ImportDirectory Name des Hauptverzeichnisses des Importvorgangs. Dieser Name kann mit Hilfe der Methode ImportJobConfiguration.GetJobDirectory() in einen absoluten Pfad umgewandelt werden. Die zu importierende Datei liegt hierbei immer im Unterverzeichnis „ListImportData“ (BA.Import.Constants.ListImportSubDirectory).
  • FieldMappingJson Spaltenzuordnungen, die vom Benutzer in der UI zugewiesen wurden.
  • AdditionalConverterParametersJson Serialisierte Ergebnisdaten, welche von dem optionalen „Additional-Information-Dialog“ zurück geliefert wurden.

Die Basisklasse ImportFileConverterBase ist abstrakt und implementiert zunächst alle Methoden und Eigenschaften des Interfaces, die Methoden Convert, GetFieldMappings und GetTargetType sind abstrakt und müssen in der eigentlichen Konverterklasse implementiert werden. Alle anderen Methoden sind virtual und damit überschreibbar, alle diese Methoden liefern null zurück bis auf GetFieldMappingDialogDescriptions, welches eine allgemeingültige Standarderklärung zurückgibt. Die Eigenschaften Id und DisplayName sind fest implementiert und greifen über den TypeCache auf das ImportFileConverter Attribut der Klasse zu, um die entsprechenden Daten auszulesen.

Ablauf eines Imports mit Konvertierung

Werfen wir zunächst einen Blick auf den Ablauf einer solchen Konvertierung im Kontext mit dem Importvorgang und der zugehörigen UI:

  1. Der Hauptdialog, in welchem sich der Dateiupload für die CSV-Datei, die Auswahl des zu verwendenden Konverters und die Angabe eines Import-Tags befinden, wurde aufgerufen.
    Bei der Erstellung dieses Dialogs werden von allen verfügbaren Konvertern die optionalen Dialog-IDs für zusätzlich benötigte Parameter und Informationen abgefragt (GetAdditionalInformationDialogId) und der client-seitigen JavaScript-Verarbeitung zur Verfügung gestellt. Ein Click auf „OK“ in diesem Dialog führt zunächst dazu, dass der Dialog für die Spaltenzuordnungen aufgerufen wird.
  2. Bei der Erstellung des Zuordnungsdialogs wird der gewählte CSV-Konverter über eine Hilfsfunktion abgerufen:
    IImportFileConverter converter = ImportFileConverterTools.GetConverter(selectedConverterId);
  3. Von dem Konverter werden als erstes die vorgeschlagenen Spaltenzuordnungen (GetFieldMappings) und anschließend der Zieltyp (GetTargetType) abgerufen.
    Der Zieltyp MUSS hierbei ein Orm-Typ sein, da es sonst zu Fehlern in der weiteren Verarbeitung kommen wird.
  4. Beim Aufbau des Dialogs werden neben den Mapping-Informationen die Beschreibungen (GetFieldMappingDialogDescriptions) und der optionale Elterntyp (GetOptionalParentType) herangezogen und in der UI verarbeitet.
  5. Anschließend wird dieser Dialog angezeigt und harrt der Bedienung durch einen Benutzer. Die vom Konverter gelieferten Field-Mappings sind (sofern fehlerfrei möglich) in diesem Dialog voreingestellt.
  6. Wird dieser Dialog mit „Ok“ bestätigt, wird anschließend geprüft, ob es für den gewählten Konverter einen Konverterdialog gibt. Ist dem so, wird dieser Dialog jetzt aufgerufen.
    Der Konverterdialog ist frei in all seinen Aktivitäten, sein Ergebnis wird beim Schließen des Dialogs in Json serialisiert und für die weitere Verarbeitung vorbereitet. Das Ergebnis des Dialogs muss dabei die ButtonId „okButton“ aufweisen, ist dies nicht der Fall, wird der ganze Vorgang abgebrochen.
  7. Ist alles soweit in Ordnung, wird die Konvertierung angestoßen. Das Parameter-Model wird erzeugt und die methode „Convert“ des Konverters aufgerufen. Fehler innerhalb der Convert-Methode sollten mit Exceptions signalisiert werden. Die Verarbeitung geht davon aus, dass die Convert-Methode das mitgegebene Importverzeichnis final vorbereitet, d.h. dass die vom Standarddatenimport zu importierenden Dateien dort in einem Unterverzeichnis „Import“ liegen und nicht mehr (wie vermutlich in einem Zwischenschritt) in dem Verzeichnis, in welchem die CSV-Datei abgelegt ist („ListImportData“).
  8. Nach der Konvertierung wird eine temporäre ImportJobConfiguration angelegt, welche dann zeitnah vom Datenimport ausgeführt wird. „Temporär“ hat hierbei zur Folge, dass
    • die Konfiguration nicht in der Liste der Importkonfigurationen angezeigt wird,
    • der Import unter den Rechten des aktuellen Benutzers stattfindet,
    • der Importvorgang in der Fortschrittsanzeige dargestellt wird und
    • die Importkonfiguration und alle im Importverzeichnis gespeicherten Daten nach Beendigung des Vorgangs wieder gelöscht werden.

Implementierung eines eigenen CSV-Konverters

Ich erläutere die Implementierung eines eigenen CSV-Konverters als Beispiel anhand des bereits mitgelieferten Konverters für Kontaktlisten. Ein paar Sachen werde ich auslassen (beispielsweise die Erstellung der Vorgabespaltenzuweisung, die doch einigermaßen kompliziert und umfangreich ist).

Beginnen wir mit dem Klassenrumpf:

[ImportFileConverter(ConstantsContact.ImportFileConverter.Contact, "1f62f2e2-c1ba-43df-a91a-0d2ca7a5d19a")]
public class ContactImportFileConverter : ImportFileConverterBase { … }

So weit, so gut. Die Klasse heißt ContactImportFileConverter, sie befindet sich im Namespace (s.o.), ist über das Attribut als CSV-Konverter deklariert und erbt von der Basisklasse. Die Parameter des Attributs sind eine von uns festgelegte Guid als ID (erster Parameter) und eine Guid, die in den Text „Kontakte“ übersetzt wird (zweiter Parameter).

Schauen wir uns nun die kleinen Methoden an, die entweder virtuelle Basismethoden überschreiben oder abstrakte implementieren:

public override string[] GetFieldMappingDialogDescriptions()
{
    return new string[] { "fd9d5259-5744-479d-83c7-8e7f26597b4f", "06fa14a7-7297-4e5c-9b52-25642a2b4f95", "ed5bcfee-74f0-4746-a021-7cf1fb7e484b" };
}

GetFieldMappingDialogDescriptions liefert einfach nur ein Array von Texten zurück, welche im Spaltenzuordnungsdialog ganz oben angezeigt werden sollen. Diese Texte können wie hier Guids von übersetzbaren Strings sein, oder feste Textbausteine.

private OrmDataSourceCacheModel TargetType = null;
public override Type GetTargetType()
{
    if (TargetType == null)
        TargetType = Api.ORM.GetOrmTypeCacheValue(EnumDataSourceExtension.ContactGuid);
    return TargetType;
}

Wir erklären, dass der Typ der Datensätze, die dieser Konverter erstellen wird, OrmContact ist. In unserem Fall wird der Typ noch im Konverter zwischengespeichert, weil in der Convert-Methode in einer Schleife darauf zugegriffen wird.

public override Type GetOptionalParentType()
{
    return Api.ORM.GetOrmTypeCacheValue(EnumDataSourceExtension.CompanyGuid);
}

Es wird deklariert, dass die von diesem Konverter erstellten Datensätze möglicherweise (!) durch eine Standardelternrelation mit Datensätzen des Typs OrmCompany verknüpft sein könnten.
Diese Verknüpfung muss in der Spaltenzuordnung zugewiesen sein, wenn nicht, findet sie auch nicht statt, d.h. vom Framework her ist eine Elternverknüpfung immer optional. Sollte das bei den von Ihnen erstellten Datensätzen nicht der Fall sein, so muss die Convert-Methode sicherstellen, dass es eine Elternverknüpfung gibt (und ggf. abbrechen, wenn dem nicht so ist).

public override Dictionary<string, string> GetFieldMappings(FileInfo importFile)
{
    Dictionary<string, string> result = new Dictionary<string, string>();
    …
    return result;
}

Wir werden hier nicht auf die tatsächliche Ermittlung der Feldzuordnungen eingehen, weil die in diesem Konverter doch sehr speziell und kompliziert ist. Grundsätzlich verläuft sie aber so, dass die übergebene Datei, also die Datei, die importiert werden soll, geöffnet und die Titelzeile ausgelesen wird. Für jeden Titel wird dann über ein internes Mapping geprüft, zu welcher Eigenschaft des Zieltyps dieser Titel passen könnte. Behalten Sie bitte auch im Hinterkopf, dass diese Spaltenzuordnung, die hier getroffen wird, nur der Vorschlag für den Benutzer ist, und diese Werte keinen anderen Zweck haben, als diese in der UI des Dialogs vorzubelegen.

Als Inhalte des zurückgegebenen Dictionarys werden erwartet:

  • Als Schlüssel: der Titel aus der CSV-Datei
  • Als Wert: die Zuordnung in ein Feld des Zieltyps

Das heißt also, in einem einfachen Fall wäre das zum Beispiel
„Vorname“ -> „FirstName“ oder „Familienname“ -> „LastName“

In einem etwas komplizierteren Fall muss das Ziel in einen Teildatensatz gemappt werden, so in etwa
„Straße“ -> „Addresses.Address“, „Ort“ -> „Addresses.City“ oder „E-Mail“ -> „EmailAddresses.EmailAddress“.

Die korrekte Spaltenzuordnung für das Beispiel aus der Einführung sähe also so aus:

  • „Vorname“ -> „FirstName“
  • „Nachname“ -> „LastName“
  • „Straße“ -> „Addresses.Address“
  • „PLZ“ -> „Addresses.PostalCode“
  • „Ort“ -> „Addresses.City“
  • „E-Mail“ -> „EmailAddresses.EmailAddress“

Und dann wären wir auch schon bei der Convert-Methode. Die Convert-Methode des Kontaktkonverters besteht aus drei logischen Teilen, in die ich die Methode zur Erklärung aufteilen werde: das wären die Vorbereitung, die Verarbeitung und die Nachbereitung.

Die Vorbereitung

public override void Convert(ImportFileConverterParameterModel parameters)
{
    Dictionary<string, string> mappings = Api.JsonHelper.Deserialize<Dictionary<string, string>>(parameters.FieldMappingJson);
    string importPath = Path.Combine(ImportJobConfiguration.GetJobDirectory(parameters.ImportDirectory), BA.Import.Constants.ListImportSubDirectory);
    DirectoryInfo di = new DirectoryInfo(importPath);
    FileInfo[] files = di.GetFiles();
    if (files == null || !files.Any())
        return;
    XPQuery<OrmCompany> parents = Api.ORM.GetDefaultSession().Query<OrmCompany>();
    ILookup<string, string> parentMapping = null;
    FileInfo file = files.First();
    …

Was passiert:

  • Die vom Benutzer gewählten Feldzuordnungen werden aus den Parametern in ein Dictionary<string, string> deserialisiert. Dieses Dictionary hat das gleiche Format wie schon bei GetFieldMappings() erklärt.
  • Der Pfad zu dem Verzeichnis mit der Import-Datei wird hergestellt. Der Parameter „ImportDirectory“ ist nur eine Guid, welche das Verzeichnis im Kontext des Importmechanismus beschreibt. Der zugehörige absolute Pfad auf der Festplatte des Servers muss daraus erst berechnet werden. Zusätzlich muss hier auf ein definiertes Unterverzeichnis zugegriffen werden.
  • Die Inhalte des Verzeichnisses werden ausgelesen. Ist es leer oder nicht existent, wird die Verarbeitung beendet.
  • Es wird eine Datenbank-Query auf die möglichen Elterndatensätze erstellt. Diese wird aber erst später ausgeführt, wenn es auch wirklich eine Elternverknüpfung gibt.
  • Die Importdatei wird abgerufen.

Die Verarbeitung

using (ImportFileConverterCsvHelper csv = new ImportFileConverterCsvHelper(GetTargetType(), mappings, file, 0))
{
    while (csv.Read())
    {

Zur Verarbeitung wird die Klasse ImportFileConverterCsvHelper herangezogen, welche die Verteilung der Daten auf mehrere Dateien stark vereinfacht. Die Klasse ist Disposable und wird daher in einem using benutzt. Als Parameter bekommt sie den Zieltypen, die Spaltenzuordnungen, die Eingabedatei selbst und ein numerisches Sortierkennzeichen.

Aus dem Zieltyp, den Spaltenzuordnungen und dem Sortierkennzeichen werden die Dateinamen der Zieldateien ermittelt und diese angelegt (in unserem Beispiel wären das also nun „00 OrmContact.csv“, „00 OrmContact.Addresses.csv“ und „00 OrmContact.EmailAddresses.csv“).

Als nächstes geht es mit einer while-Schleife so lange über die CSV-Eingabedatei, bis keine Daten mehr verfügbar sind.

foreach (string title in csv.ImportCsvTitles)
{
    if (title.Equals("oid", StringComparison.OrdinalIgnoreCase) || title.Equals("migrationid", StringComparison.OrdinalIgnoreCase))
        continue;
    string val = csv.GetSourceField(title);
    if (mappings.TryGetValue(title, out string csvTitle) && csvTitle.StartsWith($"{Import.Constants.ParentLinkIdentifier}."))
    {
        if (parentMapping == null)
        {
            string parentProperty = csvTitle.Substring(csvTitle.IndexOf('.') + 1);
            parentMapping = parents.ToLookup(ff => (string)ff.GetMemberValue(parentProperty), ff => ff.Oid.ToString());
        }
        if (parentMapping.Contains(val))
            csv.SetTargetField("$REL_Parent", parentMapping[val].FirstOrDefault() ?? "");
        else
            csv.SetTargetField("$REL_Parent", "");
    }
    else
        csv.SetTargetField(title, val);
}

Pro gelesenem Datensatz werden nun die Inhalte verarbeitet. Hierfür wird in einer Schleife über jeden Titel der Eingabedatei gegangen und die beinhalteten Daten gelesen. Dann wird überprüft, ob die Spaltenzuordnung für diesen Titel eine Elternverknüpfung vorsieht. Ist dem so, wird nun die vorbereitete Datenbank-Query auf die Firmen ausgeführt und ein sog. Lookup auf den für die Elternverknüpfung konfigurierten Feldnamen erstellt (Schlüssel ist also beispielsweise der Firmenname, und der Wert des Lookups dann die Oid, die zu diesem Firmennamen gehört; ein Lookup deshalb, weil zumindest theoretisch ein Firmenname mehrfach in der Datenbank liegen könnte, in dem Fall wäre nicht definiert, zu welcher dieser Firmen der Kontakt zugeordnet würde). Mit diesen Informationen wird in der Zieldatei die Spalte „$REL_Parent“ befüllt, über welche dann beim eigentlichen Import die Elternverknüpfung generiert wird. Ist der aktuelle Titel nicht als Elternverknüpfung vorgesehen, werden die Daten einfach per Helper gesetzt, welcher dann auch das Mapping auf den Zielspaltennamen übernimmt.

 ImportFileConverterCsvRecord record = csv.GetCsvRecordBySubTable("EmailAddresses");
if (record != null && record.CurrentRecordIsDirty && record.Record is IDictionary<string, object> dyn1 && (!dyn1.ContainsKey("EmailAddressType") ||  string.IsNullOrWhiteSpace(dyn1["EmailAddressType"] as string)))
    record.SetField("EmailAddressType", "Primary");
record = csv.GetCsvRecordBySubTable("Addresses");
if (record != null && record.CurrentRecordIsDirty && record.Record is IDictionary<string, object> dyn2 && (!dyn2.ContainsKey("AddressType") || string.IsNullOrWhiteSpace(dyn2["AddressType"] as string)))
    record.SetField("AddressType", "Main address");
if (!string.IsNullOrWhiteSpace(parameters.ImportTag))
{
    record = csv.GetMainCsvRecord();
    if (record != null && record.CurrentRecordIsDirty && record.Record is IDictionary<string, object> dyn3)
    {
        string addressTags = (dyn3.ContainsKey("AddressTags") ? dyn3["AddressTags"] as string : null) ?? "";
        if (!string.IsNullOrWhiteSpace(addressTags))
            addressTags += ",";
        addressTags += parameters.ImportTag;
        record.SetField("AddressTags", addressTags);
    }
}

Sind alle Titel der Eingabedatei verarbeitet, wird nun geprüft, ob ein Datensatz für eine E-Mail-Adresse angelegt wurde. Ist das der Fall, wird diesem Datensatz zusätzlich noch der Adresstyp „Primary“ vergeben, sollte der Adresstyp nicht bereits durch den Import gesetzt worden sein. Das gleiche wird entsprechend für den Anschriftendatensatz gemacht und anschließend noch das gewünschte ImportTag an das Feld „AddressTags“ angehängt. Das alles wird nur dann gemacht, wenn der jeweilige Datensatz auch bis dahin schon gespeichert werden soll (dirty).

csv.NextRecord();
}
csv.SaveAll();
file.Delete();
}

Mit csv.NextRecord() werden die Daten intern abgelegt und für das finale Speichern zwischengespeichert und die Strukturen für den aktuellen Datensatz geleert. Der „Cursor“ rückt sozusagen in die nächste Zeile.

Sind alle Daten durch die while-Schleife verarbeitet, werden die csv-Dateien auf die Festplatte geschrieben (csv.SaveAll()) und die Eingabedatei gelöscht.

Die Nachbereitung

files = di.GetFiles();
if (files == null || !files.Any())
    return;
string importDir = Path.Combine(di.Parent.FullName, "Import");
di = Directory.CreateDirectory(importDir);
foreach (FileInfo fi in files)
    fi.MoveTo(Path.Combine(di.FullName, fi.Name));
}

Wie schon an anderer Stelle erwähnt, wird vom Konverter erwartet, dass er ein valides Importverzeichnis zurücklässt. Das heißt, alle Dateien, die nun noch im „ListImportData“ Verzeichnis liegen, müssen eigentlich Dateien für den Standarddatenimport sein. Diese werden nun noch nach nebenan ins Verzeichnis „Import“ verschoben.

Damit ist die Konvertierung beendet.