Skip to content

Commit 8345955

Browse files
authored
Merge pull request #81 from Quafadas/copilot/fix-16
Add CSV string generation functionality to complement existing CSV reading
2 parents 5140f7c + b2c1abb commit 8345955

File tree

4 files changed

+286
-0
lines changed

4 files changed

+286
-0
lines changed

scautable/src/csvWriter.scala

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package io.github.quafadas.scautable
2+
3+
/** RFC 4180 compliant CSV writer that mirrors the CSVParser functionality.
4+
*/
5+
private[scautable] object CSVWriter:
6+
7+
/** Formats a field for CSV output according to RFC 4180.
8+
*
9+
* @param value The field value to format
10+
* @param delimiter The delimiter character (usually comma)
11+
* @param quote The quote character (usually double quote)
12+
* @return The formatted field, quoted if necessary
13+
*/
14+
def formatField(value: String, delimiter: Char = ',', quote: Char = '"'): String =
15+
if needsQuoting(value, delimiter, quote) then
16+
quote + escapeQuotes(value, quote) + quote
17+
else
18+
value
19+
end formatField
20+
21+
/** Formats a complete line for CSV output.
22+
*
23+
* @param fields The sequence of field values
24+
* @param delimiter The delimiter character (usually comma)
25+
* @param quote The quote character (usually double quote)
26+
* @return The formatted CSV line
27+
*/
28+
def formatLine(fields: Seq[String], delimiter: Char = ',', quote: Char = '"'): String =
29+
fields.map(formatField(_, delimiter, quote)).mkString(delimiter.toString)
30+
end formatLine
31+
32+
/** Determines if a field needs quoting according to RFC 4180.
33+
*
34+
* Fields need quoting if they contain:
35+
* - The delimiter character
36+
* - Quote characters
37+
* - Newline characters (CR or LF)
38+
* - Leading or trailing whitespace
39+
*/
40+
private def needsQuoting(value: String, delimiter: Char, quote: Char): Boolean =
41+
value.contains(delimiter) ||
42+
value.contains(quote) ||
43+
value.contains('\n') ||
44+
value.contains('\r') ||
45+
value.startsWith(" ") ||
46+
value.endsWith(" ")
47+
end needsQuoting
48+
49+
/** Escapes quote characters within a field value according to RFC 4180.
50+
*
51+
* In RFC 4180, quote characters are escaped by doubling them.
52+
*/
53+
private def escapeQuotes(value: String, quote: Char): String =
54+
value.replace(quote.toString, quote.toString + quote.toString)
55+
end escapeQuotes
56+
57+
end CSVWriter
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package io.github.quafadas.scautable
2+
3+
import scala.compiletime.*
4+
import scala.collection.BuildFrom
5+
import scala.NamedTuple.*
6+
7+
/** Extension methods for writing NamedTuple collections to CSV format.
8+
*/
9+
object CSVWriterExtensions:
10+
11+
extension [K <: Tuple, V <: Tuple](itr: Iterator[NamedTuple[K, V]])
12+
13+
/** Converts the iterator to CSV format as a string.
14+
*
15+
* @param includeHeaders
16+
* Whether to include column headers as the first line
17+
* @param delimiter
18+
* The delimiter character (default: comma)
19+
* @param quote
20+
* The quote character (default: double quote)
21+
* @return
22+
* CSV formatted string
23+
*/
24+
inline def toCsv(
25+
includeHeaders: Boolean = true,
26+
delimiter: Char = ',',
27+
quote: Char = '"'
28+
): Iterator[String] =
29+
val headers = constValueTuple[K].toList.map(_.toString())
30+
val headerLine = CSVWriter.formatLine(headers, delimiter, quote)
31+
32+
val striterator = itr.map { namedTuple =>
33+
val values = namedTuple.toList.map(_.toString)
34+
CSVWriter.formatLine(values, delimiter, quote)
35+
}
36+
if includeHeaders then Iterator(headerLine) ++ striterator
37+
else striterator
38+
end toCsv
39+
40+
end extension
41+
42+
extension [CC[X] <: Iterable[X], K <: Tuple, V <: Tuple](data: CC[NamedTuple[K, V]])
43+
44+
/** Converts the iterable to CSV format as a string.
45+
*
46+
* @param includeHeaders
47+
* Whether to include column headers as the first line
48+
* @param delimiter
49+
* The delimiter character (default: comma)
50+
* @param quote
51+
* The quote character (default: double quote)
52+
* @return
53+
* CSV formatted string
54+
*/
55+
inline def toCsv(
56+
includeHeaders: Boolean,
57+
delimiter: Char,
58+
quote: Char
59+
): String =
60+
val headers = constValueTuple[K].toList.map(_.toString())
61+
val headerLine =
62+
if includeHeaders then Seq(CSVWriter.formatLine(headers, delimiter, quote))
63+
else Seq.empty
64+
65+
val dataLines = data.view.map { namedTuple =>
66+
val values = namedTuple.toList.map(_.toString)
67+
CSVWriter.formatLine(values, delimiter, quote)
68+
}.toSeq
69+
70+
(headerLine ++ dataLines).mkString("\n")
71+
end toCsv
72+
73+
end extension
74+
75+
end CSVWriterExtensions

