From 98504eef0af2c25b2ab0fa0fd5bc0be36c1fab00 Mon Sep 17 00:00:00 2001 From: Martin English Date: Thu, 3 Sep 2026 11:42:09 +0100 Subject: [PATCH] fix: keep empty crosstab rows and columns through the reshape dcast only emits a row/column for each .ri/.ci value actually present in the data, so a crosstab row or column with no observations at all fell out of the wide table while the headers were still built from the full rselect()/cselect(). Reported by PamGene as: Error in setnames(x, value) : Can't assign 5 names to a 4-column data.table That is the benign failure. When the surviving row count divides evenly into the expected one, cbind() recycles instead of erroring and the export silently contains the wrong values - rows 3 and 4 carrying the data of rows 1 and 2, with only a warning. The default crosstab-view path (collapse_cols = FALSE) is affected the same way. Pin the .ri/.ci factor levels to the full index range and dcast with drop = FALSE, so the data block always lines up with the headers and empty cells come through as NA. Verified against the shipped test_1 fixture (output byte-identical) and across empty-row/empty-column shapes for both collapse_cols modes, CSV, TSV and XLSX. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EeoFKnewHFjCdPK45kWjDG --- main.R | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/main.R b/main.R index ce4d0f5..aea347d 100644 --- a/main.R +++ b/main.R @@ -50,11 +50,21 @@ if(is.null(wfId)) { # unit test condition } } -df_wide <- dcast(df_long, .ri ~ .ci, value.var = ".y") -raw_data <- df_wide[order(.ri)][, !".ri"] - row_values <- as.data.table(ctx$rselect()) col_values <- as.data.table(ctx$cselect()) + +# dcast only emits a row/column per .ri/.ci value that actually occurs, so a crosstab +# row or column holding no observations at all would silently drop out and leave the +# data block smaller than the headers built from rselect()/cselect() - which then fails +# in colnames<- ("Can't assign N names to an M-column data.table"). Pin the levels to +# the full index range and keep the empty ones so the block always lines up. +n_ri <- max(nrow(row_values), max(df_long$.ri) + 1L) +n_ci <- max(nrow(col_values), max(df_long$.ci) + 1L) +df_long[, .ri := factor(.ri, levels = seq_len(n_ri) - 1L)] +df_long[, .ci := factor(.ci, levels = seq_len(n_ci) - 1L)] + +df_wide <- dcast(df_long, .ri ~ .ci, value.var = ".y", drop = FALSE) +raw_data <- df_wide[order(.ri)][, !".ri"] yaxis_names <- unlist(ctx$yAxis) if (length(yaxis_names) == 0) yaxis_names <- "" row_names_in <- names(ctx$rnames)