1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
//! Row-streaming reader over a named-column table: parquet, or delimited
//! text (`.tsv`, `.csv`, `.txt`, gzip or not), with columns picked by
//! header name.
//!
//! Every cell comes back as a string, whatever the storage type: a parquet
//! `INT64` or `DOUBLE` cell is formatted, a null is `""`. Callers that want a
//! number parse it; callers that want a label get the value rather than a
//! blank. Rows stream, so a multi-gigabyte association table is never held
//! in memory.
//!
//! ```no_run
//! use legume_numeric::matrix::table::TableReader;
//! let t = TableReader::open("pairs.parquet")?;
//! let cols = t.select(&["variant_id", "gene_id", "pval_nominal"])?;
//! for row in t.rows(&cols)? {
//! let row = row?;
//! let (variant, gene, p) = (&row[0], &row[1], &row[2]);
//! # let _ = (variant, gene, p);
//! }
//! # Ok::<(), anyhow::Error>(())
//! ```
use crate::matrix::common_io::{open_buf_reader, unquote_field};
use parquet::file::reader::{FileReader, SerializedFileReader};
use parquet::record::reader::RowIter;
use parquet::record::Field;
use parquet::schema::types::Type as SchemaType;
use std::fs::File;
use std::io::BufRead;
use std::path::Path;
use std::sync::Arc;
/// How the cells of a text line are separated.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Delimiter {
/// One tab per boundary; empty cells are kept.
Tab,
/// One comma per boundary; empty cells are kept, quotes stripped.
Comma,
/// Runs of spaces or tabs; empty cells cannot occur.
Whitespace,
}
impl Delimiter {
/// The delimiter a header line uses: a tab if it has one, else a comma,
/// else whitespace.
pub fn detect(header: &str) -> Self {
if header.contains('\t') {
Delimiter::Tab
} else if header.contains(',') {
Delimiter::Comma
} else {
Delimiter::Whitespace
}
}
/// Split one line into cells, trimmed and unquoted.
pub fn split<'a>(&self, line: &'a str) -> Vec<&'a str> {
match self {
Delimiter::Tab => line.split('\t').map(|s| unquote_field(s.trim())).collect(),
Delimiter::Comma => line.split(',').map(|s| unquote_field(s.trim())).collect(),
Delimiter::Whitespace => line.split_whitespace().map(unquote_field).collect(),
}
}
}
enum Source {
Parquet,
Text { delim: Delimiter },
}
/// A table opened for streaming: its header is read up front, its rows on
/// demand through [`TableReader::rows`].
pub struct TableReader {
path: Box<str>,
header: Vec<Box<str>>,
source: Source,
}
/// `true` when the path names a parquet file (`.parquet` / `.pq`).
pub fn is_parquet_path(path: &str) -> bool {
matches!(
Path::new(path)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("parquet") | Some("pq")
)
}
impl TableReader {
/// Open `path` and read its header. Parquet is recognised by extension;
/// anything else is delimited text, gzip-decoded when it ends in `.gz`.
/// A text header is the first line that does not start with `##` (VCF-
/// style meta lines are skipped), with one leading `#` dropped
/// (`#chr start end` is a BED-style header).
pub fn open(path: &str) -> anyhow::Result<Self> {
if is_parquet_path(path) {
let file = File::open(path).map_err(|e| anyhow::anyhow!("opening {path}: {e}"))?;
let reader = SerializedFileReader::new(file)?;
let header = reader
.metadata()
.file_metadata()
.schema()
.get_fields()
.iter()
.map(|f| Box::from(f.name()))
.collect();
return Ok(Self {
path: path.into(),
header,
source: Source::Parquet,
});
}
let mut reader =
open_buf_reader(path).map_err(|e| anyhow::anyhow!("opening {path}: {e}"))?;
let mut line = String::new();
anyhow::ensure!(
read_header_line(&mut reader, &mut line)?,
"{path}: no header line"
);
let l = line.trim_end_matches(['\n', '\r']);
let l = l.strip_prefix('#').unwrap_or(l);
let delim = Delimiter::detect(l);
let header = delim.split(l).into_iter().map(Box::from).collect();
Ok(Self {
path: path.into(),
header,
source: Source::Text { delim },
})
}
pub fn path(&self) -> &str {
&self.path
}
/// Column names, in file order.
pub fn header(&self) -> &[Box<str>] {
&self.header
}
/// Index of a column by exact name.
pub fn column_index(&self, name: &str) -> Option<usize> {
self.header.iter().position(|h| h.as_ref() == name)
}
/// `true` when every name is a column.
pub fn has_columns(&self, names: &[&str]) -> bool {
names.iter().all(|n| self.column_index(n).is_some())
}
/// The first of `candidates` that is a column: for a field that
/// different releases spell differently.
pub fn find_column(&self, candidates: &[&str]) -> Option<usize> {
candidates.iter().find_map(|c| self.column_index(c))
}
/// Indices of `names`, in request order; a missing name is an error
/// that lists what the file does have.
pub fn select(&self, names: &[&str]) -> anyhow::Result<Vec<usize>> {
names
.iter()
.map(|n| {
self.column_index(n).ok_or_else(|| {
anyhow::anyhow!(
"{}: no column `{n}`; the header has {}",
self.path,
self.header.join(", ")
)
})
})
.collect()
}
/// Stream the rows, each cut down to the columns at `cols` (in that
/// order; repeats allowed). A text row too short for a requested column
/// yields `""` there; blank lines and `#` lines are skipped.
pub fn rows(&self, cols: &[usize]) -> anyhow::Result<TableRows> {
if let Some(&j) = cols.iter().find(|&&j| j >= self.header.len()) {
anyhow::bail!(
"{}: column index {j} out of range ({} columns)",
self.path,
self.header.len()
);
}
match &self.source {
Source::Parquet => self.parquet_rows(cols),
Source::Text { delim } => {
let mut reader = open_buf_reader(&self.path)?;
let mut line = String::new();
read_header_line(&mut reader, &mut line)?;
Ok(TableRows::Text {
reader,
delim: *delim,
cols: cols.to_vec(),
line: String::new(),
})
}
}
}
fn parquet_rows(&self, cols: &[usize]) -> anyhow::Result<TableRows> {
let file = File::open(&*self.path)?;
let reader = SerializedFileReader::new(file)?;
let root = reader.metadata().file_metadata().schema();
let root_name = root.name().to_string();
let fields = root.get_fields().to_vec();
// Project onto the distinct requested columns in file order, then map
// every request onto its position in the projection.
let mut distinct: Vec<usize> = cols.to_vec();
distinct.sort_unstable();
distinct.dedup();
let proj_fields: Vec<Arc<SchemaType>> =
distinct.iter().map(|&j| fields[j].clone()).collect();
let proj = SchemaType::group_type_builder(&root_name)
.with_fields(proj_fields)
.build()?;
let pos: Vec<usize> = cols
.iter()
.map(|j| distinct.binary_search(j).expect("in the projection"))
.collect();
let iter = RowIter::from_file_into(Box::new(reader)).project(Some(proj))?;
Ok(TableRows::Parquet {
iter: Box::new(iter),
pos,
})
}
}
/// Read up to and including the header line: the first non-blank line not
/// starting with `##`, left in `line`. `false` at end of file.
fn read_header_line(reader: &mut dyn BufRead, line: &mut String) -> anyhow::Result<bool> {
loop {
line.clear();
if reader.read_line(line)? == 0 {
return Ok(false);
}
let l = line.trim_end_matches(['\n', '\r']);
if !l.starts_with("##") && !l.trim().is_empty() {
return Ok(true);
}
}
}
/// The row stream of a [`TableReader`].
pub enum TableRows {
Parquet {
iter: Box<RowIter<'static>>,
pos: Vec<usize>,
},
Text {
reader: Box<dyn BufRead>,
delim: Delimiter,
cols: Vec<usize>,
line: String,
},
}
impl Iterator for TableRows {
type Item = anyhow::Result<Vec<Box<str>>>;
fn next(&mut self) -> Option<Self::Item> {
match self {
TableRows::Parquet { iter, pos } => {
let row = match iter.next()? {
Ok(r) => r,
Err(e) => return Some(Err(e.into())),
};
let cells: Vec<&Field> = row.get_column_iter().map(|(_, f)| f).collect();
Some(Ok(pos.iter().map(|&p| field_to_string(cells[p])).collect()))
}
TableRows::Text {
reader,
delim,
cols,
line,
} => loop {
line.clear();
match reader.read_line(line) {
Ok(0) => return None,
Ok(_) => {}
Err(e) => return Some(Err(e.into())),
}
let l = line.trim_end_matches(['\n', '\r']);
if l.trim().is_empty() || l.starts_with('#') {
continue;
}
let cells = delim.split(l);
return Some(Ok(cols
.iter()
.map(|&j| Box::from(cells.get(j).copied().unwrap_or("")))
.collect()));
},
}
}
}
/// A parquet cell as text: strings verbatim, numbers formatted, null `""`.
pub fn field_to_string(f: &Field) -> Box<str> {
match f {
Field::Null => "".into(),
Field::Str(s) => s.as_str().into(),
Field::Bool(b) => b.to_string().into(),
Field::Byte(x) => x.to_string().into(),
Field::Short(x) => x.to_string().into(),
Field::Int(x) => x.to_string().into(),
Field::Long(x) => x.to_string().into(),
Field::UByte(x) => x.to_string().into(),
Field::UShort(x) => x.to_string().into(),
Field::UInt(x) => x.to_string().into(),
Field::ULong(x) => x.to_string().into(),
Field::Float(x) => x.to_string().into(),
Field::Double(x) => x.to_string().into(),
Field::Float16(x) => x.to_string().into(),
Field::Bytes(b) => String::from_utf8_lossy(b.data()).into_owned().into(),
other => other.to_string().into(),
}
}
#[cfg(test)]
#[path = "table_tests.rs"]
mod tests;