Skip to content

Commit 09c6e37

Browse files
jqnatividadclaudeCopilot
authored
feat(to): table also for xlsx and ods (#3580)
* feat(to): Also support --table for xlsx/ods and validate names Add examples to the xlsx/ods help showing --table usage and stdin support. Extend the --table help text to clarify it applies to postgres/sqlite and xlsx/ods and document name rules and limits. Change validation so --table is disallowed for datapackage but allowed for xlsx/ods; add sheet-name checks (xlsx max 31 chars, disallow \ / * [ ] : ?) while keeping postgres/sqlite identifier checks (start with letter/underscore, alnum/_ only, max 63). Wire up apply_table_rename when processing xlsx and ods inputs so provided table/sheet names are applied during conversion. * tests(to): add tests for to --table for xlsx/ods Co-Authored-By: Claude <claude@users.noreply.github.com> * fix(to): enforce 31-char sheet name limit for ODS and use char count - Apply the 31-character sheet name limit to both xlsx and ods (previously xlsx only), matching ODF spec conventions - Use chars().count() instead of len() for character-based length checking, so multi-byte UTF-8 names aren't rejected prematurely - Add test for ODS sheet name exceeding 31 characters - Update help text to clarify limit applies to both formats Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * tests(to): improve to --table test reliability - Fix xlsx too_long test to use "a".repeat(32) instead of "a]".repeat(16) which contained forbidden chars, masking the length validation check - Add output file existence assertions to xlsx and ods happy-path tests to ensure the command actually produces the expected file Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs(to): update help md and TOC * typo: ODF -> ODS * refactor(to): addl xlsx/ods validation per Copilot review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * tests(to): tighten tests per Copilot low-confidence suggestion * fix(to): avoid double command execution in xlsx/ods happy path tests `assert_success` and `output` both call `cmd.output()`, running the command twice. Replace with a single `output()` call followed by an inline status assertion, eliminating redundant execution and potential side-effect issues from the second run. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude <claude@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent fc7835f commit 09c6e37

File tree

4 files changed

+205
-37
lines changed

4 files changed

+205
-37
lines changed

docs/help/TableOfContents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@
8484
🧠: expensive operations are memoized with available inter-session Redis/Disk caching for fetch commands.
8585
🗄️: [Extended input support](../../README.md#extended-input-support).
8686
🗃️: [Limited Extended input support](../../README.md#limited-extended-input-support).
87-
🐻‍❄️: command powered/accelerated by [![polars 0.53.0:802550b](https://img.shields.io/badge/polars-0.53.0:802550b-blue?logo=polars
87+
🐻‍❄️: command powered/accelerated by [![polars 0.53.0:9f1a742](https://img.shields.io/badge/polars-0.53.0:9f1a742-blue?logo=polars
8888
)](https://github.com/pola-rs/polars/releases/tag/rs-0.53.0) vectorized query engine.
8989
🤖: command uses Natural Language Processing or Generative AI.
9090
🏎️: multithreaded and/or faster when an index (📇) is available.

docs/help/to.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ Load files listed in the 'ourdata.infile-list' into xlsx file.
135135
qsv to xlsx output.xlsx ourdata.infile-list
136136
```
137137

138+
Load a single CSV into xlsx with a custom sheet name.
139+
```console
140+
qsv to xlsx output.xlsx --table "Sales Data" file1.csv
141+
```
142+
143+
Load from stdin with a custom sheet name.
144+
```console
145+
cat data.csv | qsv to xlsx output.xlsx --table "Monthly Report" -
146+
```
147+
138148
### ODS
139149

140150
Convert to new ODS (Open Document Spreadsheet) file.
@@ -156,6 +166,16 @@ Load files listed in the 'ourdata.infile-list' into ODS file.
156166
qsv to ods output.ods ourdata.infile-list
157167
```
158168

169+
Load a single CSV into ODS with a custom sheet name.
170+
```console
171+
qsv to ods output.ods --table "Sales Data" file1.csv
172+
```
173+
174+
Load from stdin with a custom sheet name.
175+
```console
176+
cat data.csv | qsv to ods output.ods --table "Monthly Report" -
177+
```
178+
159179
### Data Package
160180

161181
Generate a datapackage, which contains stats and information about what is in the CSV files.
@@ -215,7 +235,7 @@ qsv to --help
215235
| &nbsp;`-d,`<br>`--drop`&nbsp; | flag | Drop tables before loading new data into them (postgres/sqlite only). | |
216236
| &nbsp;`-e,`<br>`--evolve`&nbsp; | flag | If loading into existing db, alter existing tables so that new data will load. (postgres/sqlite only). | |
217237
| &nbsp;`-i,`<br>`--pipe`&nbsp; | flag | Adjust output format for piped data (omits row counts and field format columns). | |
218-
| &nbsp;`-t,`<br>`--table`&nbsp; | string | Use this as the table name (postgres/sqlite only). Overrides the default table name derived from the input filename. When reading from stdin, the default table name is "stdin". Only valid with a single input file. Table name must start with a letter or underscore, and contain only alphanumeric characters and underscores. | |
238+
| &nbsp;`-t,`<br>`--table`&nbsp; | string | Use this as the table/sheet name (postgres/sqlite/xlsx/ods). Overrides the default name derived from the input filename. When reading from stdin, the default table name is "stdin". Only valid with a single input file. For postgres/sqlite: must start with a letter or underscore, contain only alphanumeric characters and underscores (max 63). For xlsx/ods: used as sheet name (max 31 chars, cannot contain \ / * [ ] : ?). | |
219239
| &nbsp;`-p,`<br>`--separator`&nbsp; | string | For xlsx, use this character to help truncate xlsx sheet names. Defaults to space. | |
220240
| &nbsp;`-A,`<br>`--all-strings`&nbsp; | flag | Convert all fields to strings. | |
221241
| &nbsp;`-j,`<br>`--jobs`&nbsp; | string | The number of jobs to run in parallel. When not set, the number of jobs is set to the number of CPUs detected. | |

src/cmd/to.rs

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,14 @@ Load files listed in the 'ourdata.infile-list' into xlsx file.
109109
110110
$ qsv to xlsx output.xlsx ourdata.infile-list
111111
112+
Load a single CSV into xlsx with a custom sheet name.
113+
114+
$ qsv to xlsx output.xlsx --table "Sales Data" file1.csv
115+
116+
Load from stdin with a custom sheet name.
117+
118+
$ cat data.csv | qsv to xlsx output.xlsx --table "Monthly Report" -
119+
112120
ODS
113121
===
114122
Convert to new ODS (Open Document Spreadsheet) file.
@@ -129,6 +137,14 @@ Load files listed in the 'ourdata.infile-list' into ODS file.
129137
130138
$ qsv to ods output.ods ourdata.infile-list
131139
140+
Load a single CSV into ODS with a custom sheet name.
141+
142+
$ qsv to ods output.ods --table "Sales Data" file1.csv
143+
144+
Load from stdin with a custom sheet name.
145+
146+
$ cat data.csv | qsv to ods output.ods --table "Monthly Report" -
147+
132148
DATA PACKAGE
133149
============
134150
Generate a datapackage, which contains stats and information about what is in the CSV files.
@@ -175,12 +191,14 @@ To options:
175191
-d, --drop Drop tables before loading new data into them (postgres/sqlite only).
176192
-e, --evolve If loading into existing db, alter existing tables so that new data will load. (postgres/sqlite only).
177193
-i, --pipe Adjust output format for piped data (omits row counts and field format columns).
178-
-t, --table <name> Use this as the table name (postgres/sqlite only).
179-
Overrides the default table name derived from the input filename.
194+
-t, --table <name> Use this as the table/sheet name (postgres/sqlite/xlsx/ods).
195+
Overrides the default name derived from the input filename.
180196
When reading from stdin, the default table name is "stdin".
181-
Only valid with a single input file. Table name must start with
182-
a letter or underscore, and contain only alphanumeric characters
183-
and underscores.
197+
Only valid with a single input file.
198+
For postgres/sqlite: must start with a letter or underscore,
199+
contain only alphanumeric characters and underscores (max 63).
200+
For xlsx/ods: used as sheet name (max 31 chars,
201+
cannot contain \ / * [ ] : ?).
184202
-p, --separator <arg> For xlsx, use this character to help truncate xlsx sheet names.
185203
Defaults to space.
186204
-A, --all-strings Convert all fields to strings.
@@ -276,29 +294,47 @@ pub fn run(argv: &[&str]) -> CliResult<()> {
276294

277295
// validate --table option
278296
if let Some(ref table_name) = args.flag_table {
279-
if args.cmd_xlsx || args.cmd_ods || args.cmd_datapackage {
297+
if args.cmd_datapackage {
280298
return fail_incorrectusage_clierror!(
281-
"--table can only be used with postgres or sqlite subcommands."
299+
"--table cannot be used with the datapackage subcommand."
282300
);
283301
}
284302
if table_name.is_empty() {
285303
return fail_incorrectusage_clierror!("--table name must not be empty.");
286304
}
287-
if !table_name.starts_with(|c: char| c.is_alphabetic() || c == '_') {
288-
return fail_incorrectusage_clierror!(
289-
"--table name must start with a letter or underscore."
290-
);
291-
}
292-
if !table_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
293-
return fail_incorrectusage_clierror!(
294-
"--table name must contain only alphanumeric characters and underscores."
295-
);
296-
}
297-
// PostgreSQL limits identifiers to 63 characters; cap table names accordingly
298-
if table_name.len() > 63 {
299-
return fail_incorrectusage_clierror!(
300-
"--table name must not exceed 63 characters (PostgreSQL identifier limit)."
301-
);
305+
if args.cmd_xlsx || args.cmd_ods {
306+
// xlsx/ods sheet name validation
307+
// Both xlsx (Excel) and ods (ODS) enforce a 31-character limit on sheet names
308+
if table_name.chars().count() > 31 {
309+
return fail_incorrectusage_clierror!(
310+
"--table sheet name must not exceed 31 characters for xlsx/ods."
311+
);
312+
}
313+
// Also ensure the sheet name is valid when used as a filesystem filename
314+
// (Windows-invalid filename characters: < > : \" / \\ | ? *)
315+
if table_name.contains(&['\\', '/', '*', '[', ']', ':', '?', '<', '>', '"', '|'][..]) {
316+
return fail_incorrectusage_clierror!(
317+
"--table sheet name cannot contain \\ / * [ ] : ? < > \" | characters."
318+
);
319+
}
320+
} else {
321+
// postgres/sqlite table name validation
322+
if !table_name.starts_with(|c: char| c.is_alphabetic() || c == '_') {
323+
return fail_incorrectusage_clierror!(
324+
"--table name must start with a letter or underscore."
325+
);
326+
}
327+
if !table_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
328+
return fail_incorrectusage_clierror!(
329+
"--table name must contain only alphanumeric characters and underscores."
330+
);
331+
}
332+
// PostgreSQL limits identifiers to 63 characters; cap table names accordingly
333+
if table_name.len() > 63 {
334+
return fail_incorrectusage_clierror!(
335+
"--table name must not exceed 63 characters (PostgreSQL identifier limit)."
336+
);
337+
}
302338
}
303339
}
304340

@@ -335,13 +371,15 @@ pub fn run(argv: &[&str]) -> CliResult<()> {
335371
} else if args.cmd_xlsx {
336372
debug!("converting to Excel XLSX");
337373
arg_input = process_input(arg_input, &tmpdir, EMPTY_STDIN_ERRMSG)?;
374+
apply_table_rename(&args.flag_table, &mut arg_input, &tmpdir)?;
338375

339376
output =
340377
csvs_to_xlsx_with_options(args.arg_xlsx.expect("checked above"), arg_input, options)?;
341378
debug!("conversion to Excel XLSX complete");
342379
} else if args.cmd_ods {
343380
debug!("converting to ODS");
344381
arg_input = process_input(arg_input, &tmpdir, EMPTY_STDIN_ERRMSG)?;
382+
apply_table_rename(&args.flag_table, &mut arg_input, &tmpdir)?;
345383

346384
output =
347385
csvs_to_ods_with_options(args.arg_ods.expect("checked above"), arg_input, options)?;

tests/test_to.rs

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -531,44 +531,154 @@ fn to_ods_dir() {
531531
}
532532

533533
#[test]
534-
fn to_table_error_xlsx() {
535-
// --table should fail with xlsx subcommand
536-
let wrk = Workdir::new("to_table_error_xlsx");
534+
fn to_table_xlsx_happy_path() {
535+
// --table with xlsx should use the custom sheet name
536+
let wrk = Workdir::new("to_table_xlsx_happy");
537+
wrk.create(
538+
"in.csv",
539+
vec![svec!["city", "state"], svec!["Boston", "MA"]],
540+
);
541+
542+
let xlsx_file = wrk.path("test.xlsx").to_string_lossy().to_string();
543+
let mut cmd = wrk.command("to");
544+
cmd.arg("xlsx")
545+
.arg("--table")
546+
.arg("My Sheet")
547+
.arg(&xlsx_file)
548+
.arg("in.csv");
549+
550+
let output = wrk.output(&mut cmd);
551+
assert!(
552+
output.status.success(),
553+
"Command failed: {}",
554+
String::from_utf8_lossy(&output.stderr)
555+
);
556+
let stdout = String::from_utf8_lossy(&output.stdout);
557+
assert!(
558+
stdout.contains("My Sheet"),
559+
"Expected sheet name 'My Sheet' in output, got: {stdout}"
560+
);
561+
assert!(
562+
wrk.path("test.xlsx").exists(),
563+
"Expected output file test.xlsx to exist"
564+
);
565+
}
566+
567+
#[test]
568+
fn to_table_error_xlsx_invalid_chars() {
569+
// --table with invalid sheet name characters should fail for xlsx
570+
let wrk = Workdir::new("to_table_error_xlsx_chars");
537571
wrk.create("in.csv", vec![svec!["col1"], svec!["a"]]);
538572

539573
let xlsx_file = wrk.path("test.xlsx").to_string_lossy().to_string();
540574
let mut cmd = wrk.command("to");
541575
cmd.arg("xlsx")
542576
.arg("--table")
543-
.arg("custom_name")
577+
.arg("bad[name")
544578
.arg(xlsx_file)
545579
.arg("in.csv");
546580

547581
let stderr = wrk.output_stderr(&mut cmd);
548582
assert!(
549-
stderr.contains("--table can only be used with postgres or sqlite"),
550-
"Expected unsupported subcommand error, got: {stderr}"
583+
stderr.contains("sheet name cannot contain"),
584+
"Expected invalid chars error, got: {stderr}"
585+
);
586+
}
587+
588+
#[test]
589+
fn to_table_error_xlsx_too_long() {
590+
// --table with sheet name > 31 chars should fail for xlsx
591+
let wrk = Workdir::new("to_table_error_xlsx_long");
592+
wrk.create("in.csv", vec![svec!["col1"], svec!["a"]]);
593+
594+
let xlsx_file = wrk.path("test.xlsx").to_string_lossy().to_string();
595+
let mut cmd = wrk.command("to");
596+
cmd.arg("xlsx")
597+
.arg("--table")
598+
.arg("a".repeat(32))
599+
.arg(xlsx_file)
600+
.arg("in.csv");
601+
602+
let stderr = wrk.output_stderr(&mut cmd);
603+
assert!(
604+
stderr.contains("must not exceed 31 characters"),
605+
"Expected length error, got: {stderr}"
551606
);
552607
}
553608

554609
#[test]
555-
fn to_table_error_ods() {
556-
// --table should fail with ods subcommand
557-
let wrk = Workdir::new("to_table_error_ods");
610+
fn to_table_error_ods_too_long() {
611+
// --table with sheet name > 31 chars should fail for ods
612+
let wrk = Workdir::new("to_table_error_ods_long");
558613
wrk.create("in.csv", vec![svec!["col1"], svec!["a"]]);
559614

560615
let ods_file = wrk.path("test.ods").to_string_lossy().to_string();
561616
let mut cmd = wrk.command("to");
562617
cmd.arg("ods")
563618
.arg("--table")
564-
.arg("custom_name")
619+
.arg("a".repeat(32))
565620
.arg(ods_file)
566621
.arg("in.csv");
567622

568623
let stderr = wrk.output_stderr(&mut cmd);
569624
assert!(
570-
stderr.contains("--table can only be used with postgres or sqlite"),
571-
"Expected unsupported subcommand error, got: {stderr}"
625+
stderr.contains("must not exceed 31 characters"),
626+
"Expected length error, got: {stderr}"
627+
);
628+
}
629+
630+
#[test]
631+
fn to_table_ods_happy_path() {
632+
// --table with ods should use the custom sheet name
633+
let wrk = Workdir::new("to_table_ods_happy");
634+
wrk.create(
635+
"in.csv",
636+
vec![svec!["city", "state"], svec!["Boston", "MA"]],
637+
);
638+
639+
let ods_file = wrk.path("test.ods").to_string_lossy().to_string();
640+
let mut cmd = wrk.command("to");
641+
cmd.arg("ods")
642+
.arg("--table")
643+
.arg("My Sheet")
644+
.arg(&ods_file)
645+
.arg("in.csv");
646+
647+
let output = wrk.output(&mut cmd);
648+
assert!(
649+
output.status.success(),
650+
"Command failed: {}",
651+
String::from_utf8_lossy(&output.stderr)
652+
);
653+
let stdout = String::from_utf8_lossy(&output.stdout);
654+
assert!(
655+
stdout.contains("My Sheet"),
656+
"Expected sheet name 'My Sheet' in output, got: {stdout}"
657+
);
658+
assert!(
659+
wrk.path("test.ods").exists(),
660+
"Expected output file test.ods to exist"
661+
);
662+
}
663+
664+
#[test]
665+
fn to_table_error_ods_invalid_chars() {
666+
// --table with invalid sheet name characters should fail for ods
667+
let wrk = Workdir::new("to_table_error_ods_chars");
668+
wrk.create("in.csv", vec![svec!["col1"], svec!["a"]]);
669+
670+
let ods_file = wrk.path("test.ods").to_string_lossy().to_string();
671+
let mut cmd = wrk.command("to");
672+
cmd.arg("ods")
673+
.arg("--table")
674+
.arg("bad:name")
675+
.arg(ods_file)
676+
.arg("in.csv");
677+
678+
let stderr = wrk.output_stderr(&mut cmd);
679+
assert!(
680+
stderr.contains("sheet name cannot contain"),
681+
"Expected invalid chars error, got: {stderr}"
572682
);
573683
}
574684

@@ -588,7 +698,7 @@ fn to_table_error_datapackage() {
588698

589699
let stderr = wrk.output_stderr(&mut cmd);
590700
assert!(
591-
stderr.contains("--table can only be used with postgres or sqlite"),
701+
stderr.contains("--table cannot be used with the datapackage subcommand"),
592702
"Expected unsupported subcommand error, got: {stderr}"
593703
);
594704
}

0 commit comments

Comments
 (0)