Skip to main content

tpchgen_cli/
tpch_cli.rs

1//! TPCH data generation CLI with a dbgen compatible API.
2//!
3//! This crate provides a CLI for generating TPCH data and tries to remain close
4//! API wise to the original dbgen tool, as in we use the same command line flags
5//! and arguments.
6//!
7//! See the documentation on [`Cli`] for more information on the command line
8
9// Use the library public API
10use crate::progress::IndicatifProgress;
11use crate::{
12    Compression, OutputFormat, Table, TpchGenerator, TpchGeneratorBuilder,
13    DEFAULT_PARQUET_ROW_GROUP_BYTES,
14};
15use clap::builder::TypedValueParser;
16use clap::{ArgAction, Parser};
17use log::{info, LevelFilter};
18use std::io::{self, IsTerminal};
19use std::path::PathBuf;
20use std::str::FromStr;
21use std::sync::Arc;
22
23#[derive(Parser)]
24#[command(name = "tpchgen")]
25#[command(version)]
26#[command(
27    // -h output
28    about = "TPC-H Data Generator",
29    // --help output
30    long_about = r#"
31TPCH Data Generator (https://github.com/clflushopt/tpchgen-rs)
32
33By default each table is written to a single file named <output_dir>/<table>.<format>
34
35If `--part` option is specified, each table is written to a subdirectory in
36multiple files named <output_dir>/<table>/<table>.<part>.<format>
37
38Examples
39
40# Generate all tables at scale factor 1 (1GB) in TBL format (default) to /tmp/tpch directory:
41
42tpchgen-cli -s 1 --output-dir=/tmp/tpch
43
44# Generate all tables in CSV format:
45
46tpchgen-cli csv -s 1 --output-dir=/tmp/tpch
47
48# Generate scale factor one in CSV format with tab delimiter:
49
50tpchgen-cli csv -s 1 --delimiter='\t' --output-dir=/tmp/tpch
51
52# Generate the lineitem table at scale factor 100 in 10 Apache Parquet files to
53# /tmp/tpch/lineitem:
54
55tpchgen-cli parquet -s 100 --tables=lineitem --parts=10 --output-dir=/tmp/tpch
56
57# Generate scale factor one in current directory, seeing debug output
58
59RUST_LOG=debug tpchgen-cli -s 1 --output-dir=/tmp/tpch
60"#,
61    args_conflicts_with_subcommands = true
62)]
63pub struct Cli {
64    #[command(subcommand)]
65    command: Option<Commands>,
66
67    // Top-level args are only used when no subcommand is given (legacy path).
68    // args_conflicts_with_subcommands prevents these from being silently ignored
69    // when a subcommand is present (e.g. `tpchgen-cli -s 10 parquet` is an error).
70    #[command(flatten)]
71    args: TopLevelArgs,
72}
73
74#[derive(clap::Subcommand)]
75enum Commands {
76    /// Generate TBL (pipe-delimited) output
77    Tbl(TblArgs),
78    /// Generate CSV output with CSV-specific options
79    Csv(CsvArgs),
80    /// Generate Apache Parquet output with Parquet-specific options
81    Parquet(ParquetArgs),
82}
83
84#[derive(clap::Args)]
85struct CommonArgs {
86    /// Scale factor to create
87    #[arg(short, long, default_value_t = 1.)]
88    scale_factor: f64,
89
90    /// Output directory for generated files (default: current directory)
91    #[arg(short, long, default_value = ".")]
92    output_dir: PathBuf,
93
94    /// Which tables to generate (default: all)
95    #[arg(short = 'T', long = "tables", value_delimiter = ',', value_parser = TableValueParser)]
96    tables: Option<Vec<Table>>,
97
98    /// Number of part(itions) to generate. If not specified creates a single file per table
99    #[arg(short, long)]
100    parts: Option<i32>,
101
102    /// Which part(ition) to generate (1-based). If not specified, generates all parts
103    #[arg(long)]
104    part: Option<i32>,
105
106    /// The number of threads for parallel generation, defaults to the number of CPUs
107    #[arg(short, long, default_value_t = num_cpus::get())]
108    num_threads: usize,
109
110    /// Verbose output
111    ///
112    /// When specified, sets the log level to `info` and ignores the `RUST_LOG`
113    /// environment variable. When not specified, uses `RUST_LOG`
114    #[arg(short, long, default_value_t = false, conflicts_with = "quiet")]
115    verbose: bool,
116
117    /// Quiet mode - only show error-level logs
118    #[arg(short, long, default_value_t = false, conflicts_with = "verbose")]
119    quiet: bool,
120
121    /// Write the output to stdout instead of a file.
122    #[arg(long, default_value_t = false)]
123    stdout: bool,
124
125    /// Disable progress bars during data generation.
126    ///
127    /// Bars are also auto-suppressed by `--quiet`, `--stdout`, or when
128    /// stderr is not a terminal.
129    #[arg(long = "no-progress", action = ArgAction::SetFalse, default_value_t = true)]
130    progress_bars_enabled: bool,
131}
132
133impl CommonArgs {
134    /// Initialize CLI logging/progress output and create a
135    /// [`TpchGeneratorBuilder`] pre-configured with the common options.
136    fn builder(self, format: OutputFormat) -> TpchGeneratorBuilder {
137        let progress = self
138            .should_show_progress_bars()
139            .then(|| Arc::new(IndicatifProgress::new()));
140
141        let mut builder = TpchGenerator::builder()
142            .with_scale_factor(self.scale_factor)
143            .with_output_dir(self.output_dir)
144            .with_format(format)
145            .with_num_threads(self.num_threads)
146            .with_stdout(self.stdout);
147
148        if let Some(tables) = self.tables {
149            builder = builder.with_tables(tables);
150        }
151        if let Some(parts) = self.parts {
152            builder = builder.with_parts(parts);
153        }
154        if let Some(part) = self.part {
155            builder = builder.with_part(part);
156        }
157
158        configure_logging(
159            self.verbose,
160            self.quiet,
161            progress.as_ref().map(|progress| progress.log_writer()),
162        );
163        if let Some(progress) = progress {
164            builder = builder.with_progress_tracker(progress);
165        }
166
167        builder
168    }
169
170    fn should_show_progress_bars(&self) -> bool {
171        // Show progress only on an interactive terminal and when no flag
172        // suppresses it. `--stdout` is included so piped data isn't
173        // interleaved with bar redraws on shared shells.
174        self.progress_bars_enabled && !self.quiet && !self.stdout && io::stderr().is_terminal()
175    }
176}
177
178#[derive(clap::Args)]
179struct TopLevelArgs {
180    #[command(flatten)]
181    common: CommonArgs,
182
183    /// Output format (deprecated: use subcommands `tbl`, `csv`, or `parquet` instead)
184    ///
185    /// The --format flag will be removed in v4.0.0.
186    #[arg(short, long, hide = true)]
187    format: Option<OutputFormat>,
188
189    /// Parquet block compression format (deprecated: use 'parquet' subcommand instead)
190    #[arg(short = 'c', long, hide = true)]
191    parquet_compression: Option<Compression>,
192
193    /// Target row group size in bytes (deprecated: use 'parquet' subcommand instead)
194    #[arg(long, hide = true)]
195    parquet_row_group_bytes: Option<i64>,
196}
197
198#[derive(clap::Args)]
199struct TblArgs {
200    #[command(flatten)]
201    common: CommonArgs,
202}
203
204#[derive(clap::Args)]
205struct CsvArgs {
206    #[command(flatten)]
207    common: CommonArgs,
208
209    /// CSV delimiter character (default: ',')
210    ///
211    /// Specifies the delimiter character to use when generating CSV files.
212    ///
213    /// Supports escape sequences: \t (tab), \n (newline), \r (carriage return), \\ (backslash)
214    /// Common delimiters: ',' (comma), '|' (pipe), '\t' (tab), ';' (semicolon)
215    #[arg(long, default_value = ",", value_parser = parse_delimiter)]
216    delimiter: char,
217}
218
219#[derive(clap::Args)]
220struct ParquetArgs {
221    #[command(flatten)]
222    common: CommonArgs,
223
224    /// Parquet block compression format.
225    ///
226    /// Supported values: UNCOMPRESSED, ZSTD(N), SNAPPY, GZIP, LZO, BROTLI, LZ4
227    ///
228    /// Note to use zstd you must supply the "compression" level (1-22)
229    /// as a number in parentheses, e.g. `ZSTD(1)` for level 1 compression.
230    ///
231    /// Using `ZSTD` results in the best compression, but is about 2x slower than
232    /// UNCOMPRESSED. For example, for the lineitem table at SF=10
233    ///
234    ///   ZSTD(1):      1.9G  (0.52 GB/sec)
235    ///   SNAPPY:       2.4G  (0.75 GB/sec)
236    ///   UNCOMPRESSED: 3.8G  (1.41 GB/sec)
237    #[arg(short = 'c', long, default_value = "SNAPPY")]
238    compression: Compression,
239
240    /// Target size in row group bytes in Parquet files
241    ///
242    /// Row groups are the typical unit of parallel processing and compression
243    /// with many query engines. Therefore, smaller row groups enable better
244    /// parallelism and lower peak memory use but may reduce compression
245    /// efficiency.
246    ///
247    /// Note: Parquet files are limited to 32k row groups, so at high scale
248    /// factors, the row group size may be increased to keep the number of row
249    /// groups under this limit.
250    ///
251    /// Typical values range from 10MB to 100MB.
252    #[arg(long, default_value_t = DEFAULT_PARQUET_ROW_GROUP_BYTES)]
253    row_group_bytes: i64,
254}
255
256/// Parse a delimiter string, handling escape sequences.
257///
258/// The underlying arrow-csv writer requires an ASCII byte for the delimiter,
259/// so non-ASCII characters are rejected here rather than failing mid-generation.
260fn parse_delimiter(s: &str) -> Result<char, String> {
261    // Handle common escape sequences
262    let parsed = match s {
263        "\\t" => '\t',
264        "\\n" => '\n',
265        "\\r" => '\r',
266        "\\\\" => '\\',
267        _ => {
268            // If it's not an escape sequence, it should be a single character
269            let chars: Vec<char> = s.chars().collect();
270            if chars.len() != 1 {
271                return Err(format!(
272                    "Delimiter must be a single character or escape sequence (\\t, \\n, \\r, \\\\), got: '{}'",
273                    s
274                ));
275            }
276            chars[0]
277        }
278    };
279    if !parsed.is_ascii() {
280        return Err(format!(
281            "Delimiter must be an ASCII character, got: '{}'",
282            parsed
283        ));
284    }
285    Ok(parsed)
286}
287
288// TableValueParser is CLI-specific and uses the Table type from the library
289#[derive(Debug, Clone)]
290struct TableValueParser;
291
292impl TypedValueParser for TableValueParser {
293    type Value = Table;
294
295    /// Parse the value into a Table enum.
296    fn parse_ref(
297        &self,
298        cmd: &clap::Command,
299        _: Option<&clap::Arg>,
300        value: &std::ffi::OsStr,
301    ) -> Result<Self::Value, clap::Error> {
302        let value = value
303            .to_str()
304            .ok_or_else(|| clap::Error::new(clap::error::ErrorKind::InvalidValue).with_cmd(cmd))?;
305        Table::from_str(value)
306            .map_err(|_| clap::Error::new(clap::error::ErrorKind::InvalidValue).with_cmd(cmd))
307    }
308
309    fn possible_values(
310        &self,
311    ) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
312        Some(Box::new(
313            [
314                clap::builder::PossibleValue::new("region").help("Region table (alias: r)"),
315                clap::builder::PossibleValue::new("nation").help("Nation table (alias: n)"),
316                clap::builder::PossibleValue::new("supplier").help("Supplier table (alias: s)"),
317                clap::builder::PossibleValue::new("customer").help("Customer table (alias: c)"),
318                clap::builder::PossibleValue::new("part").help("Part table (alias: P)"),
319                clap::builder::PossibleValue::new("partsupp").help("PartSupp table (alias: S)"),
320                clap::builder::PossibleValue::new("orders").help("Orders table (alias: O)"),
321                clap::builder::PossibleValue::new("lineitem").help("LineItem table (alias: L)"),
322            ]
323            .into_iter(),
324        ))
325    }
326}
327
328impl Cli {
329    /// Run data generation for the selected command.
330    pub async fn run(self) -> io::Result<()> {
331        match self.command {
332            Some(Commands::Tbl(args)) => args.run().await,
333            Some(Commands::Csv(args)) => args.run().await,
334            Some(Commands::Parquet(args)) => args.run().await,
335            None => self.run_default().await,
336        }
337    }
338
339    async fn run_default(self) -> io::Result<()> {
340        // Warn about --format migration to subcommands (only when explicitly provided)
341        let (format, subcommand) = if let Some(format) = self.args.format {
342            let subcommand = match format {
343                OutputFormat::Parquet => "parquet",
344                OutputFormat::Csv => "csv",
345                OutputFormat::Tbl => "tbl",
346            };
347            (format, Some(subcommand))
348        } else {
349            (OutputFormat::Tbl, None)
350        };
351
352        let mut builder = self.args.common.builder(format);
353        if let Some(subcommand) = subcommand {
354            log::warn!(
355                "The --format flag will be removed in v4.0.0. Use `tpchgen-cli {subcommand}` instead."
356            );
357        }
358
359        if let Some(parquet_compression) = self.args.parquet_compression {
360            if format == OutputFormat::Parquet {
361                log::warn!("The --parquet-compression flag is deprecated. Use 'tpchgen-cli parquet --compression=...' instead");
362                builder = builder.with_parquet_compression(parquet_compression);
363            } else {
364                log::warn!("--parquet-compression ignored: output format is not parquet");
365            }
366        }
367
368        if let Some(parquet_row_group_bytes) = self.args.parquet_row_group_bytes {
369            if format == OutputFormat::Parquet {
370                log::warn!("The --parquet-row-group-bytes flag is deprecated. Use 'tpchgen-cli parquet --row-group-bytes=...' instead");
371                builder = builder.with_parquet_row_group_bytes(parquet_row_group_bytes);
372            } else {
373                log::warn!("--parquet-row-group-bytes ignored: output format is not parquet");
374            }
375        }
376
377        builder.build().generate().await
378    }
379}
380
381impl TblArgs {
382    async fn run(self) -> io::Result<()> {
383        self.common
384            .builder(OutputFormat::Tbl)
385            .build()
386            .generate()
387            .await
388    }
389}
390
391impl CsvArgs {
392    async fn run(self) -> io::Result<()> {
393        self.common
394            .builder(OutputFormat::Csv)
395            .with_csv_delimiter(self.delimiter)
396            .build()
397            .generate()
398            .await
399    }
400}
401
402impl ParquetArgs {
403    async fn run(self) -> io::Result<()> {
404        self.common
405            .builder(OutputFormat::Parquet)
406            .with_parquet_compression(self.compression)
407            .with_parquet_row_group_bytes(self.row_group_bytes)
408            .build()
409            .generate()
410            .await
411    }
412}
413
414fn configure_logging(
415    verbose: bool,
416    quiet: bool,
417    log_writer: Option<Box<dyn io::Write + Send + 'static>>,
418) {
419    let mut builder = env_logger::builder();
420    if quiet {
421        // Quiet mode: only show error-level logs
422        builder.filter_level(LevelFilter::Error);
423    } else if verbose {
424        builder.filter_level(LevelFilter::Info);
425    } else {
426        // Default: show warnings and errors, but respect RUST_LOG if set
427        builder.filter_level(LevelFilter::Warn).parse_default_env();
428    }
429    if let Some(log_writer) = log_writer {
430        builder.target(env_logger::Target::Pipe(log_writer));
431    }
432
433    builder.init();
434
435    if verbose {
436        info!("Verbose output enabled (ignoring RUST_LOG environment variable)");
437    }
438}