diff --git a/dataframe-core/src-internal/DataFrame/Internal/DataFrame.hs b/dataframe-core/src-internal/DataFrame/Internal/DataFrame.hs index ee0961f4..23319357 100644 --- a/dataframe-core/src-internal/DataFrame/Internal/DataFrame.hs +++ b/dataframe-core/src-internal/DataFrame/Internal/DataFrame.hs @@ -335,10 +335,20 @@ toSeparated sep df let (rows, _) = dataframeDimensions df headers = map fst (sortBy (compare `on` snd) (M.toList (columnIndices df))) sepText = T.singleton sep - headerLine = T.intercalate sepText headers - dataLines = map (T.intercalate sepText . getRowAsText df) [0 .. rows - 1] + escape = escapeField sep + headerLine = T.intercalate sepText (map escape headers) + dataLines = + map (T.intercalate sepText . map escape . getRowAsText df) [0 .. rows - 1] in T.unlines (headerLine : dataLines) +-- | RFC 4180: quote fields with sep, quote or newline; double inner quotes. +escapeField :: Char -> T.Text -> T.Text +escapeField sep t + | T.any needsQuoting t = "\"" <> T.replace "\"" "\"\"" t <> "\"" + | otherwise = t + where + needsQuoting c = c == sep || c == '"' || c == '\n' || c == '\r' + getRowAsText :: DataFrame -> Int -> [T.Text] getRowAsText df i = map (`showElement` i) (V.toList (columns df)) diff --git a/tests/IO/CSV.hs b/tests/IO/CSV.hs index e015ab2e..a020ae2e 100644 --- a/tests/IO/CSV.hs +++ b/tests/IO/CSV.hs @@ -86,6 +86,19 @@ fromCsvSingleColumn = TestLabel "fromCsv_single_column" $ TestCase $ do assertEqual "rows" 3 (D.nRows df) assertEqual "columns" 1 (D.nColumns df) +-- | Round trip: fields holding separators, quotes and newlines survive. +fromCsvRoundTripQuoted :: Test +fromCsvRoundTripQuoted = TestLabel "fromCsv_roundTrip_quoted" $ TestCase $ do + let df = + D.fromNamedColumns + [ ("a,b", DI.fromList @T.Text ["x,y", "he said \"hi\"", "line1\nline2"]) + , ("c", DI.fromList @Int [1, 2, 3]) + ] + result <- fromCsv (T.unpack (toCsv df)) + case result of + Left err -> assertFailure $ "Unexpected Left: " ++ err + Right df' -> assertEqual "round trip data" df df' + tests :: [Test] tests = [ fromCsvHappyPath @@ -94,4 +107,5 @@ tests = , fromCsvRoundTrip , fromCsvBytesRoundTrip , fromCsvSingleColumn + , fromCsvRoundTripQuoted ]