zlight_csv 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +132 -0
- data/CHANGELOG.md +38 -1
- data/README.md +55 -2
- data/VERSION +1 -1
- data/ext/zlight_csv/src/convert.rs +203 -0
- data/ext/zlight_csv/src/error.rs +9 -3
- data/ext/zlight_csv/src/lib.rs +47 -45
- data/ext/zlight_csv/src/options.rs +61 -13
- data/ext/zlight_csv/src/reader.rs +26 -0
- data/ext/zlight_csv/src/row.rs +85 -0
- data/lib/zlight_csv/errors.rb +20 -0
- data/lib/zlight_csv/native.rb +20 -0
- data/lib/zlight_csv.rb +132 -254
- metadata +7 -5
- data/ext/zlight_csv/src/converter.rs +0 -133
- data/ext/zlight_csv/src/parser.rs +0 -77
- data/ext/zlight_csv/src/stream.rs +0 -410
- data/ext/zlight_csv/src/writer.rs +0 -268
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
use magnus::{
|
|
2
|
+
prelude::*,
|
|
2
3
|
scan_args::{get_kwargs, scan_args},
|
|
3
|
-
RHash, RString, Ruby, Symbol, Value,
|
|
4
|
+
RArray, RHash, RString, Ruby, Symbol, Value,
|
|
4
5
|
};
|
|
5
6
|
|
|
7
|
+
use crate::convert::{Converter, Step};
|
|
6
8
|
use crate::error::{Result, ZlightError};
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
#[derive(Debug, Clone, Copy)]
|
|
10
|
+
#[derive(Debug, Clone)]
|
|
10
11
|
pub struct ParseOptions {
|
|
11
|
-
/// Whether the CSV has a header row.
|
|
12
12
|
pub has_headers: bool,
|
|
13
|
-
///
|
|
14
|
-
pub
|
|
15
|
-
/// Field delimiter character (defaults to comma).
|
|
13
|
+
/// How each field is turned into a Ruby value.
|
|
14
|
+
pub converter: Converter,
|
|
16
15
|
pub delimiter: u8,
|
|
17
|
-
/// Quote character (defaults to double quote).
|
|
18
16
|
pub quote: u8,
|
|
19
|
-
/// Whether to allow flexible record lengths.
|
|
20
17
|
pub flexible: bool,
|
|
21
18
|
}
|
|
22
19
|
|
|
@@ -25,7 +22,7 @@ impl Default for ParseOptions {
|
|
|
25
22
|
fn default() -> Self {
|
|
26
23
|
Self {
|
|
27
24
|
has_headers: true,
|
|
28
|
-
|
|
25
|
+
converter: Converter::default(),
|
|
29
26
|
delimiter: b',',
|
|
30
27
|
quote: b'"',
|
|
31
28
|
flexible: true,
|
|
@@ -48,9 +45,8 @@ impl ParseOptions {
|
|
|
48
45
|
Ok((input, Self::from_kwargs(ruby, parsed.keywords)?))
|
|
49
46
|
}
|
|
50
47
|
|
|
51
|
-
/// Builds options from a keyword arguments hash, applying the defaults.
|
|
52
48
|
fn from_kwargs(ruby: &Ruby, kwargs: RHash) -> Result<Self> {
|
|
53
|
-
let kw = get_kwargs::<_, (), (Option<bool>, Option<
|
|
49
|
+
let kw = get_kwargs::<_, (), (Option<bool>, Option<Value>, Option<RString>, Option<RString>, Option<bool>), ()>(
|
|
54
50
|
kwargs,
|
|
55
51
|
&[],
|
|
56
52
|
&["headers", "converters", "col_sep", "quote_char", "flexible"],
|
|
@@ -65,7 +61,7 @@ impl ParseOptions {
|
|
|
65
61
|
|
|
66
62
|
Ok(ParseOptions {
|
|
67
63
|
has_headers: headers.unwrap_or(true),
|
|
68
|
-
|
|
64
|
+
converter: converter_from(ruby, converters)?,
|
|
69
65
|
delimiter: single_byte(col_sep, "col_sep")?.unwrap_or(b','),
|
|
70
66
|
quote: single_byte(quote_char, "quote_char")?.unwrap_or(b'"'),
|
|
71
67
|
flexible: flexible.unwrap_or(true),
|
|
@@ -93,3 +89,55 @@ pub fn single_byte(opt: Option<RString>, key: &'static str) -> Result<Option<u8>
|
|
|
93
89
|
}),
|
|
94
90
|
}
|
|
95
91
|
}
|
|
92
|
+
|
|
93
|
+
/// Builds a `Converter` from the `converters:` option.
|
|
94
|
+
///
|
|
95
|
+
/// Accepts a single converter or an Array of them, where each is either the
|
|
96
|
+
/// name of a built-in or any object answering `call`. Ruby's CSV reports an
|
|
97
|
+
/// unusable converter as a NoMethodError from deep inside itself; an
|
|
98
|
+
/// ArgumentError naming the problem is more use.
|
|
99
|
+
fn converter_from(ruby: &Ruby, requested: Option<Value>) -> Result<Converter> {
|
|
100
|
+
let Some(value) = requested.filter(|value| !value.is_nil()) else {
|
|
101
|
+
return Ok(Converter::default());
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
let steps = match RArray::from_value(value) {
|
|
105
|
+
Some(array) => array
|
|
106
|
+
.into_iter()
|
|
107
|
+
.map(|element| step_from(ruby, element))
|
|
108
|
+
.collect::<Result<Vec<_>>>()?,
|
|
109
|
+
None => vec![step_from(ruby, value)?],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
Ok(Converter::new(steps))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn step_from(ruby: &Ruby, value: Value) -> Result<Step> {
|
|
116
|
+
if let Some(symbol) = Symbol::from_value(value) {
|
|
117
|
+
return match symbol.name() {
|
|
118
|
+
Ok(name) if name == "numeric" => Ok(Step::Numeric),
|
|
119
|
+
_ => Err(ZlightError::InvalidOption {
|
|
120
|
+
key: "converters",
|
|
121
|
+
expected: "a known converter name (:numeric)",
|
|
122
|
+
actual: format!(":{}", symbol.name().unwrap_or("?".into())),
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if responds_to_call(value) {
|
|
128
|
+
return Ok(Step::Callable(value));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let _ = ruby;
|
|
132
|
+
Err(ZlightError::InvalidOption {
|
|
133
|
+
key: "converters",
|
|
134
|
+
expected: "a converter name, or an object responding to #call",
|
|
135
|
+
actual: format!("{}", value.class()),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fn responds_to_call(value: Value) -> bool {
|
|
140
|
+
value
|
|
141
|
+
.funcall::<_, _, bool>("respond_to?", ("call",))
|
|
142
|
+
.unwrap_or(false)
|
|
143
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//! Constructing the underlying CSV reader.
|
|
2
|
+
//!
|
|
3
|
+
//! Both read paths build their `csv::Reader` here so that a parse option can
|
|
4
|
+
//! never apply to one and not the other. `ZLight.parse` reads from a byte
|
|
5
|
+
//! slice borrowed from Ruby; `ZLight::StreamReader` reads from a file or an
|
|
6
|
+
//! owned buffer. Only the source differs, which is why this is generic.
|
|
7
|
+
|
|
8
|
+
use std::io::Read;
|
|
9
|
+
|
|
10
|
+
use csv::{Reader, ReaderBuilder};
|
|
11
|
+
|
|
12
|
+
use crate::options::ParseOptions;
|
|
13
|
+
|
|
14
|
+
/// Reading in 64KB chunks rather than the crate default, which measurably
|
|
15
|
+
/// helps on the large files this extension exists for.
|
|
16
|
+
const BUFFER_CAPACITY: usize = 64 * 1024;
|
|
17
|
+
|
|
18
|
+
pub fn build<R: Read>(source: R, options: &ParseOptions) -> Reader<R> {
|
|
19
|
+
ReaderBuilder::new()
|
|
20
|
+
.has_headers(options.has_headers)
|
|
21
|
+
.delimiter(options.delimiter)
|
|
22
|
+
.quote(options.quote)
|
|
23
|
+
.flexible(options.flexible)
|
|
24
|
+
.buffer_capacity(BUFFER_CAPACITY)
|
|
25
|
+
.from_reader(source)
|
|
26
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//! Turning a parsed record into the Ruby value a caller sees.
|
|
2
|
+
//!
|
|
3
|
+
//! # Where new output shapes go
|
|
4
|
+
//!
|
|
5
|
+
//! This is the plug point for result shape. Both read paths — the eager
|
|
6
|
+
//! `ZLight.parse` and the streaming `ZLight::StreamReader` — build every row
|
|
7
|
+
//! through `RowShape`, so they cannot drift apart. To return something new (a
|
|
8
|
+
//! `ZLight::Row` that keeps duplicate headers, a struct, a columnar batch),
|
|
9
|
+
//! add a variant here and select it in `options.rs`.
|
|
10
|
+
|
|
11
|
+
use csv::ByteRecord;
|
|
12
|
+
use magnus::{prelude::*, encoding::Index, Error as MagnusError, Ruby, StaticSymbol, Value};
|
|
13
|
+
|
|
14
|
+
use crate::convert::Converter;
|
|
15
|
+
use crate::error::ZlightError;
|
|
16
|
+
|
|
17
|
+
/// The shape each parsed row takes on the Ruby side.
|
|
18
|
+
pub enum RowShape {
|
|
19
|
+
/// A Hash keyed by the header row's symbols, resolved once up front.
|
|
20
|
+
Hashes(Vec<StaticSymbol>),
|
|
21
|
+
/// A positional Array, used when `headers: false`.
|
|
22
|
+
Arrays,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl RowShape {
|
|
26
|
+
#[inline]
|
|
27
|
+
pub fn build(
|
|
28
|
+
&self,
|
|
29
|
+
ruby: &Ruby,
|
|
30
|
+
record: &ByteRecord,
|
|
31
|
+
converter: &Converter,
|
|
32
|
+
encoding: Index,
|
|
33
|
+
) -> Result<Value, MagnusError> {
|
|
34
|
+
match self {
|
|
35
|
+
RowShape::Hashes(headers) => {
|
|
36
|
+
let hash = ruby.hash_new();
|
|
37
|
+
|
|
38
|
+
// zip stops at the shorter side, so a row with more fields
|
|
39
|
+
// than there are headers drops the extras, matching Ruby CSV.
|
|
40
|
+
for (header, field) in headers.iter().zip(record.iter()) {
|
|
41
|
+
hash.aset(*header, converter.apply(ruby, field, encoding)?)?;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Ok(hash.as_value())
|
|
45
|
+
}
|
|
46
|
+
RowShape::Arrays => {
|
|
47
|
+
let array = ruby.ary_new_capa(record.len());
|
|
48
|
+
|
|
49
|
+
for field in record.iter() {
|
|
50
|
+
array.push(converter.apply(ruby, field, encoding)?)?;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Ok(array.as_value())
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
pub fn headers(&self) -> Option<&[StaticSymbol]> {
|
|
59
|
+
match self {
|
|
60
|
+
RowShape::Hashes(headers) => Some(headers),
|
|
61
|
+
RowShape::Arrays => None,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Interns a header record as Ruby symbols.
|
|
67
|
+
///
|
|
68
|
+
/// Headers become symbols once per parse rather than per row, which is why
|
|
69
|
+
/// `RowShape::Hashes` carries them. A header that is not valid UTF-8 cannot
|
|
70
|
+
/// become a symbol, so it is reported rather than lossily replaced.
|
|
71
|
+
pub fn header_symbols(
|
|
72
|
+
ruby: &Ruby,
|
|
73
|
+
headers: &ByteRecord,
|
|
74
|
+
) -> Result<Vec<StaticSymbol>, ZlightError> {
|
|
75
|
+
headers
|
|
76
|
+
.iter()
|
|
77
|
+
.map(|header| {
|
|
78
|
+
std::str::from_utf8(header)
|
|
79
|
+
.map(|name| ruby.sym_new(name))
|
|
80
|
+
.map_err(|_| {
|
|
81
|
+
ZlightError::InvalidHeaderEncoding(String::from_utf8_lossy(header).to_string())
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
.collect()
|
|
85
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZLight
|
|
4
|
+
# Base class for everything this library raises.
|
|
5
|
+
#
|
|
6
|
+
# Rescuing ZLight::Error catches any failure originating in the gem. The
|
|
7
|
+
# subclasses are raised from the Rust extension, which looks them up by name
|
|
8
|
+
# at raise time; see ext/zlight_csv/src/error.rs.
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Raised when the CSV itself is malformed, such as a record whose field count
|
|
12
|
+
# does not match the header under `flexible: false`.
|
|
13
|
+
class ParseError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a header is not valid UTF-8 and so cannot become a Symbol.
|
|
16
|
+
class EncodingError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when a row is read from a StreamReader that has been closed.
|
|
19
|
+
class StreamClosedError < Error; end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZLight
|
|
4
|
+
# Loads the compiled extension.
|
|
5
|
+
#
|
|
6
|
+
# A precompiled gem ships one shared library per Ruby minor version, laid out
|
|
7
|
+
# as lib/zlight_csv/<major.minor>/zlight_csv.<so|bundle|dll>, because a C
|
|
8
|
+
# extension built against one Ruby's ABI cannot be loaded by another. A gem
|
|
9
|
+
# built from source has a single library at lib/zlight_csv instead, so try
|
|
10
|
+
# the versioned path first and fall back.
|
|
11
|
+
module Native
|
|
12
|
+
def self.load!
|
|
13
|
+
require_relative "#{RUBY_VERSION[/\d+\.\d+/]}/zlight_csv"
|
|
14
|
+
rescue LoadError
|
|
15
|
+
require_relative 'zlight_csv'
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
ZLight::Native.load!
|