Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions tests/FSharp.Data.Benchmarks/CsvBenchmarks.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace FSharp.Data.Benchmarks

open System
open System.IO
open BenchmarkDotNet.Attributes
open FSharp.Data

[<MemoryDiagnoser>]
[<SimpleJob>]
type CsvBenchmarks() =

let mutable airQualityCsvText = ""
let mutable msftCsvText = ""
let mutable titanicCsvText = ""
let mutable banklistCsvText = ""

[<GlobalSetup>]
member this.Setup() =
let dataPath = Path.Combine(__SOURCE_DIRECTORY__, "../FSharp.Data.Tests/Data")
airQualityCsvText <- File.ReadAllText(Path.Combine(dataPath, "AirQuality.csv"))
msftCsvText <- File.ReadAllText(Path.Combine(dataPath, "MSFT.csv"))
titanicCsvText <- File.ReadAllText(Path.Combine(dataPath, "Titanic.csv"))
banklistCsvText <- File.ReadAllText(Path.Combine(dataPath, "banklist.csv"))

[<Benchmark>]
member this.ParseAirQualityCsv() = CsvFile.Parse(airQualityCsvText)

[<Benchmark>]
member this.IterateAirQualityCsv() =
let csv = CsvFile.Parse(airQualityCsvText)
csv.Rows |> Seq.length

[<Benchmark>]
member this.ParseMSFTCsv() = CsvFile.Parse(msftCsvText)

[<Benchmark>]
member this.IterateMSFTCsv() =
let csv = CsvFile.Parse(msftCsvText)
csv.Rows |> Seq.length

[<Benchmark>]
member this.ParseTitanicCsv() = CsvFile.Parse(titanicCsvText)

[<Benchmark>]
member this.IterateTitanicCsv() =
let csv = CsvFile.Parse(titanicCsvText)
csv.Rows |> Seq.length

[<Benchmark>]
member this.ParseBanklistCsv() = CsvFile.Parse(banklistCsvText)

[<Benchmark>]
member this.IterateBanklistCsv() =
let csv = CsvFile.Parse(banklistCsvText)
csv.Rows |> Seq.length
1 change: 1 addition & 0 deletions tests/FSharp.Data.Benchmarks/FSharp.Data.Benchmarks.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
</EmbeddedResource>
<Compile Include="JsonBenchmarks.fs" />
<Compile Include="HtmlBenchmarks.fs" />
<Compile Include="CsvBenchmarks.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
Expand Down
6 changes: 5 additions & 1 deletion tests/FSharp.Data.Benchmarks/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ let main args =
match args with
| [| "json" |] -> BenchmarkRunner.Run<JsonBenchmarks>() |> ignore
| [| "conversions" |] -> BenchmarkRunner.Run<JsonConversionBenchmarks>() |> ignore
| _ ->
| [| "html" |] -> BenchmarkRunner.Run<HtmlBenchmarks>() |> ignore
| [| "csv" |] -> BenchmarkRunner.Run<CsvBenchmarks>() |> ignore
| _ ->
printfn "Running all benchmarks..."
BenchmarkRunner.Run<JsonBenchmarks>() |> ignore
BenchmarkRunner.Run<JsonConversionBenchmarks>() |> ignore
BenchmarkRunner.Run<HtmlBenchmarks>() |> ignore
BenchmarkRunner.Run<CsvBenchmarks>() |> ignore

0
144 changes: 144 additions & 0 deletions tests/FSharp.Data.Core.Tests/CsvParserProperties.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
module FSharp.Data.Tests.CsvParserProperties

open NUnit.Framework
open FsUnit
open System
open System.IO
open FSharp.Data.Runtime.CsvReader
open FsCheck

/// Encodes a CSV field value according to RFC 4180, quoting when necessary.
/// Empty strings are always quoted to avoid producing blank CSV lines.
let private encodeCsvField (separator: char) (quote: char) (value: string) =
let needsQuoting =
value = ""
|| value.Contains(separator)
|| value.Contains(quote)
|| value.Contains('\n')
|| value.Contains('\r')

if needsQuoting then
let escaped = value.Replace(string quote, string quote + string quote)
sprintf "%c%s%c" quote escaped quote
else
value

/// Encodes a row of field values as a single CSV line.
let private encodeCsvRow (separator: char) (quote: char) (fields: string[]) =
fields |> Array.map (encodeCsvField separator quote) |> String.concat (string separator)

/// Parses a CSV string and returns all logical rows as arrays of field values.
let private parseCsv (csv: string) (separator: char) (quote: char) =
use reader = new StringReader(csv)
readCsvFile reader (string separator) quote |> Seq.map fst |> Array.ofSeq

[<Test>]
let ``CSV roundtrip property: arbitrary string values are preserved when properly encoded`` () =
let separator = ','
let quote = '"'

