tests/testthat/test-path_normalize.R

# How a database path becomes the key the driver cache is kept under.
# See handbook/usage/connections/README.md.

test_that("an in-memory database normalizes to itself", {
  expect_equal(path_normalize(""), ":memory:")
  expect_equal(path_normalize(":memory:"), ":memory:")
})

test_that("an existing database file normalizes to its resolved path", {
  path <- file.path(withr::local_tempdir(), "db.duckdb")
  file.create(path)

  expect_same_path(path_normalize(path), normalizePath(path))
})

test_that("a database file yet to be created gets the key it will keep", {
  path <- file.path(withr::local_tempdir(), "db.duckdb")

  before <- path_normalize(path)
  # Nothing is created to arrive at that answer.
  expect_false(file.exists(path))

  file.create(path)
  expect_equal(path_normalize(path), before)
})

test_that("spellings of one database collapse to one key", {
  dir <- withr::local_tempdir()
  path <- file.path(dir, "db.duckdb")
  expected <- path_normalize(path)

  expect_equal(
    path_normalize(file.path(dir, "..", basename(dir), "db.duckdb")),
    expected
  )
  expect_equal(path_normalize(paste0(dir, "//db.duckdb")), expected)

  withr::local_dir(dir)
  expect_equal(path_normalize("db.duckdb"), expected)
})

test_that("a symlinked database resolves to its target", {
  dir <- withr::local_tempdir()
  target <- file.path(dir, "real.duckdb")
  file.create(target)
  link <- file.path(dir, "link.duckdb")
  skip_if_not(
    suppressWarnings(file.symlink(target, link)),
    "symlinks unavailable"
  )

  expect_equal(path_normalize(link), path_normalize(target))
})

test_that("a symlink to a database yet to be created shares one instance", {
  # The link resolves only once the engine has created its target, so the key
  # taken before the open is not the one every later call computes.
  # Symlinks are out of scope on Windows, where creating one needs admin rights
  # or Developer Mode, and the engine does not open a link to a missing target.
  # See handbook/usage/connections/README.md.
  skip_on_os("windows")
  dir <- withr::local_tempdir()
  target <- file.path(dir, "target.duckdb")
  link <- file.path(dir, "link.duckdb")
  skip_if_not(
    suppressWarnings(file.symlink(target, link)),
    "symlinks unavailable"
  )

  drv1 <- duckdb(link)
  withr::defer(duckdb_shutdown(drv1))
  expect_equal(drv1@dbdir, path_normalize(target))

  drv2 <- duckdb(link)
  expect_identical(drv2@database_ref, drv1@database_ref)
  # The same spelling reaches the same driver through `dbConnect()` as well.
  con <- dbConnect(drv1, dbdir = link)
  withr::defer(dbDisconnect(con))
  expect_identical(con@driver@database_ref, drv1@database_ref)
})

test_that("a lower-case drive letter resolves on that drive, not in the working directory", {
  # The engine walks up to the drive and turns it into its root, and a bare
  # `c:` is the working directory on that drive rather than its root. Only a
  # Windows engine knows drive letters: elsewhere `c:` is a relative name.
  # patch/0043-Accept-a-lower-case-drive-letter-in-Windows-paths.patch
  skip_if_not(.Platform$OS.type == "windows", "drive letters are Windows-only")
  dir <- withr::local_tempdir()
  withr::local_dir(dir)
  drive <- substr(normalizePath(dir), 1, 1)
  missing <- basename(tempfile("no-such-directory-"))

  expect_equal(
    path_normalize(paste0(tolower(drive), ":/", missing, "/db.duckdb")),
    paste0(toupper(drive), ":\\", missing, "\\db.duckdb")
  )
})

test_that("a path that cannot be resolved is returned rather than refused (#455)", {
  # Resolving a path can fail for reasons that say nothing about whether the
  # database is usable (on a network drive, for a directory the user may
  # traverse but not list). A path that resolves no further is absolute and
  # usable, which is all the driver cache needs.
  path <- file.path(withr::local_tempdir(), "no-such-directory", "db.duckdb")

  expect_no_error(out <- path_normalize(path))
  expect_true(nzchar(out))
  expect_false(file.exists(path))
})

test_that("a relative path with no working directory fails as a `duckdb_error`", {
  # The one thing the engine throws on: a working directory removed from under
  # the session leaves a relative path nothing to resolve against.
  dir <- withr::local_tempdir()
  withr::local_dir(dir)
  unlink(dir, recursive = TRUE)
  # Windows refuses to remove a directory that is in use.
  skip_if(dir.exists(dir), "the working directory could not be removed")

  err <- expect_error(path_normalize("db.duckdb"), class = "duckdb_error")
  # Routed through the engine's error data, not its serialized form.
  expect_no_match(conditionMessage(err), "exception_type", fixed = TRUE)
})

test_that("a database in a directory that does not exist fails in `duckdb()`, naming the path", {
  # Normalizing no longer creates anything, so nothing fails there: the engine
  # refuses the open instead, and caches nothing.
  path <- file.path(withr::local_tempdir(), "no-such-directory", "db.duckdb")
  # Called from a function of its own, so that an error naming the caller of
  # `duckdb()` rather than `duckdb()` itself would show.
  open <- function() duckdb(dbdir = path)

  err <- expect_error(open(), "no-such-directory", fixed = TRUE)
  expect_identical(conditionCall(err)[[1]], quote(duckdb))
  # The engine's message as it reads, not its serialized form.
  expect_identical(err$error_type, "IO")
  expect_no_match(conditionMessage(err), "exception_type", fixed = TRUE)
  expect_null(driver_registry[[path_normalize(path)]])
})

test_that("a database in a directory that does not exist fails in `dbConnect()`, naming it", {
  # `dbConnect()` creates the instance for a `dbdir` of its own through `duckdb()`,
  # called under the name `dbConnect`, so the error names the user's call.
  path <- file.path(withr::local_tempdir(), "no-such-directory", "db.duckdb")
  open <- function() dbConnect(duckdb(), dbdir = path)

  err <- expect_error(open(), "no-such-directory", fixed = TRUE)
  expect_identical(conditionCall(err)[[1]], quote(dbConnect))
  expect_null(driver_registry[[path_normalize(path)]])
})

test_that("two spellings of one database share an instance", {
  # Not a reuse optimization: the engine does not refuse a second read-write
  # instance on a file this process already holds, and two of them diverge
  # silently. The registry is what prevents that, and it can only do so if the
  # key unifies the spellings. See handbook/usage/connections/README.md.
  dir <- withr::local_tempdir()
  path <- file.path(dir, "db.duckdb")

  drv1 <- duckdb(path)
  # `defer()` unwinds last-in-first-out, so every connection is closed before
  # the instance they belong to is shut down.
  withr::defer(duckdb_shutdown(drv1))
  con1 <- dbConnect(drv1)
  withr::defer(dbDisconnect(con1))
  dbWriteTable(con1, "x", data.frame(a = 1L))

  drv2 <- duckdb(file.path(dir, "..", basename(dir), "db.duckdb"))
  expect_identical(drv2@database_ref, drv1@database_ref)

  con2 <- dbConnect(drv2)
  withr::defer(dbDisconnect(con2))
  # A second instance would not see a write made through the first.
  expect_equal(dbReadTable(con2, "x"), data.frame(a = 1L))
})

Try the duckdb package in your browser

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

duckdb documentation built on Oct. 23, 2026, 5:06 p.m.