1111import java .nio .file .Path ;
1212import java .nio .file .StandardOpenOption ;
1313import java .util .ArrayList ;
14+ import java .util .Arrays ;
1415import java .util .LinkedHashMap ;
1516import java .util .List ;
1617import java .util .Map ;
2021/// Column types are inferred in priority order: long → double → boolean → utf8.
2122/// Provide a schema via [ImportOptions#withSchema] to skip inference.
2223/// Empty cell values are treated as 0 / false / "" for typed columns.
24+ ///
25+ /// Import is streaming: rows are read and written in chunks of [ImportOptions#chunkSize]
26+ /// without loading the entire file into memory. Schema inference requires a first
27+ /// sequential pass over the file; writing is a second pass. When a schema is provided
28+ /// via [ImportOptions#withSchema] only one pass is needed.
2329public final class CsvImporter {
2430
2531 private CsvImporter () {
2632 }
2733
34+ /// Imports a CSV file to a Vortex file using default options.
35+ ///
36+ /// @param csvPath path to the source CSV file
37+ /// @param vortexPath path to write the output Vortex file
38+ /// @throws IOException if reading or writing fails
2839 public static void importCsv (Path csvPath , Path vortexPath ) throws IOException {
2940 importCsv (csvPath , vortexPath , ImportOptions .defaults ());
3041 }
3142
43+ /// Imports a CSV file to a Vortex file.
44+ ///
45+ /// Rows are streamed in chunks — the file is never fully loaded into memory.
46+ /// If no schema is provided, a first streaming pass infers column types; a
47+ /// second pass writes the data. Progress is reported via
48+ /// {@link ProgressListener#onProgress(long, long)} with `rowsTotal = -1`
49+ /// (total unknown) on each completed chunk.
50+ ///
51+ /// @param csvPath path to the source CSV file
52+ /// @param vortexPath path to write the output Vortex file
53+ /// @param options import configuration
54+ /// @throws IOException if reading or writing fails
55+ /// @throws IllegalArgumentException if the CSV file is empty
3256 public static void importCsv (Path csvPath , Path vortexPath , ImportOptions options ) throws IOException {
33- List <String []> rows = readAllRows (csvPath , options );
34- if (rows .isEmpty ()) {
35- throw new IllegalArgumentException ("CSV file is empty" );
36- }
37-
38- String [] headers ;
39- int dataStart ;
40- if (options .hasHeader ()) {
41- headers = rows .getFirst ();
42- dataStart = 1 ;
43- } else {
44- headers = generateHeaders (rows .getFirst ().length );
45- dataStart = 0 ;
46- }
47-
48- List <String []> dataRows = rows .subList (dataStart , rows .size ());
49- int colCount = headers .length ;
57+ String [] headers = readHeader (csvPath , options );
5058
51- DType .Struct schema ;
52- if (options .schema () != null ) {
53- schema = options .schema ();
54- } else {
55- schema = inferSchema (headers , dataRows , colCount );
56- }
59+ DType .Struct schema = options .schema () != null
60+ ? options .schema ()
61+ : inferSchemaStreaming (csvPath , options , headers );
5762
5863 try (FileChannel channel = FileChannel .open (
5964 vortexPath , StandardOpenOption .CREATE , StandardOpenOption .WRITE ,
6065 StandardOpenOption .TRUNCATE_EXISTING );
6166 VortexWriter writer = VortexWriter .create (channel , schema , options .writeOptions ())) {
62- int chunkSize = options .chunkSize ();
63- int total = dataRows .size ();
64- for (int start = 0 ; start < total ; start += chunkSize ) {
65- int end = Math .min (start + chunkSize , total );
66- writer .writeChunk (buildChunk (schema , dataRows .subList (start , end )));
67- if (options .progressListener () != null ) {
68- options .progressListener ().onProgress (end , total );
69- }
70- }
67+ writeChunksStreaming (csvPath , options , schema , writer );
7168 }
7269 }
7370
74- private static List <String []> readAllRows (Path path , ImportOptions options ) throws IOException {
75- List <String []> rows = new ArrayList <>();
76- try (CsvReader <CsvRecord > reader = CsvReader .builder ()
77- .fieldSeparator (options .delimiter ())
78- .ofCsvRecord (path )) {
79- for (CsvRecord record : reader ) {
80- rows .add (record .getFields ().toArray (String []::new ));
71+ private static String [] readHeader (Path path , ImportOptions options ) throws IOException {
72+ try (CsvReader <CsvRecord > reader = csvReader (path , options )) {
73+ CsvRecord first = reader .stream ().findFirst ().orElse (null );
74+ if (first == null ) {
75+ throw new IllegalArgumentException ("CSV file is empty" );
8176 }
77+ String [] fields = first .getFields ().toArray (String []::new );
78+ return options .hasHeader () ? fields : generateHeaders (fields .length );
8279 }
83- return rows ;
8480 }
8581
86- private static String [] generateHeaders (int colCount ) {
87- String [] headers = new String [colCount ];
88- for (int i = 0 ; i < colCount ; i ++) {
89- headers [i ] = "col" + i ;
82+ private static DType .Struct inferSchemaStreaming (Path path , ImportOptions options , String [] headers )
83+ throws IOException {
84+ int colCount = headers .length ;
85+ boolean [] canBeLong = new boolean [colCount ];
86+ boolean [] canBeDouble = new boolean [colCount ];
87+ boolean [] canBeBool = new boolean [colCount ];
88+ Arrays .fill (canBeLong , true );
89+ Arrays .fill (canBeDouble , true );
90+ Arrays .fill (canBeBool , true );
91+
92+ try (CsvReader <CsvRecord > reader = csvReader (path , options )) {
93+ boolean skipFirst = options .hasHeader ();
94+ for (CsvRecord record : reader ) {
95+ if (skipFirst ) {
96+ skipFirst = false ;
97+ continue ;
98+ }
99+ String [] fields = record .getFields ().toArray (String []::new );
100+ for (int c = 0 ; c < colCount ; c ++) {
101+ String val = safeGet (fields , c );
102+ if (val .isEmpty ()) {
103+ continue ;
104+ }
105+ if (canBeLong [c ]) {
106+ try {
107+ Long .parseLong (val );
108+ } catch (NumberFormatException e ) {
109+ canBeLong [c ] = false ;
110+ }
111+ }
112+ if (canBeDouble [c ]) {
113+ try {
114+ Double .parseDouble (val );
115+ } catch (NumberFormatException e ) {
116+ canBeDouble [c ] = false ;
117+ }
118+ }
119+ if (canBeBool [c ]) {
120+ if (!val .equalsIgnoreCase ("true" ) && !val .equalsIgnoreCase ("false" )) {
121+ canBeBool [c ] = false ;
122+ }
123+ }
124+ }
125+ }
90126 }
91- return headers ;
92- }
93127
94- private static DType .Struct inferSchema (String [] headers , List <String []> rows , int colCount ) {
95128 List <String > names = List .of (headers );
96129 List <DType > types = new ArrayList <>(colCount );
97130 for (int c = 0 ; c < colCount ; c ++) {
98- types .add (inferColumnType ( rows , c ));
131+ types .add (resolveType ( canBeLong [ c ], canBeDouble [ c ], canBeBool [ c ] ));
99132 }
100133 return new DType .Struct (names , types , false );
101134 }
102135
103- /// Infers the narrowest type for a column using a single pass.
104- ///
105- /// Priority: long → double → bool → utf8. Each flag starts true and can only
106- /// transition to false. Empty cells are skipped (compatible with any type).
107- /// An all-empty column is inferred as long (all flags remain true).
108- ///
109- /// Integer values always infer as i64 (not i32/i16): CSV has no type annotations,
110- /// so the widest safe integer is chosen. Use [ImportOptions#withSchema] to force i32/i16.
111- /// Floating-point values always infer as f64 (not f32) for the same reason.
112- private static DType inferColumnType (List <String []> rows , int colIdx ) {
113- boolean canBeLong = true ;
114- boolean canBeDouble = true ;
115- boolean canBeBool = true ;
116-
117- for (String [] row : rows ) {
118- String val = safeGet (row , colIdx );
119- if (val .isEmpty ()) {
120- continue ;
121- }
122- if (canBeLong ) {
123- try {
124- Long .parseLong (val );
125- } catch (NumberFormatException e ) {
126- canBeLong = false ;
127- }
128- }
129- if (canBeDouble ) {
130- try {
131- Double .parseDouble (val );
132- } catch (NumberFormatException e ) {
133- canBeDouble = false ;
136+ private static void writeChunksStreaming (Path path , ImportOptions options , DType .Struct schema ,
137+ VortexWriter writer ) throws IOException {
138+ int chunkSize = options .chunkSize ();
139+ List <String []> chunk = new ArrayList <>(chunkSize );
140+ long totalRows = 0 ;
141+
142+ try (CsvReader <CsvRecord > reader = csvReader (path , options )) {
143+ boolean skipFirst = options .hasHeader ();
144+ for (CsvRecord record : reader ) {
145+ if (skipFirst ) {
146+ skipFirst = false ;
147+ continue ;
134148 }
135- }
136- if (canBeBool ) {
137- if (!val .equalsIgnoreCase ("true" ) && !val .equalsIgnoreCase ("false" )) {
138- canBeBool = false ;
149+ chunk .add (record .getFields ().toArray (String []::new ));
150+ if (chunk .size () == chunkSize ) {
151+ writer .writeChunk (buildChunk (schema , chunk ));
152+ totalRows += chunk .size ();
153+ reportProgress (options , totalRows );
154+ chunk .clear ();
139155 }
140156 }
141157 }
142158
159+ if (!chunk .isEmpty ()) {
160+ writer .writeChunk (buildChunk (schema , chunk ));
161+ totalRows += chunk .size ();
162+ reportProgress (options , totalRows );
163+ }
164+ }
165+
166+ private static void reportProgress (ImportOptions options , long totalRows ) {
167+ if (options .progressListener () != null ) {
168+ options .progressListener ().onProgress (totalRows , -1 );
169+ }
170+ }
171+
172+ private static CsvReader <CsvRecord > csvReader (Path path , ImportOptions options ) throws IOException {
173+ return CsvReader .builder ()
174+ .fieldSeparator (options .delimiter ())
175+ .ofCsvRecord (path );
176+ }
177+
178+ private static String [] generateHeaders (int colCount ) {
179+ String [] headers = new String [colCount ];
180+ for (int i = 0 ; i < colCount ; i ++) {
181+ headers [i ] = "col" + i ;
182+ }
183+ return headers ;
184+ }
185+
186+ private static DType resolveType (boolean canBeLong , boolean canBeDouble , boolean canBeBool ) {
143187 if (canBeLong ) {
144188 return new DType .Primitive (PType .I64 , false );
145189 }
@@ -152,7 +196,7 @@ private static DType inferColumnType(List<String[]> rows, int colIdx) {
152196 return new DType .Utf8 (false );
153197 }
154198
155- private static Map <String , Object > buildChunk (DType .Struct schema , List <String []> rows ) {
199+ static Map <String , Object > buildChunk (DType .Struct schema , List <String []> rows ) {
156200 int n = rows .size ();
157201 Map <String , Object > chunk = new LinkedHashMap <>();
158202 for (int c = 0 ; c < schema .fieldNames ().size (); c ++) {
0 commit comments