Skip to main content

hawdb_executor/load_csv/
reader.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A streaming RFC 4180 record reader with a fixed-size input buffer.
16
17use super::CsvImportLimits;
18use hawdb_core::{HawDBError, Result};
19use std::io::Read;
20
21/// Bytes read from the source per refill; the only buffered file contents.
22pub const CSV_READ_BUFFER_BYTES: usize = 64 * 1024;
23
24const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];
25
26/// One field of a record. An empty unquoted field is `None`, so callers can
27/// distinguish it from the quoted empty string `""`.
28pub type CsvField = Option<String>;
29
30/// Reads records one at a time. Each record is bounded by
31/// [`CsvImportLimits::max_record_bytes`] and
32/// [`CsvImportLimits::max_fields_per_record`], and the whole input by
33/// [`CsvImportLimits::max_file_bytes`].
34pub struct CsvRecordReader<R> {
35    input: R,
36    buffer: Box<[u8]>,
37    position: usize,
38    filled: usize,
39    eof: bool,
40    started: bool,
41    terminator: u8,
42    limits: CsvImportLimits,
43    bytes_read: u64,
44    /// One-based physical line of the next unread byte.
45    line: u64,
46    records: u64,
47}
48
49impl<R: Read> CsvRecordReader<R> {
50    pub fn new(input: R, terminator: u8, limits: CsvImportLimits) -> Result<Self> {
51        if !terminator.is_ascii() || matches!(terminator, b'"' | b'\r' | b'\n') {
52            return Err(HawDBError::Semantic(
53                "FIELDTERMINATOR must be one ASCII character other than a double quote or line break"
54                    .to_string(),
55            ));
56        }
57        Ok(Self {
58            input,
59            buffer: vec![0; CSV_READ_BUFFER_BYTES].into_boxed_slice(),
60            position: 0,
61            filled: 0,
62            eof: false,
63            started: false,
64            terminator,
65            limits,
66            bytes_read: 0,
67            line: 1,
68            records: 0,
69        })
70    }
71
72    /// Records returned so far, including a header record.
73    pub fn records(&self) -> u64 {
74        self.records
75    }
76
77    /// Returns the next record, skipping lines that contain no bytes at all.
78    pub fn next_record(&mut self) -> Result<Option<Vec<CsvField>>> {
79        if !self.started {
80            self.started = true;
81            self.skip_bom()?;
82        }
83        loop {
84            let Some(first) = self.peek()? else {
85                return Ok(None);
86            };
87            if matches!(first, b'\n' | b'\r') {
88                self.consume_line_break(first)?;
89                continue;
90            }
91            let start_line = self.line;
92            let record = self.read_record(start_line)?;
93            self.records += 1;
94            return Ok(Some(record));
95        }
96    }
97
98    fn read_record(&mut self, start_line: u64) -> Result<Vec<CsvField>> {
99        let mut fields = Vec::new();
100        let mut record_bytes = 0usize;
101        loop {
102            if fields.len() == self.limits.max_fields_per_record {
103                return Err(self.error(
104                    start_line,
105                    format!(
106                        "has more than max_fields_per_record {} fields",
107                        self.limits.max_fields_per_record
108                    ),
109                ));
110            }
111            let mut field = Vec::new();
112            let quoted = self.peek()? == Some(b'"');
113            let end = if quoted {
114                self.bump();
115                self.read_quoted(&mut field, &mut record_bytes, start_line)?
116            } else {
117                self.read_unquoted(&mut field, &mut record_bytes, start_line)?
118            };
119            let text = String::from_utf8(field).map_err(|_| {
120                self.error(
121                    start_line,
122                    format!("field {} is not valid UTF-8", fields.len() + 1),
123                )
124            })?;
125            fields.push((quoted || !text.is_empty()).then_some(text));
126            if end == FieldEnd::Record {
127                return Ok(fields);
128            }
129        }
130    }
131
132    fn read_unquoted(
133        &mut self,
134        field: &mut Vec<u8>,
135        record_bytes: &mut usize,
136        start_line: u64,
137    ) -> Result<FieldEnd> {
138        loop {
139            let Some(byte) = self.peek()? else {
140                return Ok(FieldEnd::Record);
141            };
142            self.bump();
143            match byte {
144                byte if byte == self.terminator => return Ok(FieldEnd::Field),
145                b'\n' | b'\r' => {
146                    self.finish_line_break(byte)?;
147                    return Ok(FieldEnd::Record);
148                }
149                // A quote inside an unquoted field is kept literally.
150                byte => self.push(field, byte, record_bytes, start_line)?,
151            }
152        }
153    }
154
155    fn read_quoted(
156        &mut self,
157        field: &mut Vec<u8>,
158        record_bytes: &mut usize,
159        start_line: u64,
160    ) -> Result<FieldEnd> {
161        loop {
162            let Some(byte) = self.peek()? else {
163                return Err(self.error(start_line, "has an unterminated quoted field".to_string()));
164            };
165            self.bump();
166            match byte {
167                b'"' if self.peek()? == Some(b'"') => {
168                    self.bump();
169                    self.push(field, b'"', record_bytes, start_line)?;
170                }
171                b'"' => break,
172                b'\n' => {
173                    self.line += 1;
174                    self.push(field, byte, record_bytes, start_line)?;
175                }
176                b'\r' => {
177                    if self.peek()? != Some(b'\n') {
178                        self.line += 1;
179                    }
180                    self.push(field, byte, record_bytes, start_line)?;
181                }
182                byte => self.push(field, byte, record_bytes, start_line)?,
183            }
184        }
185        match self.peek()? {
186            None => Ok(FieldEnd::Record),
187            Some(byte) if byte == self.terminator => {
188                self.bump();
189                Ok(FieldEnd::Field)
190            }
191            Some(byte @ (b'\n' | b'\r')) => {
192                self.consume_line_break(byte)?;
193                Ok(FieldEnd::Record)
194            }
195            Some(_) => Err(self.error(
196                start_line,
197                "has a character after a closing quote; quote the whole field and double embedded quotes"
198                    .to_string(),
199            )),
200        }
201    }
202
203    fn push(
204        &self,
205        field: &mut Vec<u8>,
206        byte: u8,
207        record_bytes: &mut usize,
208        start_line: u64,
209    ) -> Result<()> {
210        *record_bytes += 1;
211        if *record_bytes > self.limits.max_record_bytes {
212            return Err(self.error(
213                start_line,
214                format!("exceeds max_record_bytes {}", self.limits.max_record_bytes),
215            ));
216        }
217        field.push(byte);
218        Ok(())
219    }
220
221    fn consume_line_break(&mut self, byte: u8) -> Result<()> {
222        self.bump();
223        self.finish_line_break(byte)
224    }
225
226    /// Completes a line break whose first byte was consumed: CRLF, LF or CR.
227    fn finish_line_break(&mut self, byte: u8) -> Result<()> {
228        if byte == b'\r' && self.peek()? == Some(b'\n') {
229            self.bump();
230        }
231        self.line += 1;
232        Ok(())
233    }
234
235    fn skip_bom(&mut self) -> Result<()> {
236        while self.filled - self.position < UTF8_BOM.len() && !self.eof {
237            self.fill()?;
238        }
239        if self.buffer[self.position..self.filled].starts_with(&UTF8_BOM) {
240            self.position += UTF8_BOM.len();
241        }
242        Ok(())
243    }
244
245    fn peek(&mut self) -> Result<Option<u8>> {
246        if self.position == self.filled {
247            if self.eof {
248                return Ok(None);
249            }
250            self.position = 0;
251            self.filled = 0;
252            self.fill()?;
253            if self.filled == 0 {
254                return Ok(None);
255            }
256        }
257        Ok(Some(self.buffer[self.position]))
258    }
259
260    fn bump(&mut self) {
261        self.position += 1;
262    }
263
264    /// Appends input after any unread bytes; sets `eof` on a zero-byte read.
265    fn fill(&mut self) -> Result<()> {
266        if self.position > 0 {
267            self.buffer.copy_within(self.position..self.filled, 0);
268            self.filled -= self.position;
269            self.position = 0;
270        }
271        let read = loop {
272            match self.input.read(&mut self.buffer[self.filled..]) {
273                Ok(read) => break read,
274                Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
275                Err(error) => {
276                    return Err(HawDBError::Execution(format!(
277                        "LOAD CSV failed to read the source: {error}"
278                    )))
279                }
280            }
281        };
282        if read == 0 {
283            self.eof = true;
284            return Ok(());
285        }
286        self.bytes_read = self.bytes_read.saturating_add(read as u64);
287        if self.bytes_read > self.limits.max_file_bytes {
288            return Err(HawDBError::Execution(format!(
289                "LOAD CSV source exceeds max_file_bytes {}",
290                self.limits.max_file_bytes
291            )));
292        }
293        self.filled += read;
294        Ok(())
295    }
296
297    fn error(&self, start_line: u64, detail: String) -> HawDBError {
298        HawDBError::Execution(format!(
299            "LOAD CSV record {} starting at line {start_line} {detail}",
300            self.records + 1
301        ))
302    }
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306enum FieldEnd {
307    Field,
308    Record,
309}