tests/testthat/test-codec_ndjson.R

# The NDJSON codec: CDISC Dataset-JSON v1.1, newline-delimited variant.
# Line 1 is the metadata object (everything a .json file carries except
# `rows`); each subsequent line is one row array. Bounded memory in both
# directions: the writer streams slabs, the reader accumulates per slab.

test_that("ndjson round-trips the torture frame losslessly", {
  src <- .torture_frame()
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(src, p)
  back <- read_ndjson(p)
  expect_lossless(src, back, via = "ndjson")
})

test_that("the file is one JSON object per line, meta first", {
  df <- data.frame(
    USUBJID = c("01-001", "01-002"),
    AVAL = c(1.5, NA),
    stringsAsFactors = FALSE
  )
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  lines <- readLines(p)
  expect_length(lines, 3L)
  meta_line <- jsonlite::fromJSON(lines[1], simplifyVector = FALSE)
  expect_identical(meta_line$datasetJSONVersion, "1.1.0")
  expect_false("rows" %in% names(meta_line))
  row1 <- jsonlite::fromJSON(lines[2], simplifyVector = FALSE)
  expect_identical(row1[[1]], "01-001")
  row2 <- jsonlite::fromJSON(lines[3], simplifyVector = FALSE)
  expect_null(row2[[2]])
})

test_that("ndjson is byte-stable under a frozen created", {
  df <- data.frame(USUBJID = "01-001", AVAL = 1.5, stringsAsFactors = FALSE)
  frozen <- as.POSIXct("2024-01-15 10:30:00", tz = "UTC")
  p1 <- withr::local_tempfile(fileext = ".ndjson")
  p2 <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p1, created = frozen)
  write_ndjson(df, p2, created = frozen)
  expect_identical(
    readBin(p1, "raw", file.info(p1)$size),
    readBin(p2, "raw", file.info(p2)$size)
  )
})

test_that("n_max reads only the requested rows", {
  df <- data.frame(ID = sprintf("S-%03d", 1:50), AVAL = as.numeric(1:50))
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  withr::local_options(artoo.json_slab_rows = 7L)
  part <- read_ndjson(p, n_max = 10)
  expect_identical(nrow(part), 10L)
  expect_identical(part$ID, df$ID[1:10])
  expect_identical(get_meta(part)@dataset$records, 10L)
})

test_that("col_select narrows columns with the generic filter", {
  df <- data.frame(A = 1:3, B = c("x", "y", "z"), C = c(0.5, NA, 2.5))
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  part <- read_ndjson(p, col_select = c("C", "A"))
  expect_identical(names(part), c("A", "C"))
  expect_error(
    read_ndjson(p, col_select = "NOPE"),
    class = "artoo_error_input"
  )
})

test_that("a ragged row aborts with the row number", {
  df <- data.frame(A = 1:2, B = c("x", "y"))
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  lines <- readLines(p)
  lines[3] <- "[1]" # row 2 loses a cell
  writeLines(lines, p)
  expect_error(read_ndjson(p), class = "artoo_error_codec")
})

test_that("a non-Dataset-JSON first line aborts cleanly", {
  p <- withr::local_tempfile(fileext = ".ndjson")
  writeLines(c("{\"foo\": 1}", "[1,2]"), p)
  expect_error(read_ndjson(p), class = "artoo_error_codec")
  writeLines("not json at all", p)
  expect_error(read_ndjson(p), class = "artoo_error_codec")
})

test_that("a 0-row frame round-trips with typed columns", {
  df <- data.frame(USUBJID = character(0), AVAL = numeric(0))
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  back <- read_ndjson(p)
  expect_identical(nrow(back), 0L)
  expect_type(back$USUBJID, "character")
  expect_type(back$AVAL, "double")
})

test_that("special missings and the _artoo block ride line 1", {
  df <- data.frame(AENDY = c(10, NA, NA))
  attr(df$AENDY, "sas_missing") <- c(NA, ".A", NA)
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  meta_line <- jsonlite::fromJSON(readLines(p)[1], simplifyVector = FALSE)
  expect_identical(
    unlist(meta_line[["_artoo"]]$specialMissings$AENDY$tags),
    ".A"
  )
  back <- read_ndjson(p)
  expect_identical(attr(back$AENDY, "sas_missing"), c(NA, ".A", NA))
})

test_that("write_ndjson(strict = TRUE) suppresses _artoo with a warning", {
  df <- data.frame(AENDY = c(10, NA))
  attr(df$AENDY, "sas_missing") <- c(NA, ".A")
  p <- withr::local_tempfile(fileext = ".ndjson")
  expect_warning(
    write_ndjson(df, p, strict = TRUE),
    class = "artoo_warning_codec"
  )
  meta_line <- jsonlite::fromJSON(readLines(p)[1], simplifyVector = FALSE)
  expect_false("_artoo" %in% names(meta_line))
})

# ---- gz transparency (json + ndjson) ----------------------------------------

test_that("a .ndjson.gz round-trips transparently", {
  src <- .torture_frame()
  p <- withr::local_tempfile(fileext = ".ndjson.gz")
  write_ndjson(src, p)
  # The bytes on disk are gzip.
  head2 <- readBin(p, "raw", 2L)
  expect_identical(head2, as.raw(c(0x1F, 0x8B)))
  expect_lossless(src, read_ndjson(p), via = "ndjson.gz")
  # And the generic dispatcher resolves the double extension.
  expect_lossless(src, read_dataset(p), via = "ndjson.gz dispatch")
})