// Generate non-empty rows of non-null strings
let fieldGen = Arb.generate<string> |> Gen.map (fun s -> if s = null then "" else s)

let rowGen =
Gen.nonEmptyListOf fieldGen |> Gen.map Array.ofList |> Gen.resize 6

let rowsGen = Gen.nonEmptyListOf rowGen |> Gen.map Array.ofList |> Gen.resize 8

let prop (rows: string[][]) =
let csv =
rows |> Array.map (encodeCsvRow separator quote) |> String.concat "\n"

let parsed = parseCsv csv separator quote

parsed.Length = rows.Length
&& Array.forall2
(fun (expected: string[]) (actual: string[]) ->
expected.Length = actual.Length && Array.forall2 (=) expected actual)
rows
parsed

Check.One(
{ Config.QuickThrowOnFailure with MaxTest = 500 },
Prop.forAll (Arb.fromGen rowsGen) prop
)

[<Test>]
let ``CSV roundtrip: field containing separator is preserved as a single field`` () =
let fields = [| "value with, comma"; "normal field"; "a,b,c" |]
let csv = encodeCsvRow ',' '"' fields
let parsed = parseCsv csv ',' '"'
parsed.Length |> should equal 1
parsed.[0] |> should equal fields

[<Test>]
let ``CSV roundtrip: field containing quote character is preserved`` () =
let fields = [| "say \"hello\" world"; "normal"; "she said \"hi\"" |]
let csv = encodeCsvRow ',' '"' fields
let parsed = parseCsv csv ',' '"'
parsed.Length |> should equal 1
parsed.[0] |> should equal fields

[<Test>]
let ``CSV roundtrip: field containing newline spans one logical row`` () =
let fields = [| "line1\nline2"; "normal" |]
let csv = encodeCsvRow ',' '"' fields
let parsed = parseCsv csv ',' '"'
parsed.Length |> should equal 1
parsed.[0] |> should equal fields

[<Test>]
let ``CSV roundtrip: field containing carriage-return newline is preserved`` () =
let fields = [| "multi\r\nline"; "normal" |]
let csv = encodeCsvRow ',' '"' fields
let parsed = parseCsv csv ',' '"'
parsed.Length |> should equal 1
parsed.[0] |> should equal fields

[<Test>]
let ``CSV roundtrip: tab separator is supported`` () =
let fields = [| "a,b,c"; "d\te"; "normal" |]
let csv = encodeCsvRow '\t' '"' fields
let parsed = parseCsv csv '\t' '"'
parsed.Length |> should equal 1
parsed.[0] |> should equal fields

[<Test>]
let ``CSV roundtrip: custom quote character is respected`` () =
let fields = [| "value with, comma"; "value with 'single'" |]
let csv = encodeCsvRow ',' '\'' fields
let parsed = parseCsv csv ',' '\''
parsed.Length |> should equal 1
parsed.[0] |> should equal fields

[<Test>]
let ``CSV roundtrip: multiple rows with varied content are all preserved`` () =
let rows =
[| [| "Alice"; "30"; "New York, NY" |]
[| "Bob"; "25"; "Los Angeles" |]
[| "Charlie says \"hello\""; "35"; "Chicago\nIL" |] |]

let csv = rows |> Array.map (encodeCsvRow ',' '"') |> String.concat "\n"
let parsed = parseCsv csv ',' '"'
parsed.Length |> should equal rows.Length

Array.iter2 (fun expected actual -> actual |> should equal expected) rows parsed

[<Test>]
let ``CSV roundtrip: empty string field is preserved`` () =
let fields = [| ""; "non-empty"; "" |]
let csv = encodeCsvRow ',' '"' fields
let parsed = parseCsv csv ',' '"'
parsed.Length |> should equal 1
parsed.[0] |> should equal fields

[<Test>]
let ``CSV roundtrip: single-column empty field row is not lost`` () =
let rows = [| [| "before" |]; [| "" |]; [| "after" |] |]

let csv = rows |> Array.map (encodeCsvRow ',' '"') |> String.concat "\n"
let parsed = parseCsv csv ',' '"'
parsed.Length |> should equal 3
parsed.[0] |> should equal [| "before" |]
parsed.[1] |> should equal [| "" |]
parsed.[2] |> should equal [| "after" |]
1 change: 1 addition & 0 deletions tests/FSharp.Data.Core.Tests/FSharp.Data.Core.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<Compile Include="JsonSchema.fs" />
<Compile Include="CsvReader.fs" />
<Compile Include="CsvFile.fs" />
<Compile Include="CsvParserProperties.fs" />
<Compile Include="HtmlCharRefs.fs" />
<Compile Include="HtmlParser.fs" />
<Compile Include="HtmlOperations.fs" />
Expand Down
Loading