scautable/src/package.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ object table:
99
export io.github.quafadas.scautable.Excel.*
1010
export io.github.quafadas.scautable.ConsoleFormat.*
1111
export io.github.quafadas.scautable.NamedTupleIteratorExtensions.*
12+
export io.github.quafadas.scautable.CSVWriterExtensions.*
1213
export io.github.quafadas.scautable.HeaderOptions.*
1314

1415

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package io.github.quafadas.scautable
2+
3+
import io.github.quafadas.table.*
4+
5+
6+
class CSVWriterSuite extends munit.FunSuite:
7+
8+
test("CSVWriter.formatField handles simple fields") {
9+
assertEquals(CSVWriter.formatField("hello"), "hello")
10+
assertEquals(CSVWriter.formatField("123"), "123")
11+
assertEquals(CSVWriter.formatField(""), "")
12+
}
13+
14+
test("CSVWriter.formatField quotes fields with commas") {
15+
assertEquals(CSVWriter.formatField("hello,world"), "\"hello,world\"")
16+
assertEquals(CSVWriter.formatField("a,b,c"), "\"a,b,c\"")
17+
}
18+
19+
test("CSVWriter.formatField escapes quotes") {
20+
assertEquals(CSVWriter.formatField("say \"hello\""), "\"say \"\"hello\"\"\"")
21+
assertEquals(CSVWriter.formatField("\"quoted\""), "\"\"\"quoted\"\"\"")
22+
}
23+
24+
test("CSVWriter.formatField quotes fields with newlines") {
25+
assertEquals(CSVWriter.formatField("line1\nline2"), "\"line1\nline2\"")
26+
assertEquals(CSVWriter.formatField("line1\rline2"), "\"line1\rline2\"")
27+
}
28+
29+
test("CSVWriter.formatField quotes fields with leading/trailing spaces") {
30+
assertEquals(CSVWriter.formatField(" hello"), "\" hello\"")
31+
assertEquals(CSVWriter.formatField("hello "), "\"hello \"")
32+
assertEquals(CSVWriter.formatField(" hello "), "\" hello \"")
33+
}
34+
35+
test("CSVWriter.formatLine formats simple line") {
36+
assertEquals(CSVWriter.formatLine(Seq("a", "b", "c")), "a,b,c")
37+
assertEquals(CSVWriter.formatLine(Seq("1", "2", "3")), "1,2,3")
38+
}
39+
40+
test("CSVWriter.formatLine handles complex line") {
41+
assertEquals(
42+
CSVWriter.formatLine(Seq("hello,world", "say \"hi\"", "normal")),
43+
"\"hello,world\",\"say \"\"hi\"\"\",normal"
44+
)
45+
}
46+
47+
test("CSVWriter.formatLine with custom delimiter") {
48+
assertEquals(CSVWriter.formatLine(Seq("a", "b", "c"), delimiter = ';'), "a;b;c")
49+
assertEquals(CSVWriter.formatLine(Seq("a;b", "c"), delimiter = ';'), "\"a;b\";c")
50+
}
51+
52+
test("Iterator[NamedTuple].toCsv basic functionality") {
53+
val data = Iterator(
54+
(col1 = "1", col2 = "2", col3 = "7"),
55+
(col1 = "3", col2 = "4", col3 = "8"),
56+
(col1 = "5", col2 = "6", col3 = "9")
57+
)
58+
59+
val expected = Seq("col1,col2,col3",
60+
"1,2,7",
61+
"3,4,8",
62+
"5,6,9")
63+
64+
assertEquals(
65+
data.toCsv(includeHeaders = true, ',', '"'
66+
).toSeq, expected)
67+
}
68+
69+
test("Iterator[NamedTuple].toCsv without headers") {
70+
val data = Iterator(
71+
(col1 = "1", col2 = "2", col3 = "7"),
72+
(col1 = "3", col2 = "4", col3 = "8")
73+
)
74+
75+
val expected = Seq("1,2,7", "3,4,8")
76+
77+
assertEquals(
78+
data.toCsv(includeHeaders = false, ',', '"').toSeq,
79+
expected
80+
)
81+
}
82+
83+
test("Iterator[NamedTuple].toCsv with custom delimiter") {
84+
val data = Iterator(
85+
(col1 = "1", col2 = "2"),
86+
(col1 = "3", col2 = "4")
87+
)
88+
89+
val expected = Seq(
90+
"""col1;col2""",
91+
"""1;2""",
92+
"""3;4""")
93+
94+
assertEquals(data.toCsv(delimiter = ';').toSeq, expected)
95+
}
96+
97+
test("Seq[NamedTuple].toCsv basic functionality") {
98+
val data = Seq(
99+
(col1 = "1", col2 = "2", col3 = "7"),
100+
(col1 = "3", col2 = "4", col3 = "8"),
101+
(col1 = "5", col2 = "6", col3 = "9")
102+
)
103+
104+
val expected = """col1,col2,col3
105+
1,2,7
106+
3,4,8
107+
5,6,9"""
108+
109+
assertEquals(data.toCsv(true, ',', '"'), expected)
110+
}
111+
112+
test("List[NamedTuple].toCsv with empty values") {
113+
val data = List(
114+
(col1 = "", col2 = "2", col3 = "7"),
115+
(col1 = "3", col2 = "", col3 = "8"),
116+
(col1 = "5", col2 = "6", col3 = "")
117+
)
118+
119+
val expected = """col1,col2,col3
120+
,2,7
121+
3,,8
122+
5,6,"""
123+
124+
assertEquals(data.toCsv(true, ',', '"'), expected)
125+
}
126+
127+
test("Vector[NamedTuple].toCsv with numeric types") {
128+
val data = Vector(
129+
(id = 1, name = "Alice", score = 95.5),
130+
(id = 2, name = "Bob", score = 87.1)
131+
)
132+
133+
val expected = """id,name,score
134+
1,Alice,95.5
135+
2,Bob,87.1"""
136+
137+
assertEquals(data.toCsv(true, ',', '"'), expected)
138+
}
139+
140+
test("empty collection toCsv") {
141+
// Note: This test requires the type to be explicit since we can't infer from empty collections
142+
val data: List[(col1: String, col2: String)] = List.empty
143+
val expected = "col1,col2"
144+
145+
assertEquals(data.toCsv(true, ',', '"'), expected)
146+
}
147+
148+
test("empty collection toCsv without headers") {
149+
val data: List[(col1: String, col2: String)] = List.empty
150+
assertEquals(data.toCsv(false, ',', '"'), "")
151+
}
152+
153+
end CSVWriterSuite

0 commit comments

Comments
 (0)