Two independent defects in the same CSV round trip. Both verified against com.ladybugdb:lbug 0.19.0 and against the lbug shell reporting Lbug 0.19.0; source cites are main @ c934f673b.
I don't need this anymore (Parquet does round-trip) but I think it's important enough to report.
1. Nested types export in display form, which the reader cannot parse
CREATE NODE TABLE T(id STRING, m MAP(STRING, STRING), PRIMARY KEY(id));
CREATE (:T {id: 'r1', m: map(['k,1','k"2'], ['v,1','v"2'])});
COPY (MATCH (n:T) RETURN n.id, n.m) TO '/tmp/t.csv' (HEADER=true);
CREATE NODE TABLE T2(id STRING, m MAP(STRING, STRING), PRIMARY KEY(id));
COPY T2 FROM '/tmp/t.csv' (HEADER=true);
Copy exception: Error in file /tmp/t.csv on line 2: Conversion exception:
Cast failed. {k,1=v,1, k"2=v"2} is not in MAP(STRING, STRING) range.
The file contains "{k,1=v,1, k""2=v""2}". The outer CSV quoting is correct; the contents are the human-readable form. MAP, STRUCT and LIST all render this way and none escapes the element delimiter.
Per type on 0.19.0, one column and one row, exported and then imported into an identical table:
| value |
on disk |
read back |
STRING 'has,comma' |
"has,comma" |
exact |
STRING[] ['a','b'] |
"[a,b]" |
exact |
STRING[] ['a,comma','b'] |
"[a,comma,b]" |
silently 3 elements |
STRING[] ['a]b','c'] |
"[a]b,c]" |
Copy exception |
MAP map(['k'],['v,1']) |
"{k=v,1}" |
Copy exception |
STRUCT {a: 7, b: 'x,y'} |
"{a: 7, b: x,y}" |
Copy exception |
The LIST row is the one that worries me. size(n.v) is 2 before the round trip and 3 after, with no error and no warning: the display form of ['a,comma','b'] and of ['a','comma','b'] is the same string, and the reader takes the second reading. MAP and STRUCT at least fail loudly.
export_csv_function.cpp binds a cast to STRING per column (src/function/export/export_csv_function.cpp:165) and writeRows (:199) writes the result. That cast produces the display form ({k=v, …}, {a: 7, b: x}, [a,b]) rather than a literal the CSV reader's own value parser accepts.
PARALLEL=FALSE does not help. It addresses the separate quoted-newline limitation, which is still enforced on 0.19.0 (Quoted newlines are not supported in parallel CSV reader) and still fixed by that option.
Consequence: EXPORT DATABASE to CSV is not round-trippable for any nested column whose values can contain a delimiter: loudly for MAP and STRUCT, silently for LIST.
2. An empty string comes back as NULL
CREATE NODE TABLE E(id STRING, s STRING, PRIMARY KEY(id));
CREATE (:E {id:'empty', s:''});
CREATE (:E {id:'null'}); -- s absent
CREATE (:E {id:'plain', s:'x'});
COPY (MATCH (n:E) RETURN n.id, n.s ORDER BY n.id) TO '/tmp/e.csv' (HEADER=true);
CREATE NODE TABLE E1(id STRING, s STRING, PRIMARY KEY(id));
COPY E1 FROM '/tmp/e.csv' (HEADER=true);
MATCH (n:E1) RETURN n.id, n.s IS NULL ORDER BY n.id;
| id |
source IS NULL |
on disk |
after import |
empty |
false |
empty,"" |
true |
null |
true |
null, |
true |
plain |
false |
plain,x |
false |
The file is not ambiguous. The writer already emits "" for the empty string and a bare empty field for NULL. The distinction is discarded on the way in: ParsingDriver::addValue receives the field already unquoted (src/processor/operator/persistent/reader/csv/driver.cpp:22), and setVectorNull matches that text against option->nullStrings (src/function/cast_from_string_functions.cpp:861), whose default is {""} (CopyConstants::DEFAULT_CSV_NULL_STRINGS, src/include/common/constants.h). The two collapse at the reader, not in the format.
NULL_STRINGS does not rescue it; it only flips which way they collapse. With NULL_STRINGS=['\N'] the same file reads back with IS NULL false for both rows, so the genuine NULL is now an empty string.
Parquet keeps IS NULL false for the empty string and true for the absent one.
This one bit me and was caught almost by chance: I had a legitimate path = "", and the CSV loader was storing NULL for it. It surfaced only from a graph-to-graph diff. A test asserting "empty" passes when the value reads back as NULL through most comparisons, so I was lucky to be diffing graphs.
Fixes?
I could think of these:
- Emit a re-parsable literal for nested types on export, escaping delimiters and quotes within elements, and teach the reader the same escaping; or
- teach the reader the display form it already produces; or
- document that CSV export is lossy for nested types and point users at Parquet for round-tripping.
Defect 2 is separable from all three, and looks cheaper: the bytes already carry the distinction, so it is a matter of the reader knowing whether the field it is about to null-test was quoted. That is the convention most CSV dialects use.
Even (3) alone would be worth having. Round-tripping its own export is the property a user reasonably assumes of EXPORT DATABASE, and for LIST and for the empty string the failure is silent.
Reproduction
Everything above is plain Cypher: no bindings, no extensions, no parameters. Both blocks run unmodified in the shell (lbug :memory:), which is how the two tables were produced; the same statements through the JVM binding give identical results. Happy to turn them into a test/test_files/copy/*.test case alongside export_import_db.test if that is more useful than the description.
Two independent defects in the same CSV round trip. Both verified against
com.ladybugdb:lbug0.19.0 and against thelbugshell reportingLbug 0.19.0; source cites aremain@c934f673b.I don't need this anymore (Parquet does round-trip) but I think it's important enough to report.
1. Nested types export in display form, which the reader cannot parse
The file contains
"{k,1=v,1, k""2=v""2}". The outer CSV quoting is correct; the contents are the human-readable form.MAP,STRUCTandLISTall render this way and none escapes the element delimiter.Per type on 0.19.0, one column and one row, exported and then imported into an identical table:
STRING'has,comma'"has,comma"STRING[]['a','b']"[a,b]"STRING[]['a,comma','b']"[a,comma,b]"STRING[]['a]b','c']"[a]b,c]"Copy exceptionMAPmap(['k'],['v,1'])"{k=v,1}"Copy exceptionSTRUCT{a: 7, b: 'x,y'}"{a: 7, b: x,y}"Copy exceptionThe
LISTrow is the one that worries me.size(n.v)is 2 before the round trip and 3 after, with no error and no warning: the display form of['a,comma','b']and of['a','comma','b']is the same string, and the reader takes the second reading.MAPandSTRUCTat least fail loudly.export_csv_function.cppbinds a cast toSTRINGper column (src/function/export/export_csv_function.cpp:165) andwriteRows(:199) writes the result. That cast produces the display form ({k=v, …},{a: 7, b: x},[a,b]) rather than a literal the CSV reader's own value parser accepts.PARALLEL=FALSEdoes not help. It addresses the separate quoted-newline limitation, which is still enforced on 0.19.0 (Quoted newlines are not supported in parallel CSV reader) and still fixed by that option.Consequence:
EXPORT DATABASEto CSV is not round-trippable for any nested column whose values can contain a delimiter: loudly forMAPandSTRUCT, silently forLIST.2. An empty string comes back as NULL
IS NULLemptyempty,""nullnull,plainplain,xThe file is not ambiguous. The writer already emits
""for the empty string and a bare empty field for NULL. The distinction is discarded on the way in:ParsingDriver::addValuereceives the field already unquoted (src/processor/operator/persistent/reader/csv/driver.cpp:22), andsetVectorNullmatches that text againstoption->nullStrings(src/function/cast_from_string_functions.cpp:861), whose default is{""}(CopyConstants::DEFAULT_CSV_NULL_STRINGS,src/include/common/constants.h). The two collapse at the reader, not in the format.NULL_STRINGSdoes not rescue it; it only flips which way they collapse. WithNULL_STRINGS=['\N']the same file reads back withIS NULLfalse for both rows, so the genuine NULL is now an empty string.Parquet keeps
IS NULLfalse for the empty string and true for the absent one.This one bit me and was caught almost by chance: I had a legitimate
path = "", and the CSV loader was storing NULL for it. It surfaced only from a graph-to-graph diff. A test asserting "empty" passes when the value reads back as NULL through most comparisons, so I was lucky to be diffing graphs.Fixes?
I could think of these:
Defect 2 is separable from all three, and looks cheaper: the bytes already carry the distinction, so it is a matter of the reader knowing whether the field it is about to null-test was quoted. That is the convention most CSV dialects use.
Even (3) alone would be worth having. Round-tripping its own export is the property a user reasonably assumes of
EXPORT DATABASE, and forLISTand for the empty string the failure is silent.Reproduction
Everything above is plain Cypher: no bindings, no extensions, no parameters. Both blocks run unmodified in the shell (
lbug :memory:), which is how the two tables were produced; the same statements through the JVM binding give identical results. Happy to turn them into atest/test_files/copy/*.testcase alongsideexport_import_db.testif that is more useful than the description.