#![allow(clippy::cast_precision_loss)] static USAGE: &str = r#"
Returns a count of the number of records in the CSV data.
It has three modes of operation:
1. If a valid index is present, it will use it to lookup the count and
return instantaneously. (fastest)
If no index is present, it will read the CSV and count the number
of records by scanning the file.
2. If the polars feature is enabled, it will use the multithreaded,
mem-mapped Polars CSV reader. (faster - not available on qsvlite)
3. If the polars feature is not enabled, it will use the "regular",
single-threaded CSV reader.
Note that the count will not include the header row (unless --no-headers is
given).
Examples:
# Basic count of records in data.csv:
qsv count data.csv
# Count records in data.csv without headers:
qsv count --no-headers data.csv
# Count records in data.csv with human-readable output:
qsv count --human-readable data.csv
# Count records in data.csv with width statistics:
qsv count --width data.csv
# Count records in data.csv with width statistics (excluding delimiters):
qsv count --width-no-delims data.csv
# Count records in data.csv with width statistics in JSON format:
qsv count --width --json data.csv
For examples, see https://github.com/dathere/qsv/blob/master/tests/test_count.rs.
Usage:
qsv count [options] [<input>]
qsv count --help
count options:
-H, --human-readable Comma separate counts.
WIDTH OPTIONS:
--width Also return the estimated widths of each record.
Its an estimate as it doesn't count quotes, and will be an
undercount if the record has quoted fields.
The count and width are separated by a semicolon. It will
return the max, avg, median, min, variance, stddev & MAD widths,
separated by hyphens. If --human-readable is set, the widths will
be labeled as "max", "avg", "median", "min", "stddev" & "mad"
respectively, separated by spaces.
Note that this option will require scanning the entire file
using the "regular", single-threaded, streaming CSV reader,
using the index if available for the count.
If the file is very large, it may not be able to compile some
stats - particularly avg, variance, stddev & MAD. In this case,
it will return 0.0 for those stats.
--width-no-delims Same as --width but does not count the delimiters in the width.
--json Output the width stats in JSON format.
WHEN THE POLARS FEATURE IS ENABLED:
--no-polars Use the "regular", single-threaded, streaming CSV reader instead
of the much faster multithreaded, mem-mapped Polars CSV reader.
Use this when you encounter memory issues when counting with the
Polars CSV reader. The streaming reader is slower but can read
any valid CSV file of any size.
--low-memory Use the Polars CSV Reader's low-memory mode. This mode
is slower but uses less memory. If counting still fails,
use --no-polars instead to use the streaming CSV reader.
Common options:
-h, --help Display this message
-f, --flexible Do not validate if the CSV has different number of
fields per record, increasing performance when counting
without an index.
-n, --no-headers When set, the first row will be included in
the count.
-d, --delimiter <arg> The delimiter to use when reading CSV data.
Must be a single character. [default: ,]
"#;
use log::info;
use serde::Deserialize;
use crate::{
CliError, CliResult,
config::{Config, Delimiter},
util,
};
#[allow(dead_code)]
#[derive(Deserialize)]
struct Args {
arg_input: Option<String>,
flag_human_readable: bool,
flag_width: bool,
flag_width_no_delims: bool,
flag_json: bool,
flag_no_polars: bool,
flag_low_memory: bool,
flag_flexible: bool,
flag_no_headers: bool,
flag_delimiter: Option<Delimiter>,
}
#[derive(Copy, Clone, PartialEq)]
enum CountDelimsMode {
IncludeDelims,
ExcludeDelims,
NotRequired,
}
#[derive(Default)]
struct WidthStats {
max: usize,
avg: f64,
median: usize,
min: usize,
variance: f64,
stddev: f64,
mad: f64,
}
pub fn run(argv: &[&str]) -> CliResult<()> {
let args: Args = util::get_args(USAGE, argv)?;
let conf = Config::new(args.arg_input.as_ref())
.no_headers_flag(args.flag_no_headers)
.quoting(!args.flag_width || !args.flag_width_no_delims)
.flexible(args.flag_flexible)
.delimiter(args.flag_delimiter);
let count_delims_mode = if args.flag_width_no_delims {
CountDelimsMode::ExcludeDelims
} else if args.flag_width {
CountDelimsMode::IncludeDelims
} else {
CountDelimsMode::NotRequired
};
let empty_record_stats = WidthStats::default();
let (count, record_stats) =
if count_delims_mode != CountDelimsMode::NotRequired || args.flag_flexible {
count_input(&conf, count_delims_mode)?
} else {
let index_status = conf.indexed().unwrap_or_else(|_| {
info!("index is stale");
None
});
match index_status {
Some(idx) => {
info!("index used");
(idx.count(), empty_record_stats)
},
None => {
#[cfg(feature = "polars")]
if args.flag_no_polars || conf.is_snappy() {
count_input(&conf, count_delims_mode)?
} else {
let count = polars_count_input(&conf, args.flag_low_memory)?;
if count == 0 {
count_input(&conf, count_delims_mode)?
} else {
(count, empty_record_stats)
}
}
#[cfg(not(feature = "polars"))]
count_input(&conf, count_delims_mode)?
},
}
};
if args.flag_json {
woutinfo!(
r#"{{"count":{},"max":{},"avg":{},"median":{},"min":{},"variance":{},"stddev":{},"mad":{}}}"#,
count,
record_stats.max,
util::round_num(record_stats.avg, 4),
record_stats.median,
record_stats.min,
util::round_num(record_stats.variance, 4),
util::round_num(record_stats.stddev, 4),
util::round_num(record_stats.mad, 4),
);
} else if args.flag_human_readable {
use indicatif::{HumanCount, HumanFloatCount};
if count_delims_mode == CountDelimsMode::NotRequired {
woutinfo!("{}", HumanCount(count));
} else {
woutinfo!(
"{};max:{} avg:{} median:{} min:{} variance:{} stddev:{} mad:{}",
HumanCount(count),
HumanCount(record_stats.max as u64),
HumanFloatCount(record_stats.avg),
HumanCount(record_stats.median as u64),
HumanCount(record_stats.min as u64),
HumanFloatCount(record_stats.variance),
HumanFloatCount(record_stats.stddev),
HumanFloatCount(record_stats.mad),
);
}
} else if count_delims_mode == CountDelimsMode::NotRequired {
woutinfo!("{count}");
} else {
woutinfo!(
"{count};{max}-{avg}-{median}-{min}-{variance}-{stddev}-{mad}",
max = record_stats.max,
avg = util::round_num(record_stats.avg, 4),
median = record_stats.median,
min = record_stats.min,
variance = util::round_num(record_stats.variance, 4),
stddev = util::round_num(record_stats.stddev, 4),
mad = util::round_num(record_stats.mad, 4),
);
}
Ok(())
}
fn count_input(conf: &Config, count_delims_mode: CountDelimsMode) -> CliResult<(u64, WidthStats)> {
use rayon::{
iter::{IntoParallelRefIterator, ParallelIterator},
prelude::ParallelSliceMut,
};
let mut use_index_count = false;
let mut count = match conf.indexed()? {
Some(idx) => {
use_index_count = true;
info!("index used");
idx.count()
},
_ => 0_u64,
};
let mut rdr = conf.reader()?;
let mut record = csv::ByteRecord::new();
let empty_record_stats = WidthStats::default();
if count_delims_mode == CountDelimsMode::NotRequired {
if !use_index_count {
while rdr.read_byte_record(&mut record)? {
count += 1;
}
}
Ok((count, empty_record_stats))
} else {
if !rdr.read_byte_record(&mut record)? {
return Ok((0, empty_record_stats));
}
let mut curr_width = record.as_slice().len();
let mut max = curr_width;
let mut min = curr_width;
let mut total_width = curr_width;
let mut widths = Vec::new();
widths
.try_reserve(if use_index_count {
count as usize
} else {
1_000 })
.map_err(|e| CliError::OutOfMemory(e.to_string()))?;
widths.push(curr_width);
let mut manual_count = 1_u64;
let record_numdelims = if count_delims_mode == CountDelimsMode::IncludeDelims {
record.len().saturating_sub(1)
} else {
0
};
while rdr.read_byte_record(&mut record)? {
manual_count += 1;
curr_width = record.as_slice().len() + record_numdelims;
total_width = total_width.saturating_add(curr_width);
widths.push(curr_width);
if curr_width > max {
max = curr_width;
} else if curr_width < min {
min = curr_width;
}
}
if !use_index_count {
count = manual_count;
}
let avg = if total_width == usize::MAX {
0.0_f64
} else {
total_width as f64 / count as f64
};
widths.par_sort_unstable();
let median = if count.is_multiple_of(2) {
usize::midpoint(
widths[(count / 2) as usize - 1],
widths[(count / 2) as usize],
)
} else {
widths[(count / 2) as usize]
};
let (variance, stddev) = if avg > 0.0 {
let variance = widths
.par_iter()
.map(|&width| {
let diff = width as f64 - avg;
diff * diff
})
.sum::<f64>()
/ count as f64;
(variance, variance.sqrt())
} else {
(0.0_f64, 0.0_f64)
};
let mad = {
let mut abs_devs: Vec<f64> = widths
.iter()
.map(|&width| (width as f64 - median as f64).abs())
.collect();
abs_devs.par_sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
if count.is_multiple_of(2) {
f64::midpoint(
abs_devs[(count / 2) as usize - 1],
abs_devs[(count / 2) as usize],
)
} else {
abs_devs[(count / 2) as usize]
}
};
Ok((
count,
WidthStats {
max,
avg,
median,
min,
variance,
stddev,
mad,
},
))
}
}
#[cfg(feature = "polars")]
pub fn polars_count_input(conf: &Config, low_memory: bool) -> CliResult<u64> {
use polars::{
lazy::frame::{LazyFrame, OptFlags},
prelude::*,
sql::SQLContext,
};
let is_stdin = conf.is_stdin();
let filepath = if is_stdin {
let mut temp_file = tempfile::Builder::new().suffix(".csv").tempfile()?;
let stdin = std::io::stdin();
let mut stdin_handle = stdin.lock();
std::io::copy(&mut stdin_handle, &mut temp_file)?;
drop(stdin_handle);
let (_, tempfile_pb) =
temp_file.keep().or(Err(
"Cannot keep temporary file created for stdin".to_string()
))?;
tempfile_pb
} else {
conf.path.as_ref().unwrap().clone()
};
let mut comment_char = String::new();
let comment_prefix = if let Some(c) = conf.comment {
comment_char.push(c as char);
Some(PlSmallStr::from_str(comment_char.as_str()))
} else {
None
};
let mut ctx = SQLContext::new();
let lazy_df: LazyFrame;
let delimiter = conf.get_delimiter();
{
let schema_df = match LazyCsvReader::new(PlRefPath::new(&*filepath.to_string_lossy()))
.with_separator(delimiter)
.with_comment_prefix(comment_prefix.clone())
.with_n_rows(Some(1))
.with_ignore_errors(true)
.finish()
{
Ok(df) => df.collect(),
Err(e) => {
log::warn!("polars error loading CSV: {e}");
let (count_regular, _) = count_input(conf, CountDelimsMode::NotRequired)?;
return Ok(count_regular);
},
};
if schema_df.is_err() || schema_df.unwrap().height() == 0 {
return Ok(0);
}
}
let count_query = if comment_prefix.is_none() && delimiter == b',' && !low_memory {
format!(
"SELECT COUNT(*) FROM read_csv('{}')",
filepath.to_string_lossy(),
)
} else {
lazy_df = match LazyCsvReader::new(PlRefPath::new(&*filepath.to_string_lossy()))
.with_separator(delimiter)
.with_comment_prefix(comment_prefix)
.with_low_memory(low_memory)
.with_ignore_errors(true)
.finish()
{
Ok(lazy_df) => lazy_df,
Err(e) => {
log::warn!("polars error loading CSV: {e}");
let (count_regular, _) = count_input(conf, CountDelimsMode::NotRequired)?;
return Ok(count_regular);
},
};
let optflags = OptFlags::from_bits_truncate(0)
| OptFlags::PROJECTION_PUSHDOWN
| OptFlags::PREDICATE_PUSHDOWN
| OptFlags::CLUSTER_WITH_COLUMNS
| OptFlags::TYPE_COERCION
| OptFlags::SIMPLIFY_EXPR
| OptFlags::SLICE_PUSHDOWN
| OptFlags::COMM_SUBPLAN_ELIM
| OptFlags::COMM_SUBEXPR_ELIM
| OptFlags::FAST_PROJECTION
| OptFlags::NEW_STREAMING;
ctx.register("sql_lf", lazy_df.with_optimizations(optflags));
"SELECT COUNT(*) FROM sql_lf".to_string()
};
let sqlresult_lf = match ctx.execute(&count_query) {
Ok(sqlresult_lf) => sqlresult_lf,
Err(e) => {
log::warn!("polars error executing count query: {e}");
let (count_regular, _) = count_input(conf, CountDelimsMode::NotRequired)?;
return Ok(count_regular);
},
};
let mut count = match sqlresult_lf.collect()?["len"].u32() {
Ok(cnt) => {
if let Some(count) = cnt.get(0) {
count as u64
} else {
log::warn!("empty polars result, falling back to regular reader");
let (count_regular, _) = count_input(conf, CountDelimsMode::NotRequired)?;
count_regular
}
},
Err(e) => {
log::warn!("polars error, falling back to regular reader: {e}");
let (count_regular, _) = count_input(conf, CountDelimsMode::NotRequired)?;
count_regular
},
};
if is_stdin {
std::fs::remove_file(filepath)?;
}
if conf.no_headers {
count += 1;
}
Ok(count)
}