test_that("a .json.gz round-trips transparently", {
  src <- .torture_frame()
  p <- withr::local_tempfile(fileext = ".json.gz")
  write_dataset(src, p)
  head2 <- readBin(p, "raw", 2L)
  expect_identical(head2, as.raw(c(0x1F, 0x8B)))
  expect_lossless(src, read_dataset(p), via = "json.gz")
})

test_that("gz is refused for formats that do not stream text", {
  df <- data.frame(A = 1)
  p <- withr::local_tempfile(fileext = ".xpt.gz")
  expect_error(write_dataset(df, p), class = "artoo_error_input")
})

# ---- coverage of the guard branches -----------------------------------------

test_that("encode without metadata aborts (0-column frame)", {
  df <- data.frame()
  p <- withr::local_tempfile(fileext = ".ndjson")
  expect_error(write_ndjson(df, p), class = "artoo_error_codec")
})

test_that("an empty file and a BOM-prefixed file are handled", {
  p <- withr::local_tempfile(fileext = ".ndjson")
  file.create(p)
  expect_error(read_ndjson(p), class = "artoo_error_codec")

  # A BOM before the metadata line is stripped, not a parse error.
  df <- data.frame(A = 1:2)
  write_ndjson(df, p)
  lines <- readLines(p)
  con <- file(p, "wb")
  writeBin(
    c(
      as.raw(c(0xEF, 0xBB, 0xBF)),
      charToRaw(paste0(
        paste(lines, collapse = "\n"),
        "\n"
      ))
    ),
    con
  )
  close(con)
  back <- read_ndjson(p)
  expect_identical(back$A, 1:2)
})

test_that("a row line that is not JSON aborts with the row range", {
  df <- data.frame(A = 1:3)
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  lines <- readLines(p)
  lines[3] <- "[1,}garbage"
  writeLines(lines, p)
  expect_error(read_ndjson(p), class = "artoo_error_codec")
})

test_that("n_max larger than the file reads everything", {
  df <- data.frame(A = 1:5)
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  expect_identical(nrow(read_ndjson(p, n_max = 100)), 5L)
})

test_that("interior blank lines are tolerated", {
  df <- data.frame(A = 1:3)
  p <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p)
  lines <- readLines(p)
  writeLines(c(lines[1], lines[2], "", lines[3], lines[4], ""), p)
  withr::local_options(artoo.json_slab_rows = 2L)
  back <- read_ndjson(p)
  expect_identical(back$A, 1:3)
})

test_that("a failed encode leaves any prior ndjson untouched", {
  p <- withr::local_tempfile(fileext = ".ndjson")
  df <- data.frame(A = 1:2)
  write_ndjson(df, p)
  before <- readBin(p, "raw", file.info(p)$size)
  bad <- data.frame(AVAL = c(1, NaN))
  expect_error(write_ndjson(bad, p), class = "artoo_error_type")
  expect_identical(readBin(p, "raw", file.info(p)$size), before)
})

test_that("the jsonl extension resolves to the ndjson codec", {
  df <- data.frame(A = 1:2)
  p <- withr::local_tempfile(fileext = ".jsonl")
  write_dataset(df, p)
  expect_identical(read_dataset(p)$A, 1:2)
})

# ---- on_invalid: UTF-8 validation parity with write_xpt --------------------
test_that("write_ndjson gates invalid UTF-8 through on_invalid", {
  df <- data.frame(
    USUBJID = c(rawToChar(as.raw(c(0x63, 0xE9))), "01-002"),
    stringsAsFactors = FALSE
  )
  p <- withr::local_tempfile(fileext = ".ndjson")
  expect_error(write_ndjson(df, p), class = "artoo_error_codec")
  expect_warning(
    write_ndjson(df, p, on_invalid = "replace"),
    class = "artoo_warning_encoding"
  )
  back <- read_ndjson(p)
  expect_true(all(validUTF8(back$USUBJID)))
  expect_match(back$USUBJID[1], "[?]")
  p2 <- withr::local_tempfile(fileext = ".ndjson")
  expect_no_warning(write_ndjson(df, p2, on_invalid = "ignore"))
  expect_identical(read_ndjson(p2)$USUBJID[1], "c")
})

test_that("read_ndjson(encoding=) transcodes non-UTF-8 source bytes to UTF-8", {
  # A frame carrying a non-ASCII glyph, written as UTF-8, then re-encoded to
  # windows-1252 to simulate a non-conformant producer.
  df <- data.frame(
    USUBJID = c("01-001", "01-002"),
    NOTE = c("café", "café"), # e-acute
    stringsAsFactors = FALSE
  )
  p_utf8 <- withr::local_tempfile(fileext = ".ndjson")
  write_ndjson(df, p_utf8)

  # Default read of the UTF-8 file recovers the glyph (NFC parity with json).
  expect_equal(read_ndjson(p_utf8)$NOTE, c("café", "café"))

  # Re-encode UTF-8 -> windows-1252 (e-acute becomes byte 0xE9).
  txt <- rawToChar(readBin(p_utf8, "raw", n = file.size(p_utf8)))
  Encoding(txt) <- "UTF-8"
  w1252 <- iconv(txt, from = "UTF-8", to = "WINDOWS-1252", toRaw = TRUE)[[1]]
  p_w1252 <- withr::local_tempfile(fileext = ".ndjson")
  writeBin(w1252, p_w1252)

  # encoding = "windows-1252" recovers the real glyph on read.
  back <- read_ndjson(p_w1252, encoding = "windows-1252")
  expect_equal(back$NOTE, c("café", "café"))
  expect_true(all(validUTF8(back$NOTE)))
})

Try the artoo package in your browser

Any scripts or data that you put into this service are public.

artoo documentation built on July 23, 2026, 1:08 a.m.