CSV (Zeilen) parsen mit korrektem Handling von escaping

  • Hallo!

    Gibt es einen best-practise-way um beim Auslesen von CSV Dateien auch mit Escaping richtig zu behandeln? Zumindest die erste Variante mit "nested quotes" sollte drinnen sein. Hab dazu für AutoIt nicht wirklich was gefunden.

    Code
    Peter;"Paul; the Best";Mary

    Also das im Prinzip generell innerhalb von " ... " jegliche Trenner ignoriert werden. Leider gibt es was ich gesehen habe dazu in StringSplit keinen optionalen Parameter.

    Hätte sonst den Gedankenansatz die Zeile Zeichen für Zeichen durchzugehen und dann diese halt so zu verarbeiten an Stelle vom derzeitigen StringSplit($line, ";")

    Gibt es hier schon Schnipsel zu dem Thema?

    Danke!

    4 Mal editiert, zuletzt von hausl78 (27. April 2014 um 20:52)

    • Offizieller Beitrag

    Ersetze das innere Semikolon mit einem Zeichen deiner Wahl (im Bsp. die Pipe). Dann splitten und hinterher mit umgekehrtem Weg statt Pipe wieder das Semikolon.

    [autoit]

    $string = 'Peter;"Paul; the Best";Mary;Coca-Cola;"Beer; dark";Lemon'
    $sDelim = '|'

    [/autoit][autoit][/autoit][autoit]

    $sReplaced = StringRegExpReplace($string, '([^"]*"[^;]*)(;)([^;]*"[^"]*)', '$1' & $sDelim & '$3')
    ConsoleWrite("@@ Debug line" & @TAB & @ScriptLineNumber & " var: $sReplaced --> " & $sReplaced & @LF)

    [/autoit][autoit][/autoit][autoit]

    $aSplit = StringSplit($sReplaced, ';')

    [/autoit][autoit][/autoit][autoit]

    If StringInStr('\.^$|[({*+?#', $sDelim) Then $sDelim = '\' & $sDelim ; mask meta characters
    For $i = 1 To $aSplit[0]
    ConsoleWrite(StringRegExpReplace($aSplit[$i], '"([^' & $sDelim & ']*)(' & $sDelim & ')([^' & $sDelim & ']*)"', '$1;$3') & @LF)
    Next

    [/autoit]

    Ergebnis:

    Code
    @@ Debug line	1693   var: $sReplaced --> Peter;"Paul| the Best";Mary;Coca-Cola;"Beer| dark";Lemon
    Peter
    Paul; the Best
    Mary
    Coca-Cola
    Beer; dark
    Lemon
  • Hallo!

    Super für den Ansatz, danke. An sowas hatte ich zuerst auch gedacht, aber dann wieder verworfen weil ich das Pattern nich wirklich hingebracht habe und mir dann eingeredet Zeichenweise Verarbeitung ist sicher auch performanter ;)


    EDIT:
    Wenn ich eine Pipe drinnen habe, dann hab ich wieder das Problem, wenn ich zB ein mehrzeichen-Temp-Delim mache, dann muss ich vermulitch das Regex unten anpassen da dies den Delim in einer Zeichenklasse arbeitet [ ] was ich gesehen habe.

    [autoit]

    $sOut &= StringRegExpReplace($aSplit[$i], '"([^' & $escDelim & ']*)(' & $escDelim & ')([^' & $escDelim & ']*)"', '$1;$3') & @LF

    [/autoit]

    Wenn ich zB "~#-" als Trenner statt der Pipe verwenden möchte, oder !#! etc..

    3 Mal editiert, zuletzt von hausl78 (27. April 2014 um 17:14)

    • Offizieller Beitrag

    Verwende zum Maskieren einfach ein Zeichen, dass du wohl mit Sicherheit nicht im Text hast (z.B. Chr(1) ). Aber nicht Chr(0) verwenden, das ist intern die Stringbegrenzung und würde somit fehlschlagen. ;)
    Es ist ohne Sinn, nur zum Maskieren eine Zeichenkombination zu verwenden. Ein Einzelzeichen tut es.

  • Hm, stimmt hast recht.. dann bleib ich glieich bei dem chr(1) .. eine Pipe | hat man ja schnell mal wo als Textzeichen, daher wollt ich eine möglichst "unübliche" erstellen.

    Dann muss ich jetzt nur noch benchmarken :) ob ich diese Funktion generell splitten lassen oder eben nur wenn ein ;" "; vorkommt und sonst die normale StringSplit() ... mal schauen.

    Danke dir jedenfalls!

  • Habe gerade mit beiden Funktionen getestet, der unterschied ist minimal, bei einer csv mit 5000 zeilen, glaub ich lasses vorerst generell mal so.


    Code
    ([^"]*"[^;]*)(;)([^;]*"[^"]*)

    Kannst du mir den noch erklären?

    Code
    ([^"]*"[^;]*) 
    ein beliebiges zeichen ausser " beliebig oft, dann ein " 
    und dann wider ein belibeiges zeichen beliebig oft ausser ; 
    
    
    dann ; 
    
    
    ([^;]*"[^"]*) 
    wie oben nur andersrum
    • Offizieller Beitrag

    ([^"]*"[^;]*)  -- Ergebnis in ein Capture
    ([^"]*"[^;]*) -- Class: kein " null-mal bis beliebig oft
    ([^"]*"[^;]*) -- einmal "
    ([^"]*"[^;]*) -- Class: kein Semikolon null mal bis beliebig oft
    (;) -- Ergebnis in ein Capture
    (;) --einmal Semikolon
    ([^;]*"[^"]*) -- Ergebnis in ein Capture
    ([^;]*"[^"]*) -- Class: kein Semikolon null mal bis beliebig oft
    ([^;]*"[^"]*) -- einmal "
    ([^;]*"[^"]*) -- Class: kein " null-mal bis beliebig oft

  • Danke für die genaue Darstellung, sehr nett! Da war ich im Grunde mit dem Verstehen gar nicht so weit weg, ich frage mich nur - nicht falsch verstehen es funktioniert ja gut - warum der Ansatz so ist, oder besser ich verstehe es nicht ;)

    Mein Regex Ansatz wäre gewesen:

    Suche ein " ... ; ... " und ersetze das ; druch in dem Fall ein chr(1). Und dann hinten raus nach dem StringSplit() das chr(1) wieder retour in ein ;

    Also im Grunde sowas ganz grob jetzt - ich bin nur Grundkenntnis-Regexer (ohne caputre Gruppen mal):

    Code
    ;".*;.*"

    Im ersten Feld des Satzes gibt es de facto kein hochkomma, daher die mal ausser acht gelassen und mit ; begonnen.

    Du suchst ja kein " etc... Weißt was ich meine?

    LG

    • Offizieller Beitrag

    Du mußt ja unterscheiden können in csv-Semikolon und Senikolon innerhalb von Gänsefüßchen.
    Innerhalb der Gänsefüßchen hast du theoretisch die Möglichkeit, dass das Semikolon direkt am Anfang, irgendwo drin oder am Ende steht. Die negative Character-Class deshalb, damit du alle Zeichen ausser diesem einen triffst. Du brauchst ja zwingend die Reihenfolge: Gänsefüßchen - irgendwas mit Semikolon drin - Gänsefüßchen. Um die Gänsefüßchen eindeutig zu matchen mußt du alles was davor und danach steht über die Class nicht Gänsefüßchen matchen. Ein .* würde jedes beliebige Zeichen, also auch das Gänsefüßchen matchen. Das könnte man auch mit einem "positive Lookahead" für das Gänsefüßchen lösen, aber diese Methode ist im Vergleich zur negative Class langsamer.

    • Offizieller Beitrag

    Kleiner Nachtrag:
    Es war schon zu kompliziert gedacht, erst das innere Semikolon zu ersetzen, dann zu splitten und anschließend wieder einfügen.
    Einfacher gehts, wenn man die Zeile sofort mit einem Pattern splittet, das beide Fälle berücksichtigt. So gehts:

    [autoit]

    $string = 'Peter;"Paul; the Best";Mary;Coca-Cola;"Beer; dark";Lemon'
    $aSplit = StringRegExp($string, '"[^;]*;[^;]*"|[^;]+', 3)
    For $i = 0 To UBound($aSplit) -1
    ConsoleWrite($aSplit[$i] & @CRLF)
    Next

    [/autoit]


    Kleine Erklärung zum Pattern:
    Durch die Pipe wird ein ODER abgefragt. Hier muß zuerst nach der Variante mit innerem Semikolon gefragt werden. Würde man zuerst den Match auf Semikolon als Feldtrenner durchführen, wäre keine Unterscheidung mehr in inneres und begrenzendes Semikolon möglich.


    Edit:
    Damit das etwas flexibler ist, habe ich das mal in eine UDF gepackt.
    Hiermit hast du die Möglichkeit
    - Felder, die intern den csv-Trenner enthalten, mit beliebigen (auch paarigen!) Zeichen einzufassen
    - csv-Dateien mit unterschiedlichen Trennern zu nutzen

    _CSV_SplitLine()
    [autoit]

    $string = 'Peter;[Paul; the Best];Mary;Coca-Cola;[Beer; dark];Lemon'

    [/autoit] [autoit][/autoit] [autoit]

    $aSplit = _CSV_SplitLine($string, '[]')
    For $i = 0 To UBound($aSplit) -1
    ConsoleWrite($aSplit[$i] & @CRLF)
    Next

    [/autoit] [autoit][/autoit] [autoit]

    ;===================================================================================================
    ; Function Name....: _CSV_SplitLine
    ; Description......: Splits a csv-line into field values
    ; Parameter(s).....: $_sLine The line from csv-file
    ; .....optional....: $_sEdge Characters are enclosed with the fields that contain a delimiter. Default: '"'
    ; .................: By using two chars (opening and closing, i.e. <..>) commit them in a string without any other char (i.e. "<>")
    ; .....optional....: $_sSeparator The csv-separator character. Default: ';'
    ; Return Value(s)..: Array with one field in each item
    ; Author...........: BugFix ([email='AutoIt@bug-fix.info'][/email])
    ;===================================================================================================
    Func _CSV_SplitLine($_sLine, $_sEdge='"', $_sSeparator=';')
    Local $sEdgePre = $_sEdge, $sEdgePost = $_sEdge
    If StringLen($_sEdge) = 2 Then
    $sEdgePre = StringLeft($_sEdge, 1)
    $sEdgePost = StringRight($_sEdge, 1)
    EndIf
    If StringInStr('\.^$|[({*+?#', $sEdgePre) Then $sEdgePre = '\' & $sEdgePre
    If StringInStr('\.^$|[({*+?#', $sEdgePost) Then $sEdgePost = '\' & $sEdgePost
    If StringInStr('\.^$|[({*+?#', $_sSeparator) Then $_sSeparator = '\' & $_sSeparator
    Local $sPattern = $sEdgePre & '[^' & $_sSeparator & ']*' & $_sSeparator & '[^' & $_sSeparator & ']*' & $sEdgePost & '|[^' & $_sSeparator & ']+'
    Return StringRegExp($_sLine, $sPattern, 3)
    EndFunc ;==>_CSV_SplitLine

    [/autoit]
  • Hey sehr cool, danke! Ich habe die alte Func von dir etwas umgebaut, weil ich ja ein Pendat zu StringSplit() brauche, das mir ein Array zurückgibt.

    [autoit]

    func SplitCsvString($str, $sDelim)

    [/autoit][autoit][/autoit][autoit]

    local $sEscChr = Chr(1)
    local $sReplaced = StringRegExpReplace($str, '([^"]*"[^;]*)(;)([^;]*"[^"]*)', '$1' & $sEscChr & '$3')
    local $aSplit = StringSplit($sReplaced, $sDelim)

    [/autoit][autoit][/autoit][autoit]

    If StringInStr('\.^$|[({*+?#', $sEscChr) Then
    $sEscChr = '\' & $sEscChr ; mask meta characters
    EndIf

    [/autoit][autoit][/autoit][autoit]

    For $i = 1 To $aSplit[0]
    $aSplit[$i] = StringRegExpReplace($aSplit[$i], '"([^' & $sEscChr & ']*)(' & $sEscChr & ')([^' & $sEscChr & ']*)"', '$1;$3')
    Next

    [/autoit][autoit][/autoit][autoit]

    return $aSplit
    EndFunc

    [/autoit]

    Werde aber das relevante von oben noch reinpacken, super.

    Jetzt würde nur noch das Doppelte Anführungzeichen fehlen um auch Felder mit Anfürhungszeichen zu escapen dann wäre es schon die komplette cvs Spezifikation :D (Scherz).

    Danke nochmals!
    LG

  • [autoit]


    #include <Array.au3>

    [/autoit][autoit][/autoit][autoit]

    $string = 'Peter;"Paul; the Best";Mary;Coca-Cola;"Beer; dark";Lemon'

    [/autoit][autoit][/autoit][autoit]

    $aSplit = StringRegExp($string, '"[^;]*;[^;]*"|[^;]+', 3)

    [/autoit][autoit][/autoit][autoit]

    _ArrayDisplay($aSplit)

    [/autoit]

    Jetzt hab ich zu deiner letzten Variante (Pattern) doch noch zwei Dinge:

    1. Im ersten [0] ArrayElement gibt es die Anzahl nicht, was ich gesehen habe kann das StringRegExp() nicht, die Anzahl der matches angeben

    2. Bei den eingeschlossenen sind die " .... " noch enthalten, die müsst ich noch mit trim() oder mit captures wegmachen, wenns ist.

    Code
    Row|Col 0
    [0] | Peter
    [1] | "Paul; the Best"
    [2] | Mary
    [3] | Coca-Cola
    [4] | "Beer; dark"
    [5] | Lemon
    • Offizieller Beitrag

    Das ist kein Problem, dazu muß man halt das Ergebnisarray einmal durchlaufen. Ich habe das gleich mal integriert.

    _CSV_SplitLine()
    [autoit]

    $string = 'Peter;[Paul; the Best];Mary;Coca-Cola;[Beer; dark];Lemon'

    [/autoit] [autoit][/autoit] [autoit]

    $aSplit = _CSV_SplitLine($string, '[]')
    For $i = 1 To $aSplit[0]
    ConsoleWrite($aSplit[$i] & @CRLF)
    Next

    [/autoit] [autoit][/autoit] [autoit]

    ;===================================================================================================
    ; Function Name....: _CSV_SplitLine
    ; Description......: Splits a csv-line into field values
    ; Parameter(s).....: $_sLine The line from csv-file
    ; .....optional....: $_sEdge Characters are enclosed with the fields that contain a delimiter. Default: '"'
    ; .................: By using two chars (opening and closing, i.e. <..>) commit them in a string without any other char (i.e. "<>")
    ; .....optional....: $_fDeleteEdge Deletes after parsing the the edging characters if any. Default: True (delete them)
    ; .....optional....: $_sSeparator The csv-separator character. Default: ';'
    ; Return Value(s)..: Success: Array with one field value in each item, count of item at $a[0]
    ; .................: Failure: 0 set @error=1 (no match)
    ; Author...........: BugFix ([email='AutoIt@bug-fix.info'][/email])
    ;===================================================================================================
    Func _CSV_SplitLine($_sLine, $_sEdge='"', $_fDeleteEdge=True, $_sSeparator=';')
    Local $sEdgePre = $_sEdge, $sEdgePost = $_sEdge, $iMatches
    If StringLen($_sEdge) = 2 Then
    $sEdgePre = StringLeft($_sEdge, 1)
    $sEdgePost = StringRight($_sEdge, 1)
    EndIf
    If StringInStr('\.^$|[({*+?#', $sEdgePre) Then $sEdgePre = '\' & $sEdgePre
    If StringInStr('\.^$|[({*+?#', $sEdgePost) Then $sEdgePost = '\' & $sEdgePost
    If StringInStr('\.^$|[({*+?#', $_sSeparator) Then $_sSeparator = '\' & $_sSeparator
    Local $sPattern = $sEdgePre & '[^' & $_sSeparator & ']*' & $_sSeparator & '[^' & $_sSeparator & ']*' & $sEdgePost & '|[^' & $_sSeparator & ']+'
    $aMatch = StringRegExp($_sLine, $sPattern, 3)
    If @error Then Return SetError(1,0,0)
    $iMatches = UBound($aMatch)
    Local $aRet[$iMatches+1] = [$iMatches]
    For $i = 0 To $iMatches -1
    If $_fDeleteEdge Then
    $aRet[$i+1] = StringRegExpReplace($aMatch[$i], $sEdgePre & '([^' & $_sSeparator & ']*' & $_sSeparator & '[^' & $_sSeparator & ']*)' & $sEdgePost, '$1')
    Else
    $aRet[$i+1] = $aMatch[$i]
    EndIf
    Next
    Return $aRet
    EndFunc ;==>_CSV_SplitLine

    [/autoit]
  • Jetzt aber wirds kompliziert ;)

    - in der Zeile fehlte das local vorher

    [autoit]

    Local $aMatch = StringRegExp($_sLine, $sPattern, 3)

    [/autoit]

    Die Funktion hat Probleme mit leeren Feldern, es werden nur Felder mit Wert übernommen

    [autoit]


    #include <Array.au3>

    [/autoit][autoit][/autoit][autoit]

    $string = 'Peter;Paul;Stan;;;Ollie'

    [/autoit][autoit][/autoit][autoit]

    $aSplit = _CSV_SplitLine($string)

    [/autoit][autoit][/autoit][autoit]

    _ArrayDisplay($aSplit)

    [/autoit]

    gibt

    Code
    Row|Col 0
    [0]|4
    [1]|Peter
    [2]|Paul
    [3]|Stan
    [4]|Ollie

    Sollte aber vor Ollie noch zwei leere Felder ausgeben

    Ev. war das der Grund für den anderen Regex?

  • Zitat

    ..schreib es rein :whistling:

    Klaro, hab ich doch, sonst hätte ja "mein" AutoIt gestreikt ;)

    Zitat

    das muß ich mal anschauen

    Danke, keinen Stress, ich fand die vorige Funktion auch schon sehr hilfreich :)

  • Frage noch dazu.. wie würde das Pattern von der ersten Variante aussehen, wenn auch mehrere Semikolon innerhalb der " " erlaubt sind?

    [autoit]

    local $sReplaced = StringRegExpReplace($str, '([^"]*"[^;]*)(;)([^;]*"[^"]*)', '$1' & $sEscChr & '$3')

    [/autoit]

    Also zB in

    Code
    Hans;trinkt;gerne;"Cola;Bier;Wein;Schnaps"

    Ist das dann mit Regex noch machbar? Sonst bau ich das Ding um...

  • Ich habe nun noch im www diesen Thread gefunden, dort wird auch ein Pattern/Variante angeführt, die gut zu funktionieren scheint.. Hier mal die von mir angepasste (für meine Zwecke) fertige Funktion - der Vollständigkeit halber, oder falls jemanden etwas "blödes" dabei auffält, bitte danke für jeden Input dazu.
    http://www.autoitscript.com/forum/topic/10…ted-delimiters/


    [autoit][/autoit][autoit][/autoit][autoit]

    #include <Array.au3>

    [/autoit][autoit][/autoit][autoit]

    local $sCSV = 'Hans;im;"Glück";"Und;so;weiter;immer;heiter";und;weiter'

    [/autoit][autoit][/autoit][autoit]

    local $aSplitData = SplitCsvString($sCSV, ";")
    _ArrayDisplay($aSplitData)

    [/autoit][autoit][/autoit][autoit]

    #cs
    Row|Col 0
    [0]|6
    [1]|Hans
    [2]|im
    [3]|Glück
    [4]|Und;so;weiter;immer;heiter
    [5]|und
    [6]|weiter
    #ce

    [/autoit][autoit][/autoit][autoit][/autoit][autoit]

    Func SplitCsvString($sString, $sDelim)

    [/autoit][autoit][/autoit][autoit]

    Local $sReplaceChr = Chr(1)
    ;Local $sReplaceChr = "#"
    Local $sPattern = $sDelim & '(?=(?:[^"]*"[^"]*")*(?![^"]*"))'
    Local $sTemp = StringRegExpReplace($sString, $sPattern, $sReplaceChr, 0)
    ;Consolewrite($sTemp & @CRLF)

    [/autoit][autoit][/autoit][autoit]

    ;Strip trailing character if it matches the reserved pattern
    ;If StringRight($sTemp, 1) = $sReplaceChr Then $sTemp = StringTrimRight($sTemp, 1)

    [/autoit][autoit][/autoit][autoit]

    ;Split string using entire reserved string
    Local $aSplit = StringSplit($sTemp, $sReplaceChr, 1)

    [/autoit][autoit][/autoit][autoit]

    ; Anführungszeichen vorne und hinten entfernen, wenn vorhanden
    For $i = 1 To $aSplit[0]
    If StringRight($aSplit[$i], 1) = '"' Then $aSplit[$i] = StringTrimRight($aSplit[$i], 1)
    If StringLeft($aSplit[$i], 1) = '"' Then $aSplit[$i] = StringTrimLeft($aSplit[$i], 1)
    Next

    [/autoit][autoit][/autoit][autoit]

    Return $aSplit
    EndFunc

    [/autoit]

    LG