One-command assembly of a submission folder of
.xpttransport files via {xportr}, with a transport-layer gate that runs before you write and a manifest proving what shipped.
xportr writes an excellent transport file. But a submission is not one transport file — it is a folder of them, correctly named and split, with a manifest as evidence of what was sent. Today that assembly is a hand-run checklist people get wrong under deadline. submitpack makes it one call.
What it does. Takes data frames plus a metadata spec and writes a folder of SAS v5 transport files; splits anything over the per-file ceiling; runs nineteen transport-layer checks first and refuses to write on any error-severity finding; returns a manifest of file names, sizes, MD5 checksums and record counts read back off disk.
What it does not do.
write_submission(define_xml = ) is a hook. Give it the path of a Define-XML file you already have and it is copied into the folder, or give it a function(spec, path) and it is called after the transport files are written. define21::write_define() fits that signature — there is a guarded integration test for exactly that — but submitpack neither imports nor requires it, and works completely without it.submission_spec is transport metadata, not a Define-XML model. No codelists, value-level metadata, methods, comments, origins or where-clauses.
install.packages(
"https://clincoder.cloud/submitpack/submitpack_0.0.1.tar.gz",
repos = NULL, type = "source"
)
library(submitpack)
datasets <- data.frame(dataset = "DM", label = "Demographics", class = "SDTM")
variables <- data.frame(
dataset = "DM",
variable = c("USUBJID", "AGE", "SEX"),
label = c("Unique Subject Identifier", "Age", "Sex"),
type = c("character", "numeric", "character"),
length = c(20, 8, 1),
format = NA_character_,
order = 1:3
)
spec <- submission_spec(datasets, variables, study = list(study_id = "CDISCPILOT01"))
dm <- data.frame(
USUBJID = c("CDISC-001", "CDISC-002"),
AGE = c(63, 35),
SEX = c("M", "F")
)
# Would this get rejected? Check before you write.
check_submission(list(DM = dm), spec)
# Run the xportr pipeline (type, length, label, format, order, write),
# split anything over split_mb, and get the manifest back.
man <- write_submission(list(DM = dm), spec, path = "submission")
man
# Independently prove what's actually on disk.
submission_manifest("submission")Nineteen, and this is the whole list. Each row check_submission() returns names the check that produced it.
| Check | Severity | What it catches |
|---|---|---|
dataset_name_length |
error | Dataset name over 8 characters |
variable_name_length |
error | Variable name over 8 characters |
dataset_name_chars |
error | Dataset name outside [A-Za-z_][A-Za-z0-9_]*
|
variable_name_chars |
error | Variable name outside [A-Za-z_][A-Za-z0-9_]*
|
dataset_label_length |
error | Dataset label over 40 characters |
variable_label_length |
error | Variable label over 200 characters |
duplicate_dataset_name |
error | Dataset declared twice |
duplicate_variable_name |
error | Variable declared twice, in the spec or in the real data frame’s column names |
length_too_short |
error | Declared length shorter than the longest observed value, counted in bytes — a 4-character UTF-8 value needing 5 bytes is caught |
control_character |
error | A C0 control byte (0x01–0x1F) or DEL in a label or a value |
non_ascii_metadata |
warning | Byte above 0x7F in a dataset or variable label |
non_ascii_data |
warning | Byte above 0x7F in a character value |
split_variable_set |
error | Two parts of a split dataset carry different variables |
split_variable_order |
error | Two parts hold the same variables in a different order |
split_length_consistency |
error | A variable’s declared length differs between parts |
split_label_consistency |
error | A variable’s label differs between parts (first 40 bytes, all a v5 NAMESTR holds) |
split_part_name_length |
error | A part file name over 8 characters |
split_part_naming |
warning | Part names that are not <dataset><n>
|
file_size |
warning | Estimated written size over max_file_mb (default 5000) |
Split parts can be given as a list of data frames or as paths to already-written .xpt files. For files, the declared length is read out of the transport file’s NAMESTR records directly, because haven::read_xpt() trims the padding away and cannot tell you what the file declared.
write_submission() runs all of this automatically and aborts, before touching disk, on any "error"-severity violation.
USUBJID in AE existing in DM), required or expected variables for a domain, variable order against the CDISC IG, ISO 8601 date formats, --SEQ uniqueness, value-level metadata, SUPPQUAL structure, or any of the several hundred Pinnacle 21 rules. Zero violations means clean against the table above and nothing more. Run Pinnacle 21 (or p21bridge) for conformance.max_file_mb yourself if the guidance you are working to differs, or NULL to skip the check.file_size is computed, not measured. XPT v5 is a fixed layout with no compression, so the size follows from the declared lengths and row count; the estimator is asserted byte-for-byte against real CDISCPILOT01 files in the test suite. It is still arithmetic on the spec, so a spec that does not match the data gives a size that does not match the file.write_submission(split_mb = ) are consistent by construction (they come from one data frame), so the split checks exist for parts you did not write here — legacy files, another vendor’s, a previous run.submission_manifest() is one level deep and the checksum is MD5: an integrity check, not a tamper-proof signature. It is also not reproducible across runs — a v5 transport file stamps its own creation date and time into the header, so writing the same data twice gives the same size and a different checksum."character" and "numeric" types. Dates are the ISO 8601 strings or the numeric SAS dates you hand in; no date semantics are applied.submission_spec() — the metadata object driving the write. Its variables table uses the exact column names xportr expects (dataset, variable, label, type, length, format, order), so it can be handed straight to xportr::xportr_type() and friends.write_submission() — runs the xportr pipeline per dataset in the correct order, writes the folder, splits oversized files via xportr::xportr_write(max_size_gb = ), and returns the manifest.check_submission() — the pre-write gate described above.submission_manifest() — reads a submission folder back off disk (file name, size, MD5 checksum, record count, variable count) — the artefact that proves what shipped, independent of what the writer thinks it wrote.Real CDISCPILOT01 (LZZT) data, shipped in inst/extdata: the dataset and variable metadata extracted from the study’s own SDTM and ADaM define.xml (32 datasets, 728 variables), plus the real DM (306 × 25) and ADSL (254 × 48) data. Writing DM from that metadata reproduces the study’s own dm.xpt at exactly 110,800 bytes.