diff --git a/.Rbuildignore b/.Rbuildignore
index 293d000..48144d0 100644
--- a/.Rbuildignore
+++ b/.Rbuildignore
@@ -13,3 +13,4 @@
^README\.Rmd$
^doc$
^Meta$
+^dev$
diff --git a/DESCRIPTION b/DESCRIPTION
index f8fa1f1..d89754d 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -1,6 +1,6 @@
Package: datadiff
Title: Data Validation Based on 'YAML' Rules
-Version: 0.4.9.9000
+Version: 0.6.0
Authors@R: c(
person("Vincent", "Guyader", , "vincent@thinkr.fr", role = c("cre", "aut"),
comment = c(ORCID = "0000-0003-0671-9270")),
@@ -18,7 +18,6 @@ Imports:
dplyr (>= 1.0.0),
pointblank (>= 0.12.3),
rlang (>= 0.4.0),
- stats,
tidyselect (>= 1.0.0),
utils,
yaml (>= 2.2.0)
diff --git a/NAMESPACE b/NAMESPACE
index 7cf9d48..678b00e 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -1,8 +1,10 @@
# Generated by roxygen2: do not edit by hand
+S3method("$",datadiff_result)
+S3method("[[",datadiff_result)
S3method(print,datadiff_coverage)
S3method(print,datadiff_report)
-export("%||%")
+S3method(print,datadiff_result)
export(add_tolerance_columns)
export(analyze_columns)
export(compare_datasets_from_yaml)
@@ -16,8 +18,6 @@ export(setup_pointblank_agent)
export(validate_row_counts)
export(write_rules_template)
importFrom(dplyr,"%>%")
-importFrom(dplyr,across)
-importFrom(dplyr,arrange)
importFrom(dplyr,collect)
importFrom(dplyr,left_join)
importFrom(pointblank,action_levels)
@@ -26,7 +26,7 @@ importFrom(pointblank,col_vals_equal)
importFrom(pointblank,create_agent)
importFrom(pointblank,interrogate)
importFrom(rlang,":=")
-importFrom(stats,setNames)
+importFrom(rlang,.data)
importFrom(tidyselect,all_of)
importFrom(utils,modifyList)
importFrom(yaml,read_yaml)
diff --git a/NEWS.md b/NEWS.md
index c5e51a7..1408064 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,7 +1,208 @@
-# datadiff (development version)
+# datadiff 0.6.0
+
+## New features
+
+* `datadiff_report_html()` gains an `extracts_dir` argument: the failing-row
+ extracts are also written as plain CSV files (one per failing step). The
+ CSV buttons inside the HTML report are `data:` URI downloads, which some
+ viewers block silently (Positron / Posit Workbench webview): files on disk
+ are the robust alternative (issue #10).
+
+## Performance
+
+* The lazy verdict no longer loads the boolean table into R: the per-column
+ (n, n_failed) counts come from ONE SQL aggregate scan over the computed
+ slim table (`SUM(CASE WHEN ok THEN 0 ELSE 1 END)`, counting FALSE and NULL
+ alike, exactly the local reducers' semantics), and only the FAILING
+ columns' booleans are collected for the pointblank agent. A green lazy
+ comparison previously collected N x columns logicals (~2 GB of R memory
+ for 4M rows x 125 columns, 32x the figure the code comment promised); it
+ now keeps O(columns) in R, and `res$response` carries a constant-size
+ placeholder instead of N collected rows (issue #22).
+
+* Four avoidable scans and transfers are gone from the lazy path (issue #24):
+ the two systematic `COUNT(*)` full scans are skipped when nothing consumes
+ them (keyed comparison with `check_count: false`; `validate_row_counts()`
+ gains a `count_rows` parameter, default unchanged); the 0-row schema is
+ collected once per table and reused by every consumer including the
+ auto-generated template (one DB roundtrip saved when `path = NULL`); the
+ keyed join only carries the key and the compared columns, so ignored and
+ one-sided columns no longer travel through the join into the agent (the
+ failing-row extracts consequently no longer show ignored columns); and the
+ lazy duplicate-key detection aggregates in SQL, transferring 2 scalars plus
+ at most 3 example groups instead of every duplicated group.
+
+* The verdict, the coverage and the failing-column sets now come from a
+ SINGLE pass over the boolean validation columns: `build_coverage()` runs
+ first and everything else derives from it (its rows already carry the
+ structural checks). Previously the same booleans were scanned twice on a
+ green comparison and three times on a red one, and each scan recomputed the
+ local equality booleans from scratch. The now-unused scanners
+ (`all_validations_pass()`, `failing_columns()`, the `*_col_passes()`
+ predicates) are removed. Verdicts are byte-identical (equivalence guard
+ green); measured ~6% end to end on a green 200-column x 200k comparison
+ (4.35 s to 4.07 s), the join and preprocessing dominating the rest
+ (issue #21).
+
+* `compute_tolerance_ok()` gains an intermediate fast path for the most common
+ real-data case: a column with NA but no infinity no longer falls back to the
+ full special-value kernel. NaN needs no dedicated handling there (`is.na()`
+ covers NaN, so NaN follows the NA rules identically on both paths); only
+ infinities reroute to the kernel. Bit-identical results, locked by tests;
+ measured ~2.6x faster per NA-bearing column (200k rows, 0.008 s vs 0.020 s).
+ The full kernel itself drops its redundant NaN masks (`both_nan`/`one_nan`
+ are subsets of the NA masks), with unchanged results (issue #23).
+
+## Robustness
+
+* Report construction hardening (all internal to `R/report.R`):
+ `datadiff_report_html()` now shares the `print()` memoization (it reads the
+ cached report and feeds the cache, instead of rebuilding agent + report on
+ every call); a genuine evaluation error on the real agent (`eval_error`,
+ where `n_failed` is NA) is propagated to the report instead of being
+ silently replaced by the synthetic all-pass branch and forced to FALSE; a
+ dead per-row count-injection block was removed (its fields were entirely
+ overwritten by the vectorised coverage block); and the `__ok`/`__eq`/
+ `__missing_col_`/`__type_mismatch_` naming conventions plus the default
+ warn/stop levels are now defined once in `R/constants.R` and shared by the
+ producers, the verdict consumers and the report mapping, so a one-sided
+ rename can no longer silently break the step mapping (issue #29).
+
+* `arrow_dataset_to_duckdb()` builds its SQL safely: Parquet file paths are
+ quoted with `DBI::dbQuoteString()` (a path containing a quote, common in
+ French like `l'export/`, broke the query with a raw SQL syntax error), the
+ temp table name goes through `dbQuoteIdentifier()`, and `read_parquet()`
+ gets `union_by_name = true` so multi-file datasets bind columns by NAME
+ like the `arrow::to_duckdb()` fallback always did. `duckdb_memory_limit`
+ is validated as a size literal before being interpolated into `SET`, and
+ the `SET temp_directory` path is quoted too (issue #30).
+
+* A missing `__ok`/`__eq` boolean validation column now raises an explicit
+ internal error instead of silently reading as an all-pass (`all(NULL)` is
+ `TRUE`, and a dropped column previously produced a PASS row with `n = 0` in
+ the coverage: the worst failure mode for a non-regression tool). The lazy
+ slim-table projection uses `all_of()` (loud) instead of `any_of()` (silent
+ drop); posing that guard immediately surfaced a latent phantom name in the
+ projection (`paste0(character(0), "__ok")` yields `"__ok"`) that `any_of()`
+ had been eating since 0.4.8 (issue #17).
+
+## Chores
+
+* Test-suite cleanup (issue #31): the byte-for-byte duplicated
+ "uses key parameter over YAML rules" block is gone; every test that wrote
+ a fixed-name YAML into the working directory now uses `tempfile()` +
+ `on.exit(unlink())` (CRAN policy, and what NEWS 0.4.4 already claimed);
+ the orphan committed fixtures `tests/testthat/rules.yaml` and `test.yaml`
+ (referenced by no test) are removed; the unrunnable
+ `test-huge-parquet.R` (skip-everywhere + hardcoded Windows paths) moves to
+ `dev/manual-tests/`; the silent conditional assertions of
+ `test-extraction-params.R` (`if (length(extracts) > 0) expect_...`) assert
+ their precondition first, so a cap check can no longer pass vacuously; and
+ the small internal helpers (`format_key_examples()`, `get_col_names()`,
+ `is_non_local()`/`is_arrow()`, the `file = NULL` branch of
+ `datadiff_report_html()`) get direct unit tests. The larger redundancy
+ compression the issue sketches (IEEE 754 in 7 copies, duplicate-key tests
+ in 3 files) is deliberately left for a dedicated pass: it is high-churn
+ refactoring of green tests.
+
+* Packaging and code cleanup (issue #34): `dev/` is excluded from the tarball
+ (`.Rbuildignore`); the orphan `inst/templates/rules_template.yaml`
+ (referenced nowhere) is gone; unused `@importFrom` entries dropped
+ (`dplyr::arrange`/`across`, `stats::setNames` replaced by a base
+ construction); the `numeric_abs` default reads `1e-9` instead of a
+ 9-zero decimal literal; `is_non_local()` reuses `is_arrow()` instead of
+ duplicating the Arrow class list; `abs(ref_vals)` and the IEEE fp constant
+ are hoisted out of the kernels' expressions and loops;
+ `tol_col_counts()` counts failures without allocating intermediate
+ vectors; `format_key_examples()` truncates to 3 groups before formatting;
+ the lazy `preprocess_dataframe()` composes ONE `mutate()` for all
+ normalized columns instead of one or two layers per column (the dbplyr
+ query-construction anti-pattern eliminated elsewhere in 0.4.8); loop
+ variables no longer shadow `base::c`; the tolerance kernel documents the
+ integer-overflow caveat. Deliberately not changed: `print()` of a report
+ builds the gt object in non-interactive sessions too (printing IS the
+ render), and the `DBI` availability guard stays transitive via `duckdb`.
+
+## Documentation and input friction
+
+* Doc-vs-code drift resolved (issue #32): `@return` of
+ `compare_datasets_from_yaml()` now lists all 8 elements including
+ `all_passed` (the first one) and describes `agent` truthfully (configured,
+ NOT interrogated - `response` is); the README Quick Start shows the
+ zero-configuration mode first and passes `key` explicitly everywhere; the
+ README "Dev part" no longer embeds check/coverage outputs that go stale
+ (it froze results from 0.4.2); the vignette documents the local-only
+ restriction of the `__absdiff`/`__thresh` extract diagnostics and the
+ DuckDB-only NaN caveat of the lazy booleans.
+
+* `read_rules()` speaks to the person who edits the YAML by hand: an
+ unsupported `version` gets an explicit error naming the file, the declared
+ and the supported versions (was a raw `stopifnot` output), and unknown
+ top-level or `defaults` fields (typos like `by_nmae:` or `no_equal:`) get a
+ warning naming them and the known fields, instead of being silently
+ ignored. `write_rules_template()` rejects a `version` other than 1 upfront
+ (it happily wrote version 2 templates that `read_rules()` then refused).
## Bug fixes
+* Three NEWS-vs-code contradictions introduced by the "Perf (#1)" commit after
+ the 0.4.4 release are resolved in the direction 0.4.4 had announced or the
+ docs now tell the truth: `%||%` is internal again (exporting it masked
+ rlang's and base R's own operator at load time; the test that locked the
+ accidental re-export now locks the opposite), the default rules-template
+ label prefix is "comparison" (was still the French "comparaison"), and the
+ vignette states the real report language default (`lang = "fr"`, it claimed
+ English). The three exported helpers that no user-facing doc mentioned
+ (`normalize_text()`, `validate_row_counts()`, `setup_pointblank_agent()`)
+ are now documented in the vignette's utility-functions section with real
+ use cases (issue #26).
+
+* `setup_pointblank_agent()` cleanup: the `cols_reference` argument was never
+ read (verified by grep since its introduction) and is now deprecated
+ (warning when supplied, removal planned); the local equality steps validate
+ a derived `
__eq` boolean with the shared NA semantics instead of
+ embedding the whole reference vector in each step (O(rows) per step,
+ serialised with the report, and unable to express the one-sided/two-sided
+ NA distinction); the roxygen example is now executable and representative
+ (it used to target a `__ok` column that did not exist); `get_col_names()`
+ is hoisted out of the per-column loop; the dead `cols_reference`/
+ `cols_candidate` locals of the caller are gone (issue #25).
+
+* `equal_mode: normalized` now does what the vignette always said: it implies
+ `case_insensitive = TRUE` and `trim = TRUE` for character columns unless
+ those flags are set explicitly (an explicit value wins over the mode). It
+ previously activated a branch that applied the identity transformation: a
+ user writing `equal_mode: normalized` alone got nothing, silently, and a
+ test even locked that inertia. `write_rules_template()` stops co-writing
+ the default FALSE flags next to a "normalized" mode (they neutralised the
+ implication). Note: a hand-written YAML using `equal_mode: normalized`
+ without explicit flags now normalizes where it previously compared exact,
+ so verdicts on such configs can flip from FAIL to PASS; re-run rather than
+ assume stability. The `date`/`datetime`/`logical` equal-mode parameters are
+ documented as accepted-but-inert for non-character types (text
+ normalization only touches character columns) (issue #27).
+
+* Factor columns are now compared as the character values they display:
+ preprocessing converts them, so the text normalization rules
+ (`case_insensitive`, `trim`) apply to them and the verdict no longer depends
+ on `stringsAsFactors` or haven-style imports. This also fixes an opaque
+ error when reference and candidate factors carried different level sets
+ ("level sets of factors are different"). Factor key columns join correctly
+ (issue #28).
+
+* The lazy SQL booleans now reproduce the R tolerance kernel's NaN/Inf
+ semantics: same-sign infinities pass, a one-sided NA/NaN/Inf fails, NaN on
+ both sides follows `na_equal`. The previous CASE WHEN only handled SQL NULL,
+ and NaN is a regular float in DuckDB (`NaN = NaN` is true, NaN sorts above
+ everything): Parquet data containing NaN or infinities could silently get
+ the opposite verdict on the lazy path (false PASS on one-sided NaN/Inf,
+ false FAIL on matching infinities). NaN detection uses `isnan()` on DuckDB;
+ backends without NaN storage (SQLite) keep the NULL rules, and infinity
+ detection falls back to a `> DBL_MAX` comparison there. Numeric equality
+ (non-tolerance) columns get the same NaN-aware NA rules, matching the local
+ path. The new equivalence tests use the R kernel as oracle on a full
+ NaN/Inf/NA grid, on DuckDB and SQLite (issue #13).
+
* Failing-row extracts again include the explicit measured deviations
(issue #11). Since the 0.4.8 performance work, `compare_datasets_from_yaml()`
only materialised the boolean `__ok` verdict columns, so
@@ -15,6 +216,95 @@
preserved. The lazy (SQL) path is unchanged: its extracts are built from the
slim boolean table and never carried these diagnostics.
+* The lazy path no longer leaks its internal temp table on a user-supplied
+ connection: the slim boolean table is dropped at the end of each call (the
+ Arrow path already closed its private connection). Its name is now derived
+ from a per-process counter plus the PID instead of the wall clock, removing
+ a collision window for two calls in the same millisecond (issue #19).
+
+* Lazy comparisons now work on tables containing a column named `n`. The
+ duplicate-key detection used `dplyr::count()` with its default output name:
+ with a key column named `n`, the filter and the aggregates silently read the
+ key values instead of the counts, producing a wrong duplicate diagnosis.
+ Both lazy helpers now use a reserved count name, and the
+ `globalVariables("n")` declaration that masked the pattern is gone
+ (issue #18).
+
+* Argument vs YAML resolution is now deterministic and documented: explicit
+ argument > YAML `defaults` > built-in default, for both `key` and `label`.
+ Previously the `label` argument was silently overwritten by the YAML label
+ (or the built-in default) whenever `path` was provided, and the YAML `keys`
+ field was only reached through `$` partial matching on a singular `key`
+ lookup, an accident waiting to break. `keys` is now read explicitly, with a
+ legacy singular `key` field honored as fallback; when both are present,
+ `keys` (the canonical field written by `write_rules_template()`) wins
+ (issue #20).
+
+* A positional (key-less) comparison of datasets with different row counts now
+ raises a single clear error built from `error_msg_no_key` and mentioning both
+ row counts. It previously emitted `error_msg_no_key` as an informational
+ message and then crashed on an opaque internal column assignment
+ ("replacement has X rows, data has Y"). The error is raised before any
+ materialisation of non-local tables (the counts are already known), and the
+ collect guard now covers both sides: a positional comparison mixing a local
+ reference with a lazy candidate (or vice versa) previously skipped the
+ candidate collection and failed downstream. On the valid positional path,
+ the reference columns are now appended with a single bind instead of a
+ per-column assignment loop (issue #15).
+
+## Breaking changes
+
+The baseline for these entries is datadiff 0.5.0, the version on CRAN
+(functionally identical to 0.4.9: 0.5.0 was a consolidation release with no
+behaviour change).
+
+* The comparison result now exposes the interrogated agent under `response`;
+ the historical French name `reponse` is deprecated. The result gains the
+ class `datadiff_result`, whose `$` and `[[` accessors keep `reponse`
+ readable (with a once-per-session warning) until its removal in a future
+ release. Code that iterates over `names(result)` or tests
+ `"reponse" %in% names(result)` must switch to `response` now;
+ `datadiff_report_html()` still accepts results saved by older versions.
+
+* `%||%` is no longer exported (its export, accidental since 0.4.5, masked
+ the operator from base R >= 4.4 and {rlang} at load time). Code calling
+ `datadiff::%||%` explicitly must switch to the base R or {rlang} operator;
+ code that merely had {datadiff} attached keeps working. See the
+ corresponding entry under "Bug fixes" (issue #26).
+
+* The local (data.frame) path now applies the same NA semantics as the lazy
+ path and the numeric tolerance kernel to **equality** columns: a one-sided
+ NA (a value facing a missing value, including candidate rows with no
+ reference match after the key join) is always a difference; a two-sided NA
+ follows `na_equal`. Previously, with `na_equal = TRUE` (the default), any NA
+ on either side passed on the local path (`na_pass` semantics), while the
+ same data failed on the lazy path: identical datasets could get opposite
+ verdicts depending on the backend. Comparisons that relied on one-sided NA
+ passing locally will now fail; the two-sided NA behavior is unchanged.
+ On the local failure path, `pointblank::get_data_extracts()` output for a
+ failing equality column now also carries its `__eq` boolean column
+ (as the lazy path always did) (issue #14).
+
+* `compare_datasets_from_yaml()` now raises an explicit error when one or more
+ key columns are absent from either dataset, naming the missing column(s) and
+ the dataset(s) concerned, on every code path (with or without an explicit
+ YAML rules file). It previously emitted a vague message
+ ("could not find key in both data") and returned a truncated 6-field list
+ (no `coverage`, no `summary`) that broke downstream consumers such as
+ `datadiff_report_html()` (issue #16).
+
+* `compare_datasets_from_yaml()` rejects an empty (`character(0)`) or
+ non-character `key` with a clear error. An empty key previously fell through
+ to a keyless cross join (deprecated dplyr behavior producing a cartesian
+ product).
+
+# datadiff 0.5.0
+
+* Release consolidating the 0.4.x maintenance series (wide-table performance,
+ lazy / Arrow / Parquet support, accurate HTML reports and IEEE 754 tolerance
+ handling). No user-facing behaviour changes since 0.4.9; see the entries
+ below for the details.
+
# datadiff 0.4.9
## Bug fixes
diff --git a/R/compare_datasets_from_yaml.R b/R/compare_datasets_from_yaml.R
index 832888c..1ec7c97 100644
--- a/R/compare_datasets_from_yaml.R
+++ b/R/compare_datasets_from_yaml.R
@@ -18,15 +18,21 @@
#' @param numeric_abs Default absolute tolerance for numeric columns
#' @param numeric_rel Default relative tolerance for numeric columns
#' @param integer_abs Default absolute tolerance for integer columns
-#' @param character_equal_mode Default comparison mode for character columns ("exact", "normalized")
+#' @param character_equal_mode Default comparison mode for character columns:
+#' "exact", or "normalized" which implies `case_insensitive = TRUE` and
+#' `trim = TRUE` unless those flags are set explicitly (an explicit value
+#' always wins over the mode)
#' @param character_case_insensitive Logical for case-insensitive character comparison
#' @param character_trim Logical for trimming whitespace in character comparison
-#' @param date_equal_mode Default comparison mode for date columns
+#' @param date_equal_mode Default comparison mode for date columns. Only
+#' "exact" has an effect: text normalization applies to character columns
+#' only, so "normalized" is accepted but changes nothing for this type.
#' @param datetime_equal_mode Default comparison mode for datetime columns
+#' (same caveat as `date_equal_mode`)
#' @param logical_equal_mode Default comparison mode for logical columns
+#' (same caveat as `date_equal_mode`)
#' @return The \code{path} to the written YAML file, returned invisibly.
#' @importFrom yaml write_yaml
-#' @importFrom stats setNames
#' @importFrom dplyr collect
#' @export
#' @examples
@@ -36,13 +42,20 @@ write_rules_template <- function(data_reference,
key = NULL, label = NULL, path = "rules.yaml", version = 1L, na_equal_default = TRUE,
ignore_columns_default = character(0),
check_count_default = TRUE, expected_count_default = NULL, row_count_tolerance_default = 0,
- numeric_abs = 0.000000001, numeric_rel = 0,
+ numeric_abs = 1e-9, numeric_rel = 0,
integer_abs = 0L,
character_equal_mode = "exact", character_case_insensitive = FALSE, character_trim = FALSE,
date_equal_mode = "exact",
datetime_equal_mode = "exact",
logical_equal_mode = "exact") {
+ if (!identical(suppressWarnings(as.numeric(version)), 1)) {
+ stop(sprintf(
+ "Parameter 'version' must be 1 (the only rules version read_rules() accepts), got %s.",
+ version
+ ), call. = FALSE)
+ }
+
# Validate key if provided
# Use a 0-row collect to retrieve column names and types: works for both local
# data.frames and lazy tables (tbl_lazy) without loading all rows.
@@ -62,10 +75,22 @@ write_rules_template <- function(data_reference,
)
}
}
- if (is.null(label) || label == "") {label <- paste("comparaison", deparse1(substitute(data_reference)))
-
+ validate_label(label)
+ if (is.null(label) || label == "") {
+ label <- paste("comparison", deparse1(substitute(data_reference)))
}
types <- detect_column_types(.ref_schema)
+ # With equal_mode "normalized", writing the default FALSE flags would
+ # neutralise the mode's implication (an explicit flag wins over the mode):
+ # only include the flags the caller actually supplied
+ character_rules <- list(equal_mode = character_equal_mode)
+ if (!identical(character_equal_mode, "normalized") ||
+ !missing(character_case_insensitive)) {
+ character_rules$case_insensitive <- character_case_insensitive
+ }
+ if (!identical(character_equal_mode, "normalized") || !missing(character_trim)) {
+ character_rules$trim <- character_trim
+ }
y <- list(
version = version,
defaults = list(na_equal = na_equal_default,
@@ -77,12 +102,12 @@ write_rules_template <- function(data_reference,
by_type = list(
numeric = list(abs = numeric_abs, rel = numeric_rel),
integer = list(abs = integer_abs),
- character = list(equal_mode = character_equal_mode, case_insensitive = character_case_insensitive, trim = character_trim),
+ character = character_rules,
date = list(equal_mode = date_equal_mode),
datetime = list(equal_mode = datetime_equal_mode),
logical = list(equal_mode = logical_equal_mode)
),
- by_name = setNames(replicate(length(types), list(), simplify = FALSE), names(types))
+ by_name = as.list(structure(replicate(length(types), list(), simplify = FALSE), names = names(types)))
)
write_yaml(x = y, file = path)
invisible(path)
@@ -103,7 +128,53 @@ write_rules_template <- function(data_reference,
#' @export
read_rules <- function(path) {
r <- read_yaml(path)
- stopifnot(is.list(r), !is.null(r$version), r$version == 1)
+ if (!is.list(r)) {
+ stop(sprintf("'%s' does not contain a YAML mapping of rules.", path),
+ call. = FALSE)
+ }
+ version_ok <- is.atomic(r$version) &&
+ length(r$version) == 1 &&
+ identical(suppressWarnings(as.numeric(r$version)), 1)
+ if (!version_ok) {
+ shown <- if (is.atomic(r$version) && length(r$version) == 1) {
+ as.character(r$version)
+ } else if (is.null(r$version)) {
+ ""
+ } else {
+ # lists / vectors from YAML like [1, 2] or {v: 1}: printable summary
+ paste(class(r$version)[1], "of length", length(r$version))
+ }
+ stop(sprintf(
+ "'%s' declares rules version %s; this version of {datadiff} supports version 1 only.",
+ path, shown
+ ), call. = FALSE)
+ }
+ # Hand-edited YAML again: a scalar where a mapping is expected
+ # (defaults: yes) would crash far away on a $ access; refuse it here
+ for (section in c("defaults", "by_type", "by_name", "row_validation")) {
+ if (!is.null(r[[section]]) && !is.list(r[[section]])) {
+ stop(sprintf(
+ "'%s': the '%s' section must be a YAML mapping (key: value lines), not a single value.",
+ path, section
+ ), call. = FALSE)
+ }
+ }
+ # A hand-edited YAML is the main input of this package: flag the fields the
+ # comparison will silently ignore (typos like by_nmae or no_equal)
+ known_top <- c("version", "defaults", "by_type", "by_name", "row_validation")
+ unknown_top <- setdiff(names(r), known_top)
+ known_defaults <- c("na_equal", "ignore_columns", "keys", "key", "label")
+ unknown_defaults <- setdiff(names(r$defaults %||% list()), known_defaults)
+ unknown <- c(unknown_top, unknown_defaults)
+ if (length(unknown) > 0) {
+ warning(sprintf(
+ "'%s' contains field(s) the comparison will ignore: %s. Check for typos (known top-level fields: %s; known defaults fields: %s).",
+ path,
+ paste(sprintf("'%s'", unknown), collapse = ", "),
+ paste(known_top, collapse = ", "),
+ paste(known_defaults, collapse = ", ")
+ ), call. = FALSE)
+ }
r$defaults <- r$defaults %||% list()
r$by_type <- r$by_type %||% list()
r$by_name <- r$by_name %||% list()
@@ -111,22 +182,138 @@ read_rules <- function(path) {
r
}
+#' Validate a report label argument
+#'
+#' A label must be NULL or a single non-NA character string: anything else
+#' would crash later on the scalar `if (label == "")` fallback checks.
+#'
+#' @param label The label value to validate.
+#' @return \code{NULL}, invisibly. Called for its side effect (error).
+#' @noRd
+validate_label <- function(label) {
+ if (is.null(label)) {
+ return(invisible(NULL))
+ }
+ if (!is.character(label) || length(label) != 1 || is.na(label)) {
+ stop(
+ "Parameter 'label' must be a single character string (or NULL).",
+ call. = FALSE
+ )
+ }
+ invisible(NULL)
+}
+
+#' Validate the duckdb_memory_limit argument
+#'
+#' The value is interpolated into a DuckDB SET statement: only a plain size
+#' literal (number plus optional unit) or a percentage is accepted.
+#'
+#' @param duckdb_memory_limit The value to validate.
+#' @return \code{NULL}, invisibly. Called for its side effect (error).
+#' @noRd
+validate_duckdb_memory_limit <- function(duckdb_memory_limit) {
+ ok <- is.character(duckdb_memory_limit) &&
+ length(duckdb_memory_limit) == 1 &&
+ !is.na(duckdb_memory_limit) &&
+ grepl("^\\s*[0-9]+(\\.[0-9]+)?\\s*(B|KB|MB|GB|TB|KiB|MiB|GiB|TiB|%)?\\s*$",
+ x = duckdb_memory_limit, ignore.case = TRUE)
+ if (!ok) {
+ stop(
+ "Parameter 'duckdb_memory_limit' must be a single size literal such as \"8GB\" (number plus optional unit or %).",
+ call. = FALSE
+ )
+ }
+ invisible(NULL)
+}
+
+#' Validate a comparison key against both datasets
+#'
+#' Shared guard for every code path that consumes a key: checks the type and
+#' emptiness of the key, then its presence in both datasets, and raises an
+#' explicit error naming the missing column(s) and the dataset(s) concerned.
+#'
+#' @param key Character vector of key column names.
+#' @param ref_cols Column names of the reference dataset.
+#' @param cand_cols Column names of the candidate dataset.
+#' @return \code{NULL}, invisibly. Called for its side effect (error).
+#' @noRd
+validate_comparison_key <- function(key, ref_cols, cand_cols) {
+ if (!is.character(key) || length(key) == 0) {
+ stop(
+ "Parameter 'key' must be a non-empty character vector specifying column name(s) to use as join key(s).",
+ call. = FALSE
+ )
+ }
+ missing_key_ref <- setdiff(key, ref_cols)
+ missing_key_cand <- setdiff(key, cand_cols)
+ if (length(missing_key_ref) > 0 || length(missing_key_cand) > 0) {
+ describe_missing <- function(cols, dataset_name) {
+ if (length(cols) == 0) {
+ return(NULL)
+ }
+ sprintf(
+ "%s missing in %s",
+ paste(sprintf("'%s'", cols), collapse = ", "),
+ dataset_name
+ )
+ }
+ details <- c(
+ describe_missing(missing_key_ref, dataset_name = "data_reference"),
+ describe_missing(missing_key_cand, dataset_name = "data_candidate")
+ )
+ stop(sprintf(
+ "Key column(s) not found: %s.",
+ paste(details, collapse = "; ")
+ ), call. = FALSE)
+ }
+ invisible(NULL)
+}
+
#' Compare datasets using YAML validation rules
#'
#' Main function for comparing reference and candidate datasets using configurable
#' validation rules defined in a YAML file. Supports exact matching, tolerance-based
#' comparisons, text normalization, and row count validation.
#'
+#' @section Argument vs YAML precedence:
+#' Some settings can come both from an explicit argument and from the YAML
+#' rules file. The resolution is always: explicit argument first, then the
+#' YAML `defaults` section, then the built-in default.
+#'
+#' | Setting | Explicit argument | YAML `defaults` field | Built-in default |
+#' |---|---|---|---|
+#' | join key | `key` | `keys` (legacy alias: `key`) | none (positional) |
+#' | report label | `label` | `label` | "Comparing candidate vs reference" |
+#'
+#' When the YAML contains both `keys` and the legacy singular `key` field,
+#' `keys` (the canonical field written by [write_rules_template()]) wins.
+#'
+#' The `key` argument must be a character vector (a non-character value is an
+#' error), while YAML-sourced key values are coerced to character (YAML being
+#' stringly typed, `keys: [2024]` designates the column named "2024").
+#'
+#' Note for `path = NULL`: the auto-generated rules template itself carries a
+#' label (`"Comparison with default rules"`, or the explicit `label` argument),
+#' so that is the label effectively used without a YAML file; the built-in
+#' default above only applies when a YAML file is supplied with an empty or
+#' missing `defaults$label`.
+#'
#' @param data_reference Reference dataframe, tibble, or lazy table (tbl_lazy)
#' @param data_candidate Candidate dataframe to validate against reference
-#' @param key Optional character vector of column names to use as join keys for ordered comparison
+#' @param key Optional character vector of column names to use as join keys for
+#' ordered comparison. Every key column must exist in both datasets; otherwise
+#' an error is raised naming the missing column(s) and the dataset(s) concerned.
+#' See the "Argument vs YAML precedence" section.
#' @param path Path to YAML file containing validation rules. If NULL, default rules are
#' generated automatically based on the reference dataset structure.
#' @param warn_at Warning threshold as fraction of failing tests (default: 1e-14)
#' @param stop_at Stop threshold as fraction of failing tests (default: 1e-14)
#' @param ref_suffix Suffix for reference columns in comparison dataframe (default: "__reference")
-#' @param label Descriptive label for the validation report
-#' @param error_msg_no_key Error message when datasets have different row counts without keys
+#' @param label Descriptive label for the validation report. See the
+#' "Argument vs YAML precedence" section.
+#' @param error_msg_no_key Text of the error raised when a positional (key-less)
+#' comparison receives datasets with different row counts; the actual row
+#' counts of both datasets are appended to this text.
#' @param lang Language code for pointblank reports. Defaults to the
#' \code{datadiff.lang} option if set, otherwise \code{"fr"}. Override globally
#' with \code{options(datadiff.lang = "en")}. Supported values include
@@ -152,14 +339,23 @@ read_rules <- function(path) {
#' may use before spilling intermediate results to `tempdir()`. The default leaves
#' headroom for R, Arrow, and the OS alongside DuckDB. Raise it (e.g. `"16GB"`)
#' on machines with ample free RAM to reduce disk I/O; lower it (e.g. `"4GB"`)
-#' when memory is very constrained. Has no effect when both inputs are plain
-#' `data.frame`s or `tbl_lazy` objects.
-#' @return A list containing:
-#' \item{agent}{Configured pointblank agent with validation results}
-#' \item{reponse}{Interrogated pointblank agent (class \code{datadiff_report}):
+#' when memory is very constrained. The value is always validated as a size
+#' literal (an invalid one is an error on every path), but it only takes
+#' effect when Arrow datasets are used: plain `data.frame`s or `tbl_lazy`
+#' inputs ignore a valid value.
+#' @return A list of class \code{datadiff_result} containing:
+#' \item{all_passed}{Logical; \code{TRUE} when every check passed. The
+#' first element of the returned list and the single verdict consumers
+#' should read.}
+#' \item{agent}{The configured pointblank agent as built (NOT interrogated:
+#' the verdict lives in \code{response}, which is the interrogated one)}
+#' \item{response}{Interrogated pointblank agent (class \code{datadiff_report}):
#' usable by \code{pointblank::all_passed()} / \code{get_data_extracts()};
#' printing it lazily renders a full pointblank-style report from
-#' \code{coverage} (built on demand, memoized).}
+#' \code{coverage} (built on demand, memoized). Reading it under its
+#' historical name \code{reponse} still works but is deprecated and
+#' emits a warning (once per session); the alias will be removed in a
+#' future release.}
#' \item{missing_in_candidate}{Columns missing in candidate data}
#' \item{extra_in_candidate}{Extra columns in candidate data}
#' \item{applied_rules}{Final column-specific rules applied}
@@ -169,7 +365,7 @@ read_rules <- function(path) {
#' the fast path skips the per-column agent.}
#' \item{summary}{Aggregate counts from \code{coverage} (n_checks, n_pass,
#' n_fail, n_rows_failed_total, all_passed).}
-#' @importFrom dplyr arrange across left_join %>%
+#' @importFrom dplyr left_join %>%
#' @importFrom pointblank interrogate
#' @importFrom dplyr collect
#' @export
@@ -188,12 +384,12 @@ read_rules <- function(path) {
#' tmp <- tempfile(fileext = ".yaml")
#' write_rules_template(ref, key = "id", path = tmp)
#' result <- compare_datasets_from_yaml(ref, cand, key = "id", path = tmp)
-#' result$reponse
+#' result$response
compare_datasets_from_yaml <- function(data_reference,
data_candidate,
key = NULL,
path = NULL,
- warn_at = 0.00000000000001, stop_at = 0.00000000000001,
+ warn_at = 1e-14, stop_at = 1e-14,
ref_suffix = "__reference",
label = NULL,
error_msg_no_key = "Without keys, both tables must have the same number of rows.",
@@ -214,6 +410,8 @@ compare_datasets_from_yaml <- function(data_reference,
if (!inherits(data_candidate, valid_classes)) {
stop("data_candidate must be a data.frame, tibble, lazy table, or Arrow object")
}
+ validate_label(label)
+ validate_duckdb_memory_limit(duckdb_memory_limit)
# Guard: ensure dbplyr is available when lazy tables are used
if (inherits(data_reference, "tbl_lazy") || inherits(data_candidate, "tbl_lazy")) {
@@ -252,23 +450,55 @@ compare_datasets_from_yaml <- function(data_reference,
on.exit(duckdb::dbDisconnect(fresh_con, shutdown = TRUE), add = TRUE)
# Enable disk-spilling.
DBI::dbExecute(fresh_con, paste0(
- "SET temp_directory='", gsub("\\\\", "/", tempdir()), "'"
+ "SET temp_directory = ",
+ as.character(DBI::dbQuoteString(fresh_con, x = gsub("\\\\", "/", tempdir())))
))
# Cap DuckDB's buffer pool so it starts spilling well before exhausting
# system RAM. The default (80 % of total RAM) leaves no headroom for
# R, Arrow, and OS memory. Configurable via duckdb_memory_limit.
- DBI::dbExecute(fresh_con, paste0("SET memory_limit = '", duckdb_memory_limit, "'"))
+ DBI::dbExecute(fresh_con, paste0(
+ "SET memory_limit = ",
+ as.character(DBI::dbQuoteString(fresh_con, x = duckdb_memory_limit))
+ ))
if (is_arrow(data_reference))
data_reference <- arrow_dataset_to_duckdb(data_reference, fresh_con, "datadiff_ref")
if (is_arrow(data_candidate))
data_candidate <- arrow_dataset_to_duckdb(data_candidate, fresh_con, "datadiff_cand")
}
- # If no path provided, create a temporary YAML with default rules
+ # Collect 0-row schemas ONCE for type detection and every consumer below
+ # (key validation, auto-template generation, suffix checks): each collect is
+ # a database roundtrip on lazy inputs.
+ schema_ref <- dplyr::collect(utils::head(data_reference, 0L))
+ schema_cand <- dplyr::collect(utils::head(data_candidate, 0L))
+
+ # Validate the key argument against BOTH datasets before any other consumer:
+ # the auto-generated-template path (path = NULL) would otherwise reach
+ # write_rules_template() first, which validates the reference only and with
+ # a less specific error.
+ if (!is.null(key)) {
+ validate_comparison_key(
+ key,
+ ref_cols = names(schema_ref),
+ cand_cols = names(schema_cand)
+ )
+ }
+
+ # An empty string means "no explicit label": normalised BEFORE the
+ # auto-template call, so that "" falls through to the template default
+ # instead of triggering write_rules_template's deparse fallback (which
+ # would leak the internal variable name into the report label).
+ if (!is.null(label) && label == "") {
+ label <- NULL
+ }
+
+ # If no path provided, create a temporary YAML with default rules. The 0-row
+ # schema stands in for the reference: write_rules_template() only reads
+ # column names and types, and this avoids a second schema roundtrip.
if (is.null(path)) {
path <- tempfile(fileext = ".yaml")
write_rules_template(
- data_reference = data_reference,
+ data_reference = schema_ref,
key = key,
label = label %||% "Comparison with default rules",
path = path
@@ -283,8 +513,8 @@ compare_datasets_from_yaml <- function(data_reference,
}
# Check for reserved suffix conflicts in column names
- ref_cols_with_suffix <- grep(ref_suffix, get_col_names(data_reference), fixed = TRUE, value = TRUE)
- cand_cols_with_suffix <- grep(ref_suffix, get_col_names(data_candidate), fixed = TRUE, value = TRUE)
+ ref_cols_with_suffix <- grep(ref_suffix, names(schema_ref), fixed = TRUE, value = TRUE)
+ cand_cols_with_suffix <- grep(ref_suffix, names(schema_cand), fixed = TRUE, value = TRUE)
if (length(ref_cols_with_suffix) > 0 || length(cand_cols_with_suffix) > 0) {
conflicting <- unique(c(ref_cols_with_suffix, cand_cols_with_suffix))
warning(sprintf(
@@ -295,21 +525,29 @@ compare_datasets_from_yaml <- function(data_reference,
rules <- read_rules(path)
- # Collect 0-row schemas for type detection and type-dependent logic.
- schema_ref <- dplyr::collect(utils::head(data_reference, 0L))
- schema_cand <- dplyr::collect(utils::head(data_candidate, 0L))
-
na_equal <- isTRUE(rules$defaults$na_equal)
ignore_columns <- rules$defaults$ignore_columns %||% character(0)
- label <- rules$defaults$label
- if (is.null(label) || label == "") {label <- "Comparing candidate vs reference"}
- # Use key parameter if provided, otherwise fall back to rules
- if (is.null(key)) {
- key <- rules$defaults$key
+ # Precedence: explicit argument > YAML defaults > built-in default.
+ # An empty string means "no explicit label" (same convention as
+ # write_rules_template), so it must not shadow the YAML label.
+ if (!is.null(label) && label == "") {
+ label <- NULL
+ }
+ label <- label %||% rules$defaults[["label"]]
+ if (is.null(label) || label == "") {
+ label <- "Comparing candidate vs reference"
}
- if (is.null(key)) {message("key is missing")}
+ # Precedence: explicit argument > YAML defaults > none. The canonical YAML
+ # field is "keys" (what write_rules_template() writes); a legacy singular
+ # "key" field is honored as fallback. [[ avoids $ partial matching.
+ if (is.null(key)) {
+ key <- rules$defaults[["keys"]] %||% rules$defaults[["key"]]
+ if (!is.null(key)) {
+ key <- as.character(unlist(key, use.names = FALSE))
+ }
+ }
# Check for duplicate keys (only if key exists in both datasets)
if (!is.null(key) && all(key %in% get_col_names(data_reference)) && all(key %in% get_col_names(data_candidate))) {
@@ -325,18 +563,20 @@ compare_datasets_from_yaml <- function(data_reference,
# Build detailed warning message
warning_parts <- c()
+ # %.0f: the lazy counts are whole doubles from SQL aggregates, and
+ # sprintf %d errors on any whole double at or above 2^31
if (ref_has_dups) {
warning_parts <- c(warning_parts, sprintf(
- "data_reference: %d duplicate key value(s) affecting %d rows (examples: %s)",
- ref_dup_info$n_dup_keys, ref_dup_info$n_dup_rows,
+ "data_reference: %.0f duplicate key value(s) affecting %.0f rows (examples: %s)",
+ as.numeric(ref_dup_info$n_dup_keys), as.numeric(ref_dup_info$n_dup_rows),
paste(ref_dup_info$examples, collapse = "; ")
))
}
if (cand_has_dups) {
warning_parts <- c(warning_parts, sprintf(
- "data_candidate: %d duplicate key value(s) affecting %d rows (examples: %s)",
- cand_dup_info$n_dup_keys, cand_dup_info$n_dup_rows,
+ "data_candidate: %.0f duplicate key value(s) affecting %.0f rows (examples: %s)",
+ as.numeric(cand_dup_info$n_dup_keys), as.numeric(cand_dup_info$n_dup_rows),
paste(cand_dup_info$examples, collapse = "; ")
))
}
@@ -357,8 +597,6 @@ compare_datasets_from_yaml <- function(data_reference,
# Analyze columns
col_analysis <- analyze_columns(data_reference, data_candidate, ignore_columns = ignore_columns)
- cols_reference <- col_analysis$cols_reference
- cols_candidate <- col_analysis$cols_candidate
missing_in_candidate <- col_analysis$missing_in_candidate
extra_in_candidate <- col_analysis$extra_in_candidate
common_cols <- col_analysis$common_cols
@@ -399,40 +637,62 @@ compare_datasets_from_yaml <- function(data_reference,
data_reference_p <- preprocess_dataframe(data_reference, col_rules, schema = schema_ref)
data_candidate_p <- preprocess_dataframe(data_candidate, col_rules, schema = schema_cand)
- # Get row validation information
- row_validation_info <- validate_row_counts(data_reference_p, data_candidate_p, rules)
+ # Get row validation information. The counts are only consumed by the
+ # row-count check and the positional path: on a keyed comparison with
+ # check_count off, skip the two COUNT(*) full scans entirely.
+ needs_counts <- isTRUE(rules$row_validation$check_count) || is.null(key)
+ row_validation_info <- validate_row_counts(
+ data_reference_p, data_candidate_p, rules,
+ count_rows = needs_counts
+ )
if (!is.null(key)) {
- if (isFALSE(all(key %in% get_col_names(data_reference_p))) | isFALSE(all(key %in% get_col_names(data_candidate_p)))) {
- message("could not find key in both data")
- return(
- list(
- all_passed = FALSE,
- agent = NULL,
- reponse = NULL,
- missing_in_candidate = NULL,
- extra_in_candidate = NULL,
- applied_rules = NULL
- )
- )
- }
+ # Re-validated here because the key may come from the YAML rules
+ # (defaults$keys), which the argument-level check upstream cannot see.
+ validate_comparison_key(
+ key,
+ ref_cols = get_col_names(data_reference_p),
+ cand_cols = get_col_names(data_candidate_p)
+ )
- # Join candidate to reference on key to handle different row counts
- cmp <- left_join(data_candidate_p, data_reference_p, by = key, suffix = c("", ref_suffix))
+ # Join candidate to reference on key to handle different row counts.
+ # Only the key and the compared columns enter the join: ignored and
+ # one-sided columns are never compared, and carrying them through the
+ # join and into the agent costs memory on wide tables.
+ keep_cols <- c(key, common_cols)
+ cmp <- left_join(
+ dplyr::select(data_candidate_p, dplyr::all_of(keep_cols)),
+ dplyr::select(data_reference_p, dplyr::all_of(keep_cols)),
+ by = key, suffix = c("", ref_suffix)
+ )
} else {
- # For non-keyed comparison, collect non-local tables (positional join requires local data)
- if (is_non_local(data_reference_p)) {
- message("Note: positional comparison requires collecting non-local tables into memory.")
- data_reference_p <- dplyr::collect(data_reference_p)
- data_candidate_p <- dplyr::collect(data_candidate_p)
- }
- # Use pre-computed counts from validate_row_counts (avoids a second nrow() on lazy)
+ # Row-count mismatch is decidable from the counts precomputed by
+ # validate_row_counts(): abort before the potentially expensive collect
+ # of non-local tables below.
if (row_validation_info$ref_count != row_validation_info$cand_count) {
- message(error_msg_no_key)
+ stop(sprintf(
+ "%s (data_reference: %s rows, data_candidate: %s rows).",
+ error_msg_no_key,
+ row_validation_info$ref_count,
+ row_validation_info$cand_count
+ ), call. = FALSE)
+ }
+ # For non-keyed comparison, collect non-local tables on BOTH sides
+ # (positional binding requires local data on each side).
+ if (is_non_local(data_reference_p) || is_non_local(data_candidate_p)) {
+ message("Note: positional comparison requires collecting non-local tables into memory.")
+ if (is_non_local(data_reference_p)) {
+ data_reference_p <- dplyr::collect(data_reference_p)
+ }
+ if (is_non_local(data_candidate_p)) {
+ data_candidate_p <- dplyr::collect(data_candidate_p)
+ }
}
cmp <- data_candidate_p
- for (c in common_cols) {
- cmp[[paste0(c, ref_suffix)]] <- data_reference_p[[c]]
+ if (length(common_cols) > 0) {
+ ref_block <- data_reference_p[, common_cols, drop = FALSE]
+ names(ref_block) <- paste0(common_cols, ref_suffix)
+ cmp <- dplyr::bind_cols(cmp, ref_block)
}
}
@@ -466,8 +726,15 @@ compare_datasets_from_yaml <- function(data_reference,
# construction + SQL rendering), the dominant cost on wide tables; the
# templated SQL is O(1) dbplyr work and lets the database do the rest.
cmp <- if (is_non_local(cmp)) {
- add_bool_cols_sql(cmp, tol_cols, eq_cols,
- col_rules, ref_suffix, na_equal)
+ # Numeric equality columns get NaN-aware NA rules in the SQL (matching
+ # the local path, where is.na(NaN) is TRUE); isnan() on a non-numeric
+ # column would be a SQL type error.
+ eq_num_cols <- eq_cols[vapply(X = eq_cols, FUN = function(nm) {
+ is.numeric(schema_ref[[nm]])
+ }, FUN.VALUE = logical(1))]
+ add_bool_cols_sql(cmp, tol_cols = tol_cols, eq_cols = eq_cols,
+ col_rules = col_rules, ref_suffix = ref_suffix,
+ na_equal = na_equal, eq_num_cols = eq_num_cols)
} else {
add_ok_columns(cmp, tol_cols, col_rules, ref_suffix, na_equal)
}
@@ -496,51 +763,93 @@ compare_datasets_from_yaml <- function(data_reference,
is_lazy <- is_non_local(cmp)
cmp_for_agent <- cmp
if (is_lazy) {
+ # The name helpers are length-guarded against the recycle0 phantom
+ # (paste0(character(0), "__ok") yields "__ok"); all_of() below then
+ # fails loudly instead of silently dropping a phantom name
val_cols <- c(
- paste0(tol_cols, "__ok"),
- paste0(eq_cols, "__eq"),
- if (isTRUE(row_validation_info$check_count)) "row_count_ok" else character(0)
+ datadiff_ok_col(tol_cols),
+ datadiff_eq_col(eq_cols),
+ if (isTRUE(row_validation_info$check_count)) {
+ "row_count_ok"
+ } else {
+ character(0)
+ }
)
- cmp_slim <- dplyr::select(cmp, dplyr::any_of(val_cols))
- tmp_tbl_name <- paste0("datadiff_", gsub("[^0-9]", "", format(Sys.time(), "%H%M%OS3")))
+ if (length(val_cols) == 0) {
+ # Nothing to compute or aggregate (no value checks, no row-count flag):
+ # the verdict rests on the structural coverage rows alone, and a
+ # zero-column CREATE TABLE AS SELECT is invalid SQL on some backends
+ lazy_counts <- list()
+ cmp_for_agent <- data.frame()
+ } else {
+ # all_of(): a validation column silently dropped here would later read as
+ # NULL by the boolean accessors, i.e. a false all-pass; fail loudly instead
+ cmp_slim <- dplyr::select(cmp, dplyr::all_of(val_cols))
+ tmp_tbl_name <- datadiff_tmp_table_name()
# compute() sends CREATE TEMP TABLE AS SELECT ... to DuckDB: all computation
# (join, boolean expressions) happens inside DuckDB's process, with disk
- # spilling available for the large join. We then collect() the slim boolean
- # result into R so that pointblank receives a plain data.frame - avoiding
- # DuckDB connection-state issues (is_tbl_mssql crash) during interrogation.
+ # spilling available for the large join. The verdict is then derived from
+ # an SQL aggregate scan of this table; only the FAILING boolean columns
+ # are later collected into R (red path) so that pointblank receives a
+ # plain data.frame - avoiding DuckDB connection-state issues
+ # (is_tbl_mssql crash) during interrogation.
cmp_slim_computed <- dplyr::compute(cmp_slim, name = tmp_tbl_name, temporary = TRUE)
- cmp_for_agent <- dplyr::collect(cmp_slim_computed)
- }
-
- # Fast all-pass short-circuit.
- # The verdict is fully determined by the boolean validation columns already
- # computed above (plus the structural checks). When everything passes there
- # are no cells to extract, so the expensive per-column pointblank agent (one
- # step per column, ~quadratic on wide tables) can be replaced by a constant
- # cost trivially-passing agent. all_passed stays identical and
- # get_data_extracts() is empty either way. Any failure falls through to the
- # full per-column agent so failing cells remain extractable byte-for-byte.
- all_passed_fast <-
- length(missing_in_candidate) == 0 &&
- length(type_mismatch_cols) == 0 &&
- isTRUE(row_count_ok) &&
- all_validations_pass(
- tbl = cmp_for_agent, tol_cols = tol_cols, eq_cols = eq_cols,
- ref_suffix = ref_suffix, na_equal = na_equal
+ # The slim table only feeds the aggregate scan and the failing-column
+ # collect below. Drop it at exit so that repeated calls on a user-supplied
+ # connection do not accumulate temp tables for the lifetime of that
+ # connection. after = FALSE runs the drop BEFORE the exit handlers
+ # registered earlier, in particular before the dbDisconnect of the private
+ # connection on the Arrow path (on.exit add = TRUE fires FIFO by default,
+ # which would drop on a closed connection); the try() then only masks
+ # genuine failures.
+ on.exit(
+ try(
+ DBI::dbRemoveTable(dbplyr::remote_con(cmp_slim_computed), tmp_tbl_name),
+ silent = TRUE
+ ),
+ add = TRUE, after = FALSE
+ )
+ # The verdict needs only (n, n_failed) per column: one aggregate scan in
+ # the database replaces the collect of N x columns booleans into R (the
+ # collect made the green verdict cost O(rows) of R memory, contradicting
+ # the documented promise). Only the FAILING columns are collected later,
+ # on the red path, for the pointblank agent.
+ lazy_counts <- lazy_boolean_counts(
+ cmp_slim_computed, tol_cols = tol_cols, eq_cols = eq_cols
)
+ cmp_for_agent <- data.frame()
+ }
+ }
- # Faithful, O(columns) record of every check performed, built from the same
- # booleans the verdict is derived from. Always produced (green and red) so the
- # caller can see what was verified even when the fast path skips the per-column
- # pointblank agent.
+ # Faithful, O(columns) record of every check performed, built from the
+ # boolean validation columns computed above. It is the SINGLE pass over the
+ # data: the verdict and the failing-column sets below both derive from it,
+ # so the same booleans are no longer scanned two or three times (and the
+ # local equality booleans no longer recomputed once per scan). On the lazy
+ # path the counts come from the SQL aggregate scan.
coverage <- build_coverage(
tbl = cmp_for_agent, tol_cols = tol_cols, eq_cols = eq_cols,
missing_in_candidate = missing_in_candidate,
type_mismatch_cols = type_mismatch_cols,
row_validation_info = row_validation_info, row_count_ok = row_count_ok,
- ref_suffix = ref_suffix, na_equal = na_equal
+ ref_suffix = ref_suffix, na_equal = na_equal,
+ counts = if (is_lazy) lazy_counts else NULL
)
+ # Fast all-pass short-circuit.
+ # The verdict is fully determined by the coverage, whose rows already
+ # include the structural checks (missing_column, type_mismatch, row_count).
+ # The row_count row only exists when check_count is TRUE; otherwise
+ # row_count_ok is TRUE by construction (unconditional init above), so the
+ # absent row is neutral.
+ # When everything passes there are no cells to extract, so the expensive
+ # per-column pointblank agent (one step per column, ~quadratic on wide
+ # tables) can be replaced by a constant cost trivially-passing agent.
+ # all_passed stays identical and get_data_extracts() is empty either way.
+ # Any failure falls through to the full per-column agent so failing cells
+ # remain extractable byte-for-byte.
+ all_passed_fast <- all(coverage$n_failed == 0L)
+
if (all_passed_fast) {
agent <- build_pass_agent(
tbl = cmp_for_agent, label = label,
@@ -553,10 +862,24 @@ compare_datasets_from_yaml <- function(data_reference,
# the per-column agent overhead on the passing majority. col_exists steps
# (which only ever pass) are dropped for the same reason. Structural
# failures (missing columns, type mismatches, row count) are always kept.
- fail <- failing_columns(
- tbl = cmp_for_agent, tol_cols = tol_cols, eq_cols = eq_cols,
- ref_suffix = ref_suffix, na_equal = na_equal
+ # The failing sets come from the coverage (same booleans, already scanned).
+ fail_rows <- coverage[coverage$n_failed > 0L, , drop = FALSE]
+ fail <- list(
+ tol = fail_rows$column[fail_rows$check == "tolerance"],
+ eq = fail_rows$column[fail_rows$check == "equality"]
)
+ # Local path: materialise the __eq boolean for the failing equality
+ # columns so the pointblank step validates the exact boolean the verdict
+ # used (one-sided NA fails, two-sided NA follows na_equal). The lazy path
+ # already carries __eq columns; col_vals_equal(na_pass = ...) alone cannot
+ # express these semantics.
+ if (!is_lazy && length(fail$eq) > 0) {
+ for (col_nm in fail$eq) {
+ cmp_for_agent[[datadiff_eq_col(col_nm)]] <- eq_col_bool(
+ cmp_for_agent, col = col_nm, ref_suffix = ref_suffix, na_equal = na_equal
+ )
+ }
+ }
# Materialise the measured deviation (__absdiff) and applied threshold
# (__thresh) for the FAILING tolerance columns only, so the extracts
# and the report CSV show the explicit gap (as in <= 0.4.7) at a cost
@@ -568,26 +891,51 @@ compare_datasets_from_yaml <- function(data_reference,
cmp_for_agent, fail$tol, col_rules, ref_suffix, na_equal
)
}
+ # Lazy path: collect ONLY the failing boolean columns (plus the row-count
+ # flag consumed by its validation step) for the agent; the passing
+ # majority stays in the database.
+ if (is_lazy) {
+ red_cols <- c(
+ datadiff_ok_col(fail$tol),
+ datadiff_eq_col(fail$eq),
+ if (isTRUE(row_validation_info$check_count)) {
+ "row_count_ok"
+ } else {
+ character(0)
+ }
+ )
+ cmp_for_agent <- if (length(red_cols) > 0) {
+ dplyr::collect(
+ dplyr::select(cmp_slim_computed, dplyr::all_of(red_cols))
+ )
+ } else {
+ # Structural-only failure (missing columns / type mismatches with no
+ # failing value column and no row-count check): seed ONE row so the
+ # dummy FALSE columns added by setup_pointblank_agent carry a failing
+ # unit - a 0-row dummy step would interrogate 0 units and pass,
+ # flipping pointblank::all_passed() against the coverage verdict
+ data.frame(.datadiff_structural = FALSE)
+ }
+ }
agent <- setup_pointblank_agent(
cmp_for_agent,
- cols_reference,
- fail$eq,
- fail$tol,
- row_validation_info,
- ref_suffix,
- warn_at,
- stop_at,
- label,
- na_equal,
- lang,
- locale,
+ common_cols = fail$eq,
+ tol_cols = fail$tol,
+ row_validation_info = row_validation_info,
+ ref_suffix = ref_suffix,
+ warn_at = warn_at,
+ stop_at = stop_at,
+ label = label,
+ na_equal = na_equal,
+ lang = lang,
+ locale = locale,
missing_in_candidate = missing_in_candidate,
type_mismatch_cols = type_mismatch_cols,
add_col_exists_steps = FALSE
)
}
- reponse <- interrogate(
+ response <- interrogate(
agent,
extract_failed = extract_failed,
get_first_n = get_first_n,
@@ -596,24 +944,24 @@ compare_datasets_from_yaml <- function(data_reference,
sample_limit = sample_limit
)
- all_passed <- pointblank::all_passed(reponse)
+ all_passed <- pointblank::all_passed(response)
- # Make reponse render the full pointblank report lazily (on print) from the
+ # Make response render the full pointblank report lazily (on print) from the
# coverage, while remaining a real interrogated agent for all_passed() and
# get_data_extracts().
- reponse <- as_datadiff_report(
- reponse, coverage = coverage, label = label, lang = lang, locale = locale,
+ response <- as_datadiff_report(
+ response, coverage = coverage, label = label, lang = lang, locale = locale,
warn_at = warn_at, stop_at = stop_at
)
- list(
+ new_datadiff_result(list(
all_passed = all_passed,
agent = agent,
- reponse = reponse,
+ response = response,
missing_in_candidate = missing_in_candidate,
extra_in_candidate = extra_in_candidate,
applied_rules = col_rules,
coverage = coverage,
summary = summarize_coverage(coverage)
- )
+ ))
}
diff --git a/R/constants.R b/R/constants.R
new file mode 100644
index 0000000..91789fd
--- /dev/null
+++ b/R/constants.R
@@ -0,0 +1,39 @@
+# Internal naming conventions shared by the boolean producers (tolerance.R,
+# compare_datasets_from_yaml.R), the verdict consumers (fast_path.R,
+# coverage.R) and the report step mapping (report.R, pointblank_setup.R).
+# They live in one place because a drift on one side breaks the mapping
+# SILENTLY: the report step is simply not found and its extract is lost.
+
+# Suffix of the per-column boolean produced for a tolerance column.
+datadiff_suffix_ok <- "__ok"
+
+# Suffix of the per-column boolean produced for an equality column.
+datadiff_suffix_eq <- "__eq"
+
+# Prefix of the dummy column carrying a missing-column failure step.
+datadiff_prefix_missing_col <- "__missing_col_"
+
+# Prefix of the dummy column carrying a type-mismatch failure step.
+datadiff_prefix_type_mismatch <- "__type_mismatch_"
+
+# Column name helpers: the only supported way to build these names.
+# Length-guarded: paste0(character(0), suffix) yields the bare suffix
+# (recycle0 is FALSE by default), a phantom column name.
+datadiff_ok_col <- function(col) {
+ if (length(col) == 0) {
+ return(character(0))
+ }
+ paste0(col, datadiff_suffix_ok)
+}
+datadiff_eq_col <- function(col) {
+ if (length(col) == 0) {
+ return(character(0))
+ }
+ paste0(col, datadiff_suffix_eq)
+}
+
+# Default pointblank action levels (fractions of failing rows). Used as the
+# internal fallback everywhere a threshold is reconstructed from attributes;
+# the public compare_datasets_from_yaml() signature documents the same value.
+datadiff_default_warn_at <- 1e-14
+datadiff_default_stop_at <- 1e-14
diff --git a/R/coverage.R b/R/coverage.R
index b656024..5bfbfd9 100644
--- a/R/coverage.R
+++ b/R/coverage.R
@@ -9,7 +9,9 @@
# matching col_vals_equal(..., na_pass = FALSE).
tol_col_counts <- function(tbl, col) {
ok <- tol_col_bool(tbl, col = col)
- list(n = length(ok), n_failed = sum(is.na(ok) | !ok))
+ # length - sum(TRUE) counts FALSE and NA in one pass without allocating
+ # the two intermediate logical vectors of is.na(ok) | !ok
+ list(n = length(ok), n_failed = length(ok) - sum(ok, na.rm = TRUE))
}
# n / n_failed for an equality column (resolved boolean, no NA).
@@ -18,54 +20,109 @@ eq_col_counts <- function(tbl, col, ref_suffix, na_equal) {
list(n = length(b), n_failed = sum(!b))
}
+# (n, n_failed) for every boolean validation column of a LAZY table, computed
+# in ONE SQL aggregate scan: SUM(CASE WHEN col THEN 0 ELSE 1 END) counts the
+# not-TRUE values (FALSE and NULL alike), matching the local reducers
+# (tolerance: is.na | !ok; equality: !b, NA-free by construction). This is
+# what lets the lazy verdict avoid collecting N x columns booleans into R.
+# Returns a named list keyed by the UNDERLYING column name:
+# list( = list(n, n_failed), ...).
+lazy_boolean_counts <- function(tbl_lazy, tol_cols, eq_cols) {
+ con <- dbplyr::remote_con(tbl_lazy)
+ sub <- dbplyr::sql_render(tbl_lazy)
+ q <- function(x) as.character(DBI::dbQuoteIdentifier(con, x = x))
+
+ bool_cols <- c(datadiff_ok_col(tol_cols), datadiff_eq_col(eq_cols))
+ underlying <- c(tol_cols, eq_cols)
+ nf_exprs <- vapply(X = seq_along(bool_cols), FUN = function(i) {
+ sprintf("SUM(CASE WHEN %s THEN 0 ELSE 1 END) AS %s",
+ q(bool_cols[i]), q(sprintf("..datadiff_nf_%d", i)))
+ }, FUN.VALUE = character(1))
+
+ sql <- sprintf(
+ "SELECT COUNT(*) AS %s%s FROM (%s) AS %s",
+ q("..datadiff_total"),
+ if (length(nf_exprs) > 0) {
+ paste0(", ", paste(nf_exprs, collapse = ", "))
+ } else {
+ ""
+ },
+ sub, q("datadiff_counts")
+ )
+ row <- DBI::dbGetQuery(con, statement = sql)
+
+ total <- as.numeric(row[["..datadiff_total"]])
+ counts <- vector("list", length(underlying))
+ names(counts) <- underlying
+ for (i in seq_along(underlying)) {
+ nf <- as.numeric(row[[sprintf("..datadiff_nf_%d", i)]])
+ # SUM over an empty table is NULL: zero rows means zero failures
+ counts[[i]] <- list(n = total, n_failed = if (is.na(nf)) {
+ 0
+ } else {
+ nf
+ })
+ }
+ counts
+}
+
#' Build a faithful coverage summary of the checks performed
#'
#' @param tbl Local data.frame holding the boolean validation columns (and, on
#' the local path, the raw + reference columns for equality recomputation).
+#' Ignored when `counts` is supplied.
#' @param tol_cols,eq_cols Character vectors of tolerance / equality columns.
#' @param missing_in_candidate,type_mismatch_cols Structural failures.
#' @param row_validation_info List with `check_count`.
#' @param row_count_ok Logical row-count outcome.
#' @param ref_suffix Suffix identifying reference columns.
#' @param na_equal Logical; NA equality semantics for equality columns.
+#' @param counts Optional precomputed per-column counts (named list
+#' `col -> list(n, n_failed)` covering every tolerance and equality column),
+#' as produced by `lazy_boolean_counts()`: the lazy path aggregates in SQL
+#' instead of collecting the booleans.
#' @return A `datadiff_coverage` data.frame with columns `column`, `check`,
#' `n`, `n_failed`, `status` (one row per check performed).
#' @noRd
build_coverage <- function(tbl, tol_cols, eq_cols,
missing_in_candidate, type_mismatch_cols,
row_validation_info, row_count_ok,
- ref_suffix, na_equal) {
+ ref_suffix, na_equal, counts = NULL) {
columns <- character(0)
checks <- character(0)
- ns <- integer(0)
- n_failed <- integer(0)
+ ns <- numeric(0)
+ n_failed <- numeric(0)
add <- function(column, check, n, nf) {
columns <<- c(columns, column)
checks <<- c(checks, check)
- ns <<- c(ns, as.integer(n))
- n_failed <<- c(n_failed, as.integer(nf))
+ # numeric, not integer: the lazy counts come from SQL aggregates and can
+ # exceed 2^31 - 1, where as.integer() would overflow to NA and silently
+ # corrupt the coverage and the verdict
+ ns <<- c(ns, as.numeric(n))
+ n_failed <<- c(n_failed, as.numeric(nf))
}
# Existence checks: every common (non-type-mismatch) column gets a col_exists
# check, distinct from its value check. These pass (the column is present in
# both datasets by construction), mirroring a full per-column pointblank run.
- for (c in c(tol_cols, eq_cols)) {
- add(c, "col_exists", 1L, 0L)
+ for (col_nm in c(tol_cols, eq_cols)) {
+ add(col_nm, "col_exists", 1L, 0L)
}
- for (c in tol_cols) {
- cnt <- tol_col_counts(tbl, col = c)
- add(c, "tolerance", cnt$n, cnt$n_failed)
+ for (col_nm in tol_cols) {
+ cnt <- counts[[col_nm]] %||% tol_col_counts(tbl, col = col_nm)
+ add(col_nm, "tolerance", cnt$n, cnt$n_failed)
}
- for (c in eq_cols) {
- cnt <- eq_col_counts(tbl, col = c, ref_suffix = ref_suffix, na_equal = na_equal)
- add(c, "equality", cnt$n, cnt$n_failed)
+ for (col_nm in eq_cols) {
+ cnt <- counts[[col_nm]] %||%
+ eq_col_counts(tbl, col = col_nm, ref_suffix = ref_suffix, na_equal = na_equal)
+ add(col_nm, "equality", cnt$n, cnt$n_failed)
}
- for (c in missing_in_candidate) {
- add(c, "missing_column", 1L, 1L)
+ for (col_nm in missing_in_candidate) {
+ add(col_nm, "missing_column", 1L, 1L)
}
- for (c in type_mismatch_cols) {
- add(c, "type_mismatch", 1L, 1L)
+ for (col_nm in type_mismatch_cols) {
+ add(col_nm, "type_mismatch", 1L, 1L)
}
if (isTRUE(row_validation_info$check_count)) {
add("", "row_count", 1L, if (isTRUE(row_count_ok)) 0L else 1L)
diff --git a/R/data_types.R b/R/data_types.R
index 4561d6a..2c97a8f 100644
--- a/R/data_types.R
+++ b/R/data_types.R
@@ -3,6 +3,12 @@
#' Analyzes each column of a dataframe to determine its data type
#' (integer, numeric, date, datetime, logical, or character)
#'
+#' Any column matching none of the first five types falls in the "character"
+#' bucket and receives the character rules. That is intentional for factors
+#' (compared as the character values they display, see
+#' [preprocess_dataframe()]) and works for types whose `==` behaves like
+#' character (e.g. `bit64::integer64`). List-columns are not supported.
+#'
#' @param data A dataframe or tibble
#' @return A named character vector with column names as names and types as values
#' @examples
diff --git a/R/datadiff-result.R b/R/datadiff-result.R
new file mode 100644
index 0000000..7e9d787
--- /dev/null
+++ b/R/datadiff-result.R
@@ -0,0 +1,51 @@
+# Accessors for the result of compare_datasets_from_yaml().
+#
+# The result is a plain named list of class `datadiff_result`. The class only
+# exists to keep the historical `reponse` field name readable while user code
+# migrates to `response`: `$` and `[[` redirect the old name to the new one
+# with a deprecation warning, and behave like plain list access otherwise.
+
+new_datadiff_result <- function(fields) {
+ structure(fields, class = "datadiff_result")
+}
+
+warn_reponse_deprecated <- function() {
+ rlang::warn(
+ message = paste(
+ "The `reponse` field is deprecated as of {datadiff} 0.6.0:",
+ "use `response` instead.",
+ "`reponse` will be removed in a future release."
+ ),
+ .frequency = "once",
+ .frequency_id = "datadiff_reponse_deprecated",
+ class = "datadiff_deprecated_reponse_warning"
+ )
+}
+
+#' @export
+#' @noRd
+`$.datadiff_result` <- function(x, name) {
+ if (identical(name, "reponse")) {
+ warn_reponse_deprecated()
+ name <- "response"
+ }
+ # exact = FALSE replicates the silent partial matching of `$` on plain lists
+ unclass(x)[[name, exact = FALSE]]
+}
+
+#' @export
+#' @noRd
+`[[.datadiff_result` <- function(x, i, ...) {
+ if (identical(i, "reponse")) {
+ warn_reponse_deprecated()
+ i <- "response"
+ }
+ unclass(x)[[i, ...]]
+}
+
+#' @export
+#' @noRd
+print.datadiff_result <- function(x, ...) {
+ print(unclass(x), ...)
+ invisible(x)
+}
diff --git a/R/duplicate_keys.R b/R/duplicate_keys.R
index 2fd5997..7068315 100644
--- a/R/duplicate_keys.R
+++ b/R/duplicate_keys.R
@@ -10,29 +10,57 @@
# tables keep the SQL-native count()/group_by (cheap inside the database).
format_key_examples <- function(uniq_keys, key) {
- rows <- unname(apply(uniq_keys, 1, function(r) {
+ n_groups <- nrow(uniq_keys)
+ shown <- utils::head(uniq_keys, 3L)
+ rows <- unname(apply(shown, 1, function(r) {
paste(key, "=", r, collapse = ", ")
}))
- if (length(rows) <= 3) {
- rows
- } else {
- c(rows[1:3], "...")
+ if (n_groups > 3L) {
+ rows <- c(rows, "...")
}
+ rows
}
+#' @importFrom rlang .data
+#' @noRd
find_duplicate_keys <- function(data, key) {
if (is_non_local(data)) {
- dups <- data %>%
- dplyr::count(dplyr::across(dplyr::all_of(key))) %>%
- dplyr::filter(n > 1L) %>%
+ # Reserved count name: count()'s default "n" collides with a user column
+ # named "n" (count() then stores its result in "nn", and a key named "n"
+ # would be filtered on its own values instead of the count). The reserved
+ # name is extended until it differs from every key column, because
+ # count(name = ) silently REPLACES that grouping column
+ # with the count.
+ count_col <- "..datadiff_n"
+ while (count_col %in% key) {
+ count_col <- paste0(count_col, "_")
+ }
+ duplicated_groups <- data %>%
+ dplyr::count(dplyr::across(dplyr::all_of(key)), name = count_col) %>%
+ dplyr::filter(.data[[count_col]] > 1L)
+ # Aggregate in SQL: only 2 scalars plus at most 3 example groups cross the
+ # wire, instead of every duplicated group (potentially millions).
+ agg <- duplicated_groups %>%
+ dplyr::summarise(
+ ..datadiff_dup_keys = dplyr::n(),
+ ..datadiff_dup_rows = sum(.data[[count_col]], na.rm = TRUE)
+ ) %>%
dplyr::collect()
- if (nrow(dups) == 0L) {
+ n_dup_keys <- as.numeric(agg$..datadiff_dup_keys)
+ if (n_dup_keys == 0) {
return(NULL)
}
+ example_groups <- duplicated_groups %>%
+ utils::head(3L) %>%
+ dplyr::collect()
+ examples <- format_key_examples(example_groups[, key, drop = FALSE], key)
+ if (n_dup_keys > 3) {
+ examples <- c(examples, "...")
+ }
return(list(
- n_dup_keys = nrow(dups),
- n_dup_rows = sum(dups$n),
- examples = format_key_examples(dups[, key, drop = FALSE], key)
+ n_dup_keys = n_dup_keys,
+ n_dup_rows = as.numeric(agg$..datadiff_dup_rows),
+ examples = examples
))
}
diff --git a/R/fast_path.R b/R/fast_path.R
index 0e94d14..04f4756 100644
--- a/R/fast_path.R
+++ b/R/fast_path.R
@@ -1,93 +1,63 @@
# Per-column pass predicates, shared by the all-pass short-circuit and the
-# failing-column selection. They reproduce pointblank's verdict exactly:
+# failing-column selection.
# - a tolerance column passes iff every __ok value is TRUE (NA counts as
# a failure, matching col_vals_equal(..., na_pass = FALSE));
-# - an equality column passes iff its pre-computed __eq column is all TRUE
-# (lazy path) or, when no __eq exists (local path), the raw comparison holds
-# under the same na_pass = na_equal semantics pointblank uses (any NA on
-# either side passes iff na_equal).
+# - an equality column passes iff its pre-computed __eq column is all
+# TRUE (lazy path) or, when no __eq exists (local path), the raw comparison
+# holds under the shared NA semantics: a one-sided NA is always a
+# difference, a two-sided NA follows na_equal. This matches both the
+# numeric tolerance kernel and the lazy SQL CASE WHEN.
# Per-row boolean outcome of a tolerance column: the pre-computed __ok
# vector. NA (never produced by construction) would count as a failure, matching
-# col_vals_equal(..., na_pass = FALSE).
+# col_vals_equal(..., na_pass = FALSE). A missing __ok column is a hard internal
+# error: all(NULL) is TRUE, so returning NULL would silently turn a dropped
+# column into a false all-pass.
tol_col_bool <- function(tbl, col) {
- tbl[[paste0(col, "__ok")]]
+ b <- tbl[[datadiff_ok_col(col)]]
+ if (is.null(b)) {
+ stop(sprintf("internal error: boolean column '%s' missing",
+ datadiff_ok_col(col)),
+ call. = FALSE)
+ }
+ b
}
# Per-row boolean outcome of an equality column, resolved to TRUE/FALSE (no NA):
-# the pre-computed __eq vector (lazy path) or, when absent (local path), the
-# raw comparison under the same na_pass = na_equal semantics pointblank uses
-# (any NA on either side passes iff na_equal).
+# the pre-computed __eq vector (lazy path) or, when absent (local path),
+# the raw comparison with one-sided NA = FALSE and two-sided NA = na_equal.
eq_col_bool <- function(tbl, col, ref_suffix, na_equal) {
- eq_precomputed <- tbl[[paste0(col, "__eq")]]
+ eq_precomputed <- tbl[[datadiff_eq_col(col)]]
if (!is.null(eq_precomputed)) {
return(eq_precomputed)
}
cand_vals <- tbl[[col]]
ref_vals <- tbl[[paste0(col, ref_suffix)]]
- cmp_res <- cand_vals == ref_vals
- ifelse(is.na(cmp_res), na_equal, cmp_res)
-}
-
-tol_col_passes <- function(tbl, col) {
- isTRUE(all(tol_col_bool(tbl, col = col)))
-}
-
-eq_col_passes <- function(tbl, col, ref_suffix, na_equal) {
- isTRUE(all(eq_col_bool(tbl, col = col, ref_suffix = ref_suffix, na_equal = na_equal)))
-}
-
-#' Fast global pass/fail without building the per-column pointblank agent
-#'
-#' The verdict produced by \code{compare_datasets_from_yaml} is entirely
-#' determined by boolean columns already computed in a single vectorised pass
-#' (\code{__ok} for tolerance columns, \code{__eq} for equality
-#' columns on the lazy path). pointblank then re-checks those booleans through
-#' one validation step per column, which dominates runtime on wide tables. When
-#' every check passes there is nothing to extract, so the agent can be skipped.
-#'
-#' @param tbl Local data.frame holding the boolean validation columns (and, for
-#' the local path, the raw and reference equality columns).
-#' @param tol_cols Character vector of tolerance column names.
-#' @param eq_cols Character vector of equality column names (already stripped of
-#' key, type-mismatch and tolerance columns).
-#' @param ref_suffix Suffix identifying reference columns.
-#' @param na_equal Logical; whether NA equals NA for equality columns.
-#' @return \code{TRUE} iff every tolerance and equality check passes.
-#' @noRd
-all_validations_pass <- function(tbl, tol_cols, eq_cols, ref_suffix, na_equal) {
- for (c in tol_cols) {
- if (!tol_col_passes(tbl, col = c)) {
- return(FALSE)
- }
- }
- for (c in eq_cols) {
- if (!eq_col_passes(tbl, col = c, ref_suffix = ref_suffix, na_equal = na_equal)) {
- return(FALSE)
- }
+ # Same rationale as tol_col_bool: a missing pair must fail loudly, not
+ # yield NULL and a silent all-pass downstream
+ if (is.null(cand_vals) || is.null(ref_vals)) {
+ missing_cols <- c(
+ if (is.null(cand_vals)) {
+ sprintf("'%s'", col)
+ },
+ if (is.null(ref_vals)) {
+ sprintf("'%s%s'", col, ref_suffix)
+ }
+ )
+ stop(sprintf(
+ "internal error: equality column(s) %s missing (no precomputed '%s' either)",
+ paste(missing_cols, collapse = " and "), datadiff_eq_col(col)
+ ), call. = FALSE)
}
- TRUE
-}
-
-#' Subset of columns whose validation fails
-#'
-#' Used on the failure path to build pointblank steps only for columns that
-#' actually fail. Columns that pass produce empty data extracts, so omitting
-#' their steps leaves \code{get_data_extracts()} unchanged while avoiding the
-#' per-column agent overhead on the passing majority.
-#'
-#' @inheritParams all_validations_pass
-#' @return A list with \code{tol} and \code{eq} character vectors of failing
-#' column names.
-#' @noRd
-failing_columns <- function(tbl, tol_cols, eq_cols, ref_suffix, na_equal) {
- fail_tol <- tol_cols[vapply(tol_cols, function(c) {
- !tol_col_passes(tbl, col = c)
- }, logical(1))]
- fail_eq <- eq_cols[vapply(eq_cols, function(c) {
- !eq_col_passes(tbl, col = c, ref_suffix = ref_suffix, na_equal = na_equal)
- }, logical(1))]
- list(tol = fail_tol, eq = fail_eq)
+ cand_na <- is.na(cand_vals)
+ ref_na <- is.na(ref_vals)
+ cmp_res <- cand_vals == ref_vals
+ # Both values present and equal; any NA resolves below
+ out <- !is.na(cmp_res) & cmp_res
+ # Two-sided NA follows na_equal; a one-sided NA stays FALSE (a value facing
+ # a missing value is a difference)
+ out[cand_na & ref_na] <- na_equal
+ out
}
#' Build a minimal pointblank agent whose interrogation passes
diff --git a/R/globals.R b/R/globals.R
deleted file mode 100644
index d07754e..0000000
--- a/R/globals.R
+++ /dev/null
@@ -1 +0,0 @@
-globalVariables("n")
diff --git a/R/pointblank_setup.R b/R/pointblank_setup.R
index 29d156f..7378450 100644
--- a/R/pointblank_setup.R
+++ b/R/pointblank_setup.R
@@ -3,9 +3,19 @@
#' Creates and configures a pointblank validation agent with all necessary validation steps
#' including column existence checks, exact value comparisons, and tolerance validations.
#'
+#' The equality steps validate a `__eq` boolean (one-sided NA fails,
+#' two-sided NA follows `na_equal`, matching the tolerance kernel and the lazy
+#' SQL): when `cmp` does not already carry it, it is derived on the fly for a
+#' local data.frame. Tolerance steps expect the `__ok` booleans to be
+#' precomputed (see [add_tolerance_columns()]). A lazy `cmp` must carry every
+#' boolean column already.
+#'
#' @param cmp Comparison dataframe with candidate and reference data
-#' @param cols_reference Character vector of reference column names
-#' @param common_cols Character vector of columns present in both datasets
+#' @param cols_reference Deprecated and unused; supplying any non-NULL value
+#' raises a warning (an explicit NULL is silent) and the argument will be
+#' removed in a future release
+#' @param common_cols Character vector of equality columns to validate (the
+#' internal pipeline passes only the failing ones on its failure path)
#' @param tol_cols Character vector of columns with tolerance rules
#' @param row_validation_info List with row validation information from validate_row_counts
#' @param ref_suffix Suffix for reference columns
@@ -29,25 +39,40 @@
#' data columns.
#' @return Configured pointblank agent ready for interrogation
#' @examples
-#' cmp <- data.frame(a = 1:3, a__reference = 1:3, b = c(1.1, 2.2, 3.3),
-#' b__reference = c(1.0, 2.0, 3.0))
+#' cmp <- data.frame(
+#' a = 1:3, a__reference = 1:3,
+#' b = c(1.1, 2.2, 3.3), b__reference = c(1.0, 2.0, 3.0),
+#' b__ok = c(FALSE, FALSE, FALSE)
+#' )
#' row_info <- list(check_count = FALSE)
-#' setup_pointblank_agent(cmp, c("a", "b"), c("a", "b"), "b", row_info,
-#' "__reference", 0.1, 0.1, "Test", TRUE)
+#' agent <- setup_pointblank_agent(
+#' cmp,
+#' common_cols = "a", tol_cols = "b", row_validation_info = row_info,
+#' ref_suffix = "__reference", warn_at = 0.1, stop_at = 0.1,
+#' label = "Test", na_equal = TRUE
+#' )
+#' pointblank::all_passed(pointblank::interrogate(agent))
#' @importFrom pointblank create_agent col_exists col_vals_equal action_levels
#' @importFrom tidyselect all_of
#' @importFrom rlang :=
#' @export
-setup_pointblank_agent <- function(cmp, cols_reference, common_cols, tol_cols,
+setup_pointblank_agent <- function(cmp, cols_reference = NULL, common_cols, tol_cols,
row_validation_info = NULL, ref_suffix, warn_at, stop_at, label,
na_equal, lang = "fr", locale = "fr_FR",
missing_in_candidate = character(0),
type_mismatch_cols = character(0),
add_col_exists_steps = TRUE) {
+ if (!is.null(cols_reference)) {
+ warning(
+ "The 'cols_reference' argument of setup_pointblank_agent() is deprecated and unused; it will be removed in a future release.",
+ call. = FALSE
+ )
+ }
+
# Add dummy columns for missing columns BEFORE creating the agent
# These columns are set to FALSE and we'll check they equal TRUE (will fail)
- for (c in missing_in_candidate) {
- dummy_col <- paste0("__missing_col_", c)
+ for (col_nm in missing_in_candidate) {
+ dummy_col <- paste0(datadiff_prefix_missing_col, col_nm)
if (is_non_local(cmp)) {
cmp <- dplyr::mutate(cmp, !!dummy_col := FALSE)
} else {
@@ -57,8 +82,8 @@ setup_pointblank_agent <- function(cmp, cols_reference, common_cols, tol_cols,
# Add dummy FALSE columns for type-mismatched columns.
# These will generate a dedicated failing validation step per column.
- for (c in type_mismatch_cols) {
- dummy_col <- paste0("__type_mismatch_", c)
+ for (col_nm in type_mismatch_cols) {
+ dummy_col <- paste0(datadiff_prefix_type_mismatch, col_nm)
if (is_non_local(cmp)) {
cmp <- dplyr::mutate(cmp, !!dummy_col := FALSE)
} else {
@@ -66,6 +91,25 @@ setup_pointblank_agent <- function(cmp, cols_reference, common_cols, tol_cols,
}
}
+ # Derive the missing __eq booleans BEFORE creating the agent, so every
+ # equality step validates the same boolean the verdict logic uses (shared NA
+ # semantics) instead of embedding the whole reference vector in the step
+ # (O(n_rows) per step, serialised with the report, and na_pass cannot
+ # express the one-sided/two-sided NA distinction).
+ eq_cols <- setdiff(x = common_cols, y = tol_cols)
+ if (!is_non_local(cmp)) {
+ for (col_nm in eq_cols) {
+ eq_col <- datadiff_eq_col(col_nm)
+ if (!(eq_col %in% get_col_names(cmp))) {
+ cmp[[eq_col]] <- eq_col_bool(
+ cmp, col = col_nm, ref_suffix = ref_suffix, na_equal = na_equal
+ )
+ }
+ }
+ }
+ # Hoisted once, AFTER every cmp mutation above
+ col_names <- get_col_names(cmp)
+
agent <- create_agent(tbl = cmp, label = label,
actions = action_levels(warn_at = warn_at, stop_at = stop_at),
lang = lang,
@@ -76,61 +120,58 @@ setup_pointblank_agent <- function(cmp, cols_reference, common_cols, tol_cols,
# Skipped for the non-local path where cmp is a slim table containing only
# boolean validation columns (the original data columns are not present).
if (add_col_exists_steps) {
- for (c in common_cols) {
- agent <- agent %>% col_exists(columns = all_of(c))
+ for (col_nm in common_cols) {
+ agent <- agent %>% col_exists(columns = all_of(col_nm))
}
}
# For missing columns, add a validation that will fail
- for (c in missing_in_candidate) {
- dummy_col <- paste0("__missing_col_", c)
+ for (col_nm in missing_in_candidate) {
+ dummy_col <- paste0(datadiff_prefix_missing_col, col_nm)
agent <- agent %>%
col_vals_equal(
columns = all_of(dummy_col),
value = TRUE,
na_pass = FALSE,
- label = paste("col_exists:", c)
+ label = paste("col_exists:", col_nm)
)
}
# For type-mismatched columns, add a validation that will always fail.
- for (c in type_mismatch_cols) {
- dummy_col <- paste0("__type_mismatch_", c)
+ for (col_nm in type_mismatch_cols) {
+ dummy_col <- paste0(datadiff_prefix_type_mismatch, col_nm)
agent <- agent %>%
col_vals_equal(
columns = all_of(dummy_col),
value = TRUE,
na_pass = FALSE,
- label = paste("type_mismatch:", c)
+ label = paste("type_mismatch:", col_nm)
)
}
- eq_cols <- setdiff(x = common_cols, y = tol_cols)
- for (c in eq_cols) {
- eq_precomputed <- paste0(c, "__eq")
- if (eq_precomputed %in% get_col_names(cmp)) {
- # Lazy path: use pre-computed boolean equality column
- agent <- agent %>%
- col_vals_equal(columns = all_of(eq_precomputed), value = TRUE, na_pass = FALSE)
- } else {
- # Local path: compare directly against reference column values
- agent <- agent %>%
- col_vals_equal(
- columns = all_of(c),
- value = cmp[[paste0(c, ref_suffix)]],
- na_pass = na_equal
- )
+ for (col_nm in eq_cols) {
+ eq_col <- datadiff_eq_col(col_nm)
+ if (!(eq_col %in% col_names)) {
+ # Only reachable with a lazy cmp missing its precomputed boolean:
+ # the documented contract requires it (deriving it here would need
+ # the raw columns, absent from the slim lazy table)
+ stop(sprintf(
+ "equality boolean column '%s' missing from cmp (a lazy cmp must carry the precomputed __eq columns)",
+ eq_col
+ ), call. = FALSE)
}
+ agent <- agent %>%
+ col_vals_equal(columns = all_of(eq_col), value = TRUE, na_pass = FALSE)
}
- for (c in tol_cols) {
- ok_col <- paste0(c, "__ok")
+ for (col_nm in tol_cols) {
+ ok_col <- datadiff_ok_col(col_nm)
agent <- agent %>% col_vals_equal(columns = all_of(ok_col), value = TRUE, na_pass = FALSE)
}
# Add row count validation if needed
# Use colnames() instead of names() to work correctly with lazy tables
- if (!is.null(row_validation_info) && isTRUE(row_validation_info$check_count) && "row_count_ok" %in% get_col_names(cmp)) {
+ if (!is.null(row_validation_info) && isTRUE(row_validation_info$check_count) && "row_count_ok" %in% col_names) {
agent <- agent %>% col_vals_equal(columns = all_of("row_count_ok"), value = TRUE, na_pass = FALSE)
}
diff --git a/R/preprocessing.R b/R/preprocessing.R
index 3100a4f..6a0dbfb 100644
--- a/R/preprocessing.R
+++ b/R/preprocessing.R
@@ -19,7 +19,7 @@ normalize_text <- function(x, case_insensitive = FALSE, trim = FALSE) {
x <- trimws(x)
}
if (case_insensitive) {
- x <- tolower(x = x)
+ x <- tolower(x)
}
x
}
@@ -30,12 +30,18 @@ normalize_text <- function(x, case_insensitive = FALSE, trim = FALSE) {
#' such as text normalization for character columns. Supports both local data.frames
#' and lazy tables (tbl_lazy) via dplyr::mutate().
#'
+#' On a local data.frame, every factor column (including key and non-compared
+#' columns) is first converted to character: factors are compared as the
+#' character values they display, so the text normalization rules apply to
+#' them and factors with differing level sets compare cleanly.
+#'
#' @param df A dataframe or lazy table to preprocess
#' @param col_rules A list of column-specific rules from derive_column_rules()
#' @param schema Optional local data.frame with 0 rows used to determine column
#' types when \code{df} is a lazy table. Obtained via
#' \code{dplyr::collect(utils::head(df, 0L))}.
-#' @return Preprocessed dataframe (or lazy table) with transformations applied
+#' @return Preprocessed dataframe (or lazy table) with transformations applied.
+#' Factor columns of a local data.frame come back as character vectors.
#' @examples
#' df <- data.frame(text_col = c(" HELLO ", "world"))
#' rules <- list(text_col = list(equal_mode = "normalized", case_insensitive = TRUE, trim = TRUE))
@@ -44,41 +50,70 @@ normalize_text <- function(x, case_insensitive = FALSE, trim = FALSE) {
#' @export
preprocess_dataframe <- function(df, col_rules, schema = NULL) {
out <- df
+ # Factors are classed "character" by detect_column_types() and receive the
+ # character rules, but == on factors with differing level sets errors and
+ # normalize_text() skips non-character vectors. Compare them as the
+ # character values they display. SQL lazy tables convert factors to varchar
+ # upstream; Arrow dictionary columns stay lazy and are handled by the
+ # is.factor(schema[[nm]]) branch of the is_char predicate below.
+ if (!is_non_local(out)) {
+ for (nm in names(out)) {
+ if (is.factor(out[[nm]])) {
+ out[[nm]] <- as.character(out[[nm]])
+ }
+ }
+ }
+ lazy_exprs <- list()
for (nm in names(col_rules)) {
cr <- col_rules[[nm]]
- eq_mode <- cr$equal_mode %||% "exact"
- case_insensitive <- isTRUE(cr$case_insensitive)
- trim <- isTRUE(cr$trim)
+ normalized <- identical(cr$equal_mode %||% "exact", "normalized")
+
+ # equal_mode "normalized" implies BOTH text normalizations unless the
+ # rule sets them explicitly (an explicit FALSE wins over the mode)
+ case_insensitive <- if (is.null(cr$case_insensitive)) {
+ normalized
+ } else {
+ isTRUE(cr$case_insensitive)
+ }
+ trim <- if (is.null(cr$trim)) {
+ normalized
+ } else {
+ isTRUE(cr$trim)
+ }
- # Apply normalization if equal_mode is "normalized" OR if case_insensitive/trim is TRUE
- should_normalize <- identical(eq_mode, "normalized") || case_insensitive || trim
+ should_normalize <- case_insensitive || trim
- # Determine column type: use schema for lazy tables, otherwise inspect df directly
+ # Determine column type: use schema for lazy tables, otherwise inspect df
+ # directly. A factor in the schema counts as character: the local values
+ # were converted above and the character rules apply to it.
is_char <- if (!is.null(schema)) {
- is.character(schema[[nm]])
+ is.character(schema[[nm]]) || is.factor(schema[[nm]])
} else {
is.character(out[[nm]])
}
if (is_char && should_normalize) {
if (is_non_local(out)) {
- # Non-local path: build transformations via dplyr::mutate()
- nm_sym <- dplyr::sym(nm)
+ # Non-local path: compose ONE expression per column and accumulate;
+ # a single mutate() at the end keeps the dbplyr query-construction
+ # cost O(1) instead of one or two mutate() layers per column
+ expr <- dplyr::sym(nm)
if (trim) {
if (is_arrow(out)) {
# Arrow does not support trimws(); use stringr::str_trim() instead
if (!requireNamespace("stringr", quietly = TRUE)) {
stop("Package 'stringr' is required for trim on Arrow objects.")
}
- out <- dplyr::mutate(out, !!nm := stringr::str_trim(!!nm_sym))
+ expr <- rlang::expr(stringr::str_trim(!!expr))
} else {
# SQL path: trimws() -> SQL TRIM() (translated by dbplyr)
- out <- dplyr::mutate(out, !!nm := trimws(!!nm_sym))
+ expr <- rlang::expr(trimws(!!expr))
}
}
if (case_insensitive) {
- out <- dplyr::mutate(out, !!nm := tolower(!!nm_sym))
+ expr <- rlang::expr(tolower(!!expr))
}
+ lazy_exprs[[nm]] <- expr
} else {
out[[nm]] <- normalize_text(
out[[nm]],
@@ -88,5 +123,8 @@ preprocess_dataframe <- function(df, col_rules, schema = NULL) {
}
}
}
+ if (length(lazy_exprs) > 0) {
+ out <- dplyr::mutate(out, !!!lazy_exprs)
+ }
out
}
diff --git a/R/report.R b/R/report.R
index 56c70f8..ce9f0b0 100644
--- a/R/report.R
+++ b/R/report.R
@@ -1,9 +1,9 @@
# Lazy pointblank-style report.
#
-# The fast path keeps res$reponse a real interrogated agent (minimal when green,
+# The fast path keeps res$response a real interrogated agent (minimal when green,
# targeted when red) so pointblank::all_passed() and get_data_extracts() keep
# working. We prefix the class "datadiff_report" and attach the coverage so that
-# PRINTING res$reponse builds, on demand, a full pointblank agent report (one
+# PRINTING res$response builds, on demand, a full pointblank agent report (one
# step per column) from the pre-computed coverage - without re-running the slow
# per-column interrogation. The build cost is paid only when the report is
# actually displayed, and memoized so repeated prints are instant.
@@ -14,13 +14,13 @@ report_underlying_col <- function(col) {
if (identical(col, "row_count_ok")) {
return("")
}
- if (startsWith(col, "__missing_col_")) {
- return(sub("^__missing_col_", "", col))
+ if (startsWith(col, datadiff_prefix_missing_col)) {
+ return(sub(paste0("^", datadiff_prefix_missing_col), "", x = col))
}
- if (startsWith(col, "__type_mismatch_")) {
- return(sub("^__type_mismatch_", "", col))
+ if (startsWith(col, datadiff_prefix_type_mismatch)) {
+ return(sub(paste0("^", datadiff_prefix_type_mismatch), "", x = col))
}
- sub("__(ok|eq)$", "", col)
+ sub(sprintf("(%s|%s)$", datadiff_suffix_ok, datadiff_suffix_eq), "", x = col)
}
# Build a pointblank agent whose report mirrors the coverage table.
@@ -33,17 +33,20 @@ report_underlying_col <- function(col) {
# synthetic passing row is added for the rest. Without `real_agent`, a purely
# synthetic count-only agent is produced (no extracts).
build_report_agent <- function(coverage, label, lang = "fr", locale = "fr_FR",
- warn_at = 1e-14, stop_at = 1e-14,
+ warn_at = datadiff_default_warn_at, stop_at = datadiff_default_stop_at,
real_agent = NULL) {
n <- nrow(coverage)
# Augment the real interrogated agent only when it has genuine failures
- # (real extracts to preserve). On an all-pass comparison the real agent is the
+ # (real extracts to preserve) or evaluation errors (n_failed is NA there, so
+ # the count test alone would silently route a broken interrogation to the
+ # synthetic all-pass branch). On an all-pass comparison the real agent is the
# minimal placeholder (a single col_exists step); cloning its template would
# mislabel every value check as col_exists, so fall through to the synthetic
# col_vals_equal build instead.
if (!is.null(real_agent) &&
- any(real_agent$validation_set$n_failed > 0, na.rm = TRUE)) {
+ (any(real_agent$validation_set$n_failed > 0, na.rm = TRUE) ||
+ any(real_agent$validation_set$eval_error, na.rm = TRUE))) {
rvs <- real_agent$validation_set
real_cols <- vapply(seq_len(nrow(rvs)), function(j) {
report_underlying_col(rvs$column[[j]][1])
@@ -55,6 +58,8 @@ build_report_agent <- function(coverage, label, lang = "fr", locale = "fr_FR",
template <- rvs[1, , drop = FALSE]
rows <- vector("list", n)
new_extracts <- list()
+ real_eval_error <- logical(n)
+ real_eval_warning <- logical(n)
for (i in seq_len(n)) {
col <- coverage$column[i]
# Only value checks (tolerance / equality) map to a real step: that is
@@ -62,34 +67,30 @@ build_report_agent <- function(coverage, label, lang = "fr", locale = "fr_FR",
# structural checks (missing_column, type_mismatch, row_count) are
# synthesized from coverage - mapping them to a real step would pull in
# per-row dummy results (e.g. n_failed = nrow) that contradict coverage.
+ # Consequence: an eval_error on a structural step is intentionally not
+ # surfaced per-column (only value-step evaluation errors are).
j <- if (coverage$check[i] %in% c("tolerance", "equality")) {
match(col, real_cols)
} else {
NA_integer_
}
if (!is.na(j)) {
- # Genuine interrogated step: keep it, and carry its real extract over to
- # the new step index.
+ # Genuine interrogated step: keep it, carry its real extract over to
+ # the new step index, and remember its evaluation outcome (the
+ # vectorised coverage overwrite below must not mask a real
+ # eval_error/eval_warning behind FALSE).
row <- rvs[j, , drop = FALSE]
+ real_eval_error[i] <- isTRUE(rvs$eval_error[j])
+ real_eval_warning[i] <- isTRUE(rvs$eval_warning[j])
old_key <- as.character(rvs$i[j])
if (!is.null(old_extracts[[old_key]])) {
new_extracts[[as.character(i)]] <- old_extracts[[old_key]]
}
} else {
# Column the targeted agent did not validate (it passed): synthesise a
- # passing row from the real-row template.
+ # passing row from the real-row template. Every count/verdict field is
+ # set authoritatively from coverage in the vectorised block below.
row <- template
- row$eval_error <- FALSE
- row$eval_warning <- FALSE
- row$n <- as.numeric(coverage$n[i])
- row$n_passed <- as.numeric(coverage$n[i] - coverage$n_failed[i])
- row$n_failed <- as.numeric(coverage$n_failed[i])
- row$f_passed <- if (coverage$n[i] > 0) row$n_passed / coverage$n[i] else 1
- row$f_failed <- if (coverage$n[i] > 0) coverage$n_failed[i] / coverage$n[i] else 0
- row$all_passed <- coverage$n_failed[i] == 0L
- row$warn <- FALSE
- row$stop <- FALSE
- row$notify <- FALSE
}
row$column <- list(coverage$column[i])
row$label <- coverage$check[i]
@@ -113,8 +114,8 @@ build_report_agent <- function(coverage, label, lang = "fr", locale = "fr_FR",
new_vs$warn <- coverage$n_failed > 0L & new_vs$f_failed >= warn_at
new_vs$stop <- coverage$n_failed > 0L & new_vs$f_failed >= stop_at
new_vs$notify <- rep(FALSE, nrow(new_vs))
- new_vs$eval_error <- rep(FALSE, nrow(new_vs))
- new_vs$eval_warning <- rep(FALSE, nrow(new_vs))
+ new_vs$eval_error <- real_eval_error
+ new_vs$eval_warning <- real_eval_warning
real_agent$validation_set <- new_vs
real_agent$extracts <- new_extracts
return(real_agent)
@@ -179,17 +180,17 @@ mark_agent_interrogated <- function(agent) {
# Attach the lazy-report capability to a real interrogated agent: prefix the
# class (print dispatches to print.datadiff_report) and stash what is needed to
# render the full report, plus a (reference-semantics) environment for caching.
-as_datadiff_report <- function(reponse, coverage, label, lang, locale,
- warn_at = 1e-14, stop_at = 1e-14) {
- attr(reponse, "datadiff_coverage") <- coverage
- attr(reponse, "datadiff_label") <- label
- attr(reponse, "datadiff_lang") <- lang
- attr(reponse, "datadiff_locale") <- locale
- attr(reponse, "datadiff_warn_at") <- warn_at
- attr(reponse, "datadiff_stop_at") <- stop_at
- attr(reponse, "datadiff_render") <- new.env(parent = emptyenv())
- class(reponse) <- c("datadiff_report", class(reponse))
- reponse
+as_datadiff_report <- function(response, coverage, label, lang, locale,
+ warn_at = datadiff_default_warn_at, stop_at = datadiff_default_stop_at) {
+ attr(response, "datadiff_coverage") <- coverage
+ attr(response, "datadiff_label") <- label
+ attr(response, "datadiff_lang") <- lang
+ attr(response, "datadiff_locale") <- locale
+ attr(response, "datadiff_warn_at") <- warn_at
+ attr(response, "datadiff_stop_at") <- stop_at
+ attr(response, "datadiff_render") <- new.env(parent = emptyenv())
+ class(response) <- c("datadiff_report", class(response))
+ response
}
# Build (once) and memoize the pointblank agent report for a datadiff_report.
@@ -205,8 +206,8 @@ datadiff_render_report <- function(x) {
label = attr(x, "datadiff_label") %||% "datadiff report",
lang = attr(x, "datadiff_lang") %||% "fr",
locale = attr(x, "datadiff_locale") %||% "fr_FR",
- warn_at = attr(x, "datadiff_warn_at") %||% 1e-14,
- stop_at = attr(x, "datadiff_stop_at") %||% 1e-14,
+ warn_at = attr(x, "datadiff_warn_at") %||% datadiff_default_warn_at,
+ stop_at = attr(x, "datadiff_stop_at") %||% datadiff_default_stop_at,
real_agent = x
)
)
@@ -220,7 +221,7 @@ datadiff_render_report <- function(x) {
#' pointblank-style report (HTML in interactive sessions / viewer). The report
#' is built on first print and memoized.
#'
-#' @param x A `datadiff_report` (the `reponse` element of a comparison result).
+#' @param x A `datadiff_report` (the `response` element of a comparison result).
#' @param ... Unused.
#' @return `x`, invisibly.
#' @exportS3Method print datadiff_report
@@ -243,26 +244,81 @@ print.datadiff_report <- function(x, ...) {
#' @param res The list returned by [compare_datasets_from_yaml()].
#' @param file Optional path to write the report to as a self-contained HTML
#' file (via [pointblank::export_report()]). When `NULL`, nothing is written.
+#' @param extracts_dir Optional directory where the failing-row extracts are
+#' also written as plain CSV files, one per failing validation step, named
+#' `extract__.csv` with a zero-padded 4-digit step number
+#' (e.g. `extract_0002_price.csv`). Column names are sanitized for the
+#' file system: any character outside `[A-Za-z0-9_.-]` becomes `_`. The CSV buttons inside the HTML report are
+#' `data:` URI downloads, which some viewers block (Positron / Posit
+#' Workbench webview): files on disk are the robust alternative. The
+#' directory is created when needed; nothing is created when there is no
+#' extract (all-pass comparison or `extract_failed = FALSE`).
#' @return The pointblank agent report object, invisibly.
#' @export
-datadiff_report_html <- function(res, file = NULL) {
+datadiff_report_html <- function(res, file = NULL, extracts_dir = NULL) {
coverage <- res$coverage
if (is.null(coverage)) {
stop("`res` has no `coverage`; was it produced by compare_datasets_from_yaml()?")
}
- reponse <- res$reponse
- agent <- build_report_agent(
- coverage = coverage,
- label = attr(reponse, "datadiff_label") %||% "datadiff report",
- lang = attr(reponse, "datadiff_lang") %||% "fr",
- locale = attr(reponse, "datadiff_locale") %||% "fr_FR",
- warn_at = attr(reponse, "datadiff_warn_at") %||% 1e-14,
- stop_at = attr(reponse, "datadiff_stop_at") %||% 1e-14,
- real_agent = reponse
- )
- report <- pointblank::get_agent_report(agent)
+ response <- res[["response"]]
+ if (is.null(response)) {
+ # A result saved by a version where the field was still named `reponse`
+ response <- res[["reponse"]]
+ }
+ report <- if (inherits(response, "datadiff_report")) {
+ # Share the print() memoization: read the cached report when a print
+ # already built it, and feed the cache otherwise
+ datadiff_render_report(response)
+ } else {
+ # No lazy-report wrapper (hand-assembled res): one-shot synthetic build
+ pointblank::get_agent_report(build_report_agent(
+ coverage = coverage,
+ label = "datadiff report",
+ real_agent = response
+ ))
+ }
if (!is.null(file)) {
pointblank::export_report(report, filename = file, quiet = TRUE)
}
+ if (!is.null(extracts_dir)) {
+ write_extract_csvs(response, extracts_dir = extracts_dir)
+ }
invisible(report)
}
+
+# Write each failing-step extract of an interrogated agent as a CSV file.
+# Returns the written paths, invisibly.
+write_extract_csvs <- function(response, extracts_dir) {
+ extracts <- tryCatch(
+ pointblank::get_data_extracts(response),
+ error = function(e) {
+ warning(sprintf(
+ "extracts_dir: could not read the data extracts (%s); no CSV written.",
+ conditionMessage(e)
+ ), call. = FALSE)
+ list()
+ }
+ )
+ if (length(extracts) == 0) {
+ return(invisible(character(0)))
+ }
+ if (!dir.exists(extracts_dir)) {
+ dir.create(extracts_dir, recursive = TRUE)
+ }
+ vs <- response$validation_set
+ paths <- vapply(X = names(extracts), FUN = function(nm) {
+ i <- as.integer(nm)
+ # Look the step up by its id (vs$i), not by row position: contiguity of
+ # the validation set is an implicit pointblank invariant, not a contract
+ col <- report_underlying_col(vs$column[[match(i, vs$i)]][1])
+ safe_col <- gsub("[^A-Za-z0-9_.-]", "_", x = col)
+ path <- file.path(
+ extracts_dir,
+ sprintf("extract_%04d_%s.csv", i, safe_col)
+ )
+ utils::write.csv(as.data.frame(extracts[[nm]]), file = path,
+ row.names = FALSE, fileEncoding = "UTF-8")
+ path
+ }, FUN.VALUE = character(1), USE.NAMES = FALSE)
+ invisible(paths)
+}
diff --git a/R/tolerance.R b/R/tolerance.R
index d96e70b..edfba55 100644
--- a/R/tolerance.R
+++ b/R/tolerance.R
@@ -21,30 +21,35 @@
#' @param na_equal Logical; whether NA/NaN on both sides count as equal.
#' @return A list with numeric vectors \code{absdiff}, \code{thresh} and the
#' logical vector \code{ok}.
+#' @note When both compared columns are integer, \code{cand - ref} is
+#' integer subtraction: a difference beyond \code{.Machine$integer.max}
+#' overflows to NA with a warning (and NA counts as a failure). Cast such
+#' columns to double upstream if their differences can approach that range.
#' @noRd
compute_tolerance_col <- function(cand_vals, ref_vals, abs_tol, rel_tol, na_equal) {
+ # is.na() covers NaN in R, so the NA masks subsume the NaN ones: NaN-specific
+ # masks would be redundant (both_nan is a subset of both_na, one_nan of
+ # one_na) and are not computed.
both_na <- is.na(cand_vals) & is.na(ref_vals)
one_na <- is.na(cand_vals) | is.na(ref_vals)
both_inf <- is.infinite(cand_vals) & is.infinite(ref_vals)
same_inf <- both_inf & (sign(cand_vals) == sign(ref_vals))
- both_nan <- is.nan(cand_vals) & is.nan(ref_vals)
- one_nan <- is.nan(cand_vals) | is.nan(ref_vals)
one_inf <- is.infinite(cand_vals) | is.infinite(ref_vals)
absdiff <- abs(cand_vals - ref_vals)
absdiff[same_inf] <- 0
- thresh <- abs_tol + rel_tol * abs(ref_vals)
+ abs_ref <- abs(ref_vals)
+ thresh <- abs_tol + rel_tol * abs_ref
- fp_correction <- 8 * .Machine$double.eps * abs(ref_vals)
+ fp_correction <- 8 * .Machine$double.eps * abs_ref
fp_correction[!is.finite(fp_correction)] <- 0
within_tol <- absdiff <= thresh + fp_correction
if (na_equal) {
- ok <- both_na | both_nan | same_inf |
- (!one_na & !one_nan & !one_inf & within_tol)
+ ok <- both_na | same_inf | (!one_na & !one_inf & within_tol)
} else {
- ok <- same_inf | (!one_na & !one_nan & !one_inf & within_tol)
+ ok <- same_inf | (!one_na & !one_inf & within_tol)
}
list(absdiff = absdiff, thresh = thresh, ok = ok)
@@ -52,12 +57,19 @@ compute_tolerance_col <- function(cand_vals, ref_vals, abs_tol, rel_tol, na_equa
#' Within-tolerance boolean for one column (hot path, ok only)
#'
-#' Returns ONLY the boolean within-tolerance vector (not absdiff/thresh), with a
-#' fast path for the common case of a column with no NA/NaN/Inf: the dozen
-#' special-value passes of `compute_tolerance_col()` are skipped and the result
-#' reduces to `abs(cand - ref) <= thresh + fp`. Falls back to the full kernel
-#' (taking only its `$ok`) when special values are present, so the result is
-#' identical to `compute_tolerance_col(...)$ok` in every case.
+#' Returns ONLY the boolean within-tolerance vector (not absdiff/thresh), with
+#' two fast paths:
+#' - no NA/NaN/Inf at all: the result reduces to
+#' `abs(cand - ref) <= thresh + fp` (3 vector passes);
+#' - NA and/or NaN present but no infinity (the most common real-data case):
+#' the raw comparison yields NA exactly where an NA or NaN is involved, so
+#' one `ok[is.na(ok)] <- FALSE` pass plus the two-sided correction
+#' reproduces the kernel at a fraction of its passes (`is.na()` covers NaN,
+#' so NaN follows the NA rules identically on both paths).
+#'
+#' Only infinities fall back to the full kernel (taking only its `$ok`;
+#' `abs(Inf - Inf)` is NaN but same-sign infinities must PASS), so the result
+#' is identical to `compute_tolerance_col(...)$ok` in every case.
#'
#' @inheritParams compute_tolerance_col
#' @return Logical vector, the same as `compute_tolerance_col(...)$ok`.
@@ -65,21 +77,39 @@ compute_tolerance_col <- function(cand_vals, ref_vals, abs_tol, rel_tol, na_equa
compute_tolerance_ok <- function(cand_vals, ref_vals, abs_tol, rel_tol, na_equal) {
if (!anyNA(cand_vals) && !anyNA(ref_vals) &&
all(is.finite(cand_vals)) && all(is.finite(ref_vals))) {
- thresh <- abs_tol + rel_tol * abs(ref_vals)
- fp <- 8 * .Machine$double.eps * abs(ref_vals)
+ abs_ref <- abs(ref_vals)
+ thresh <- abs_tol + rel_tol * abs_ref
+ fp <- 8 * .Machine$double.eps * abs_ref
return(abs(cand_vals - ref_vals) <= thresh + fp)
}
+ if (!any(is.infinite(cand_vals)) && !any(is.infinite(ref_vals))) {
+ # NA/NaN present but no Inf. NaN needs no dedicated guard: is.na() covers
+ # NaN in R, so NaN follows the NA rules identically here and in the
+ # kernel. Only Inf diverges (abs(Inf - Inf) is NaN, but same-sign
+ # infinities must PASS), hence the infinity-only fallback test.
+ abs_ref <- abs(ref_vals)
+ thresh <- abs_tol + rel_tol * abs_ref
+ fp <- 8 * .Machine$double.eps * abs_ref
+ ok <- abs(cand_vals - ref_vals) <= thresh + fp
+ # NA wherever an NA/NaN was involved: a one-sided one is a difference...
+ ok[is.na(ok)] <- FALSE
+ # ...and a two-sided one follows na_equal
+ if (na_equal) {
+ ok[is.na(cand_vals) & is.na(ref_vals)] <- TRUE
+ }
+ return(ok)
+ }
compute_tolerance_col(cand_vals, ref_vals, abs_tol, rel_tol, na_equal)$ok
}
#' Add only the `__ok` tolerance columns (hot path)
#'
-#' Like [add_tolerance_columns()] but materialises only the boolean `__ok`
+#' Like `add_tolerance_columns()` but materialises only the boolean `__ok`
#' columns - which alone determine the verdict - in a single bind. The
#' `__absdiff` / `__thresh` diagnostic columns are not produced here:
#' no verdict logic reads them, and on the all-pass fast path there are no
#' extracts to surface them in. On the failure path they are re-added for the
-#' failing columns only by [add_diff_columns()], so the failing-row extracts
+#' failing columns only by `add_diff_columns()`, so the failing-row extracts
#' keep the explicit measured deviations at a cost proportional to the failing
#' columns rather than the table width.
#'
@@ -92,15 +122,15 @@ add_ok_columns <- function(cmp, tol_cols, col_rules, ref_suffix, na_equal) {
}
ok_cols <- vector("list", length(tol_cols))
for (i in seq_along(tol_cols)) {
- c <- tol_cols[i]
+ col_nm <- tol_cols[i]
ok_cols[[i]] <- compute_tolerance_ok(
- cand_vals = cmp[[c]], ref_vals = cmp[[paste0(c, ref_suffix)]],
- abs_tol = col_rules[[c]][["abs"]] %||% 0,
- rel_tol = col_rules[[c]][["rel"]] %||% 0,
+ cand_vals = cmp[[col_nm]], ref_vals = cmp[[paste0(col_nm, ref_suffix)]],
+ abs_tol = col_rules[[col_nm]][["abs"]] %||% 0,
+ rel_tol = col_rules[[col_nm]][["rel"]] %||% 0,
na_equal = na_equal
)
}
- names(ok_cols) <- paste0(tol_cols, "__ok")
+ names(ok_cols) <- datadiff_ok_col(tol_cols)
cbind(cmp, list2DF(ok_cols))
}
@@ -111,7 +141,7 @@ add_ok_columns <- function(cmp, tol_cols, col_rules, ref_suffix, na_equal) {
#' failing-row extracts - `pointblank::get_data_extracts()`, the HTML report and
#' its CSV download - show the explicit gap next to the candidate and reference
#' values, as they did up to 0.4.7. The `__ok` verdict columns are expected
-#' to exist already (see [add_ok_columns()]) and are not touched. Cost is
+#' to exist already (see `add_ok_columns()`) and are not touched. Cost is
#' proportional to the number of columns passed, so the all-pass fast path and
#' the passing majority of columns pay nothing.
#'
@@ -126,11 +156,11 @@ add_diff_columns <- function(cmp, tol_cols, col_rules, ref_suffix, na_equal) {
absdiff_cols <- vector("list", m)
thresh_cols <- vector("list", m)
for (i in seq_len(m)) {
- c <- tol_cols[i]
+ col_nm <- tol_cols[i]
blocks <- compute_tolerance_col(
- cand_vals = cmp[[c]], ref_vals = cmp[[paste0(c, ref_suffix)]],
- abs_tol = col_rules[[c]][["abs"]] %||% 0,
- rel_tol = col_rules[[c]][["rel"]] %||% 0,
+ cand_vals = cmp[[col_nm]], ref_vals = cmp[[paste0(col_nm, ref_suffix)]],
+ abs_tol = col_rules[[col_nm]][["abs"]] %||% 0,
+ rel_tol = col_rules[[col_nm]][["rel"]] %||% 0,
na_equal = na_equal
)
absdiff_cols[[i]] <- blocks$absdiff
@@ -146,19 +176,34 @@ add_diff_columns <- function(cmp, tol_cols, col_rules, ref_suffix, na_equal) {
#' On the lazy path, building the per-column tolerance/equality booleans with
#' `dplyr::mutate()` is O(expressions) on the R side (dbplyr query construction
#' and SQL rendering dominate; e.g. ~60 s of 64 s for 300 columns). This builds
-#' the same boolean columns in a single templated SQL `SELECT`, leaving DuckDB to
-#' execute. The CASE WHEN logic reproduces exactly the `dplyr::case_when` used
-#' previously (NULL handling and IEEE 754 fp correction inlined), so the lazy
-#' verdict is unchanged.
+#' the same boolean columns in a single templated SQL `SELECT`, leaving the
+#' database to execute.
+#'
+#' The CASE WHEN logic reproduces the R tolerance kernel
+#' (`compute_tolerance_col`) including its NaN/Inf semantics: same-sign
+#' infinities pass, a one-sided NA/NaN/Inf fails, NA/NaN on both sides follows
+#' `na_equal`. SQL NULL only covers R's NA; NaN is a regular float in DuckDB
+#' (ordered above everything, `NaN = NaN` true), so NaN detection uses
+#' `isnan()` there. Backends without NaN storage (SQLite turns NaN into NULL
+#' on insert) fall back to the NULL rules; infinity detection uses a
+#' `> DBL_MAX` comparison where `isinf()` is unavailable. Known limitation:
+#' NaN detection is only wired for DuckDB (the tested lazy backend). A
+#' non-DuckDB backend that does store NaN (e.g. PostgreSQL) keeps the
+#' NULL-only rules for NaN, i.e. the pre-fix semantics on those values.
#'
#' @param cmp A lazy table (the join of candidate and reference).
#' @param tol_cols,eq_cols Tolerance / equality column names.
#' @param col_rules Per-column rules (abs / rel).
#' @param ref_suffix Reference-column suffix.
#' @param na_equal Logical; NA equality semantics.
+#' @param eq_num_cols Subset of `eq_cols` holding numeric data: their equality
+#' CASE gets the NaN-aware NA rules (matching the local path, where
+#' `is.na(NaN)` is TRUE). Non-numeric columns must not receive `isnan()`
+#' (type error in SQL).
#' @return A lazy table with the `__ok` / `__eq` columns added.
#' @noRd
-add_bool_cols_sql <- function(cmp, tol_cols, eq_cols, col_rules, ref_suffix, na_equal) {
+add_bool_cols_sql <- function(cmp, tol_cols, eq_cols, col_rules, ref_suffix,
+ na_equal, eq_num_cols = character(0)) {
if (length(tol_cols) == 0 && length(eq_cols) == 0) {
return(cmp)
}
@@ -168,35 +213,77 @@ add_bool_cols_sql <- function(cmp, tol_cols, eq_cols, col_rules, ref_suffix, na_
num <- function(x) sprintf("%.17g", x)
fp_eps <- 8 * .Machine$double.eps
- case_bool <- function(cc, rc, cond) {
- if (na_equal) {
- sprintf(paste0("CASE WHEN %s IS NULL AND %s IS NULL THEN TRUE ",
- "WHEN %s IS NULL OR %s IS NULL THEN FALSE ",
- "WHEN %s THEN TRUE ELSE FALSE END"),
- cc, rc, cc, rc, cond)
+ is_duckdb <- inherits(con, "duckdb_connection")
+ nan_sql <- function(x) {
+ if (is_duckdb) {
+ sprintf("isnan(%s)", x)
+ } else {
+ "FALSE"
+ }
+ }
+ inf_sql <- function(x) {
+ if (is_duckdb) {
+ sprintf("isinf(%s)", x)
} else {
- sprintf(paste0("CASE WHEN %s IS NULL OR %s IS NULL THEN FALSE ",
- "WHEN %s THEN TRUE ELSE FALSE END"),
- cc, rc, cond)
+ sprintf("(%s > 1.7976931348623157e308 OR %s < -1.7976931348623157e308)",
+ x, x)
}
}
+ # R's is.na(): SQL NULL or NaN
+ na_like <- function(x) {
+ sprintf("(%s IS NULL OR %s)", x, nan_sql(x))
+ }
+ na_equal_lit <- if (na_equal) "TRUE" else "FALSE"
- tol_exprs <- vapply(X = tol_cols, FUN = function(c) {
- cc <- q(c)
- rc <- q(paste0(c, ref_suffix))
- at <- col_rules[[c]][["abs"]] %||% 0
- rt <- col_rules[[c]][["rel"]] %||% 0
+ # Tolerance kernel semantics: two-sided NA/NaN -> na_equal; one-sided
+ # NA/NaN -> FALSE; same-sign infinities -> TRUE; remaining infinity
+ # (one-sided or opposite signs) -> FALSE; else the finite comparison.
+ case_tol <- function(cc, rc, cond) {
+ sprintf(paste0(
+ "CASE WHEN %s AND %s THEN %s ",
+ "WHEN %s OR %s THEN FALSE ",
+ "WHEN %s AND %s AND ((%s > 0) = (%s > 0)) THEN TRUE ",
+ "WHEN %s OR %s THEN FALSE ",
+ "WHEN %s THEN TRUE ELSE FALSE END"),
+ na_like(cc), na_like(rc), na_equal_lit,
+ na_like(cc), na_like(rc),
+ inf_sql(cc), inf_sql(rc), cc, rc,
+ inf_sql(cc), inf_sql(rc),
+ cond
+ )
+ }
+
+ # Equality semantics: two-sided NA(-like) -> na_equal; one-sided -> FALSE;
+ # else plain SQL equality (infinities compare correctly there). NaN joins
+ # the NA rules only for numeric columns (nan_aware).
+ case_eq <- function(cc, rc, nan_aware) {
+ n <- if (nan_aware) na_like else function(x) sprintf("%s IS NULL", x)
+ sprintf(paste0(
+ "CASE WHEN %s AND %s THEN %s ",
+ "WHEN %s OR %s THEN FALSE ",
+ "WHEN %s = %s THEN TRUE ELSE FALSE END"),
+ n(cc), n(rc), na_equal_lit,
+ n(cc), n(rc),
+ cc, rc
+ )
+ }
+
+ tol_exprs <- vapply(X = tol_cols, FUN = function(col_nm) {
+ cc <- q(col_nm)
+ rc <- q(paste0(col_nm, ref_suffix))
+ at <- col_rules[[col_nm]][["abs"]] %||% 0
+ rt <- col_rules[[col_nm]][["rel"]] %||% 0
within <- sprintf("ABS(%s - %s) <= (%s + %s * ABS(%s)) + %s * ABS(%s)",
cc, rc, num(at), num(rt), rc, num(fp_eps), rc)
- sprintf("%s AS %s", case_bool(cc, rc, within), q(paste0(c, "__ok")))
+ sprintf("%s AS %s", case_tol(cc, rc, cond = within), q(datadiff_ok_col(col_nm)))
}, FUN.VALUE = character(1), USE.NAMES = FALSE)
- eq_exprs <- vapply(X = eq_cols, FUN = function(c) {
- cc <- q(c)
- rc <- q(paste0(c, ref_suffix))
+ eq_exprs <- vapply(X = eq_cols, FUN = function(col_nm) {
+ cc <- q(col_nm)
+ rc <- q(paste0(col_nm, ref_suffix))
sprintf("%s AS %s",
- case_bool(cc, rc, sprintf("%s = %s", cc, rc)),
- q(paste0(c, "__eq")))
+ case_eq(cc, rc, nan_aware = col_nm %in% eq_num_cols),
+ q(datadiff_eq_col(col_nm)))
}, FUN.VALUE = character(1), USE.NAMES = FALSE)
exprs <- c(tol_exprs, eq_exprs)
@@ -229,32 +316,35 @@ add_tolerance_columns <- function(cmp, tol_cols, col_rules, ref_suffix, na_equal
# (one for __absdiff + __thresh, one for __ok) regardless of how many columns
# there are. Each individual mutate() adds one lazy_query node to the dbplyr
# plan; with 100+ columns the nesting would exceed R's expression stack limit.
- if (length(tol_cols) == 0) return(cmp)
+ if (length(tol_cols) == 0) {
+ return(cmp)
+ }
+
+ # IEEE 754: floating-point subtraction introduces rounding errors
+ # proportional to the magnitude of the operands, not to the threshold.
+ # e.g. 100.01 - 100.00 = 0.0100000000000051 > 0.01 in double precision.
+ # Adding a few ULPs of the reference magnitude absorbs this error without
+ # meaningfully widening the user-specified tolerance.
+ fp_eps <- 8 * .Machine$double.eps
exprs_diff <- list()
exprs_ok <- list()
- for (c in tol_cols) {
- reference_c <- paste0(c, ref_suffix)
- abs_tol <- col_rules[[c]][["abs"]] %||% 0
- rel_tol <- col_rules[[c]][["rel"]] %||% 0
- c_sym <- dplyr::sym(c)
+ for (col_nm in tol_cols) {
+ reference_c <- paste0(col_nm, ref_suffix)
+ abs_tol <- col_rules[[col_nm]][["abs"]] %||% 0
+ rel_tol <- col_rules[[col_nm]][["rel"]] %||% 0
+ c_sym <- dplyr::sym(col_nm)
rc_sym <- dplyr::sym(reference_c)
- absdiff_col <- paste0(c, "__absdiff")
- thresh_col <- paste0(c, "__thresh")
- ok_col <- paste0(c, "__ok")
+ absdiff_col <- paste0(col_nm, "__absdiff")
+ thresh_col <- paste0(col_nm, "__thresh")
+ ok_col <- datadiff_ok_col(col_nm)
absdiff_sym <- dplyr::sym(absdiff_col)
thresh_sym <- dplyr::sym(thresh_col)
exprs_diff[[absdiff_col]] <- rlang::expr(abs(!!c_sym - !!rc_sym))
exprs_diff[[thresh_col]] <- rlang::expr(!!abs_tol + !!rel_tol * abs(!!rc_sym))
- # IEEE 754: floating-point subtraction introduces rounding errors
- # proportional to the magnitude of the operands, not to the threshold.
- # e.g. 100.01 - 100.00 = 0.0100000000000051 > 0.01 in double precision.
- # Adding a few ULPs of the reference magnitude absorbs this error without
- # meaningfully widening the user-specified tolerance.
- fp_eps <- 8 * .Machine$double.eps
if (na_equal) {
exprs_ok[[ok_col]] <- rlang::expr(dplyr::case_when(
is.na(!!c_sym) & is.na(!!rc_sym) ~ TRUE,
@@ -293,11 +383,11 @@ add_tolerance_columns <- function(cmp, tol_cols, col_rules, ref_suffix, na_equal
ok_cols <- vector("list", m)
for (i in seq_len(m)) {
- c <- tol_cols[i]
+ col_nm <- tol_cols[i]
blocks <- compute_tolerance_col(
- cand_vals = cmp[[c]], ref_vals = cmp[[paste0(c, ref_suffix)]],
- abs_tol = col_rules[[c]][["abs"]] %||% 0,
- rel_tol = col_rules[[c]][["rel"]] %||% 0,
+ cand_vals = cmp[[col_nm]], ref_vals = cmp[[paste0(col_nm, ref_suffix)]],
+ abs_tol = col_rules[[col_nm]][["abs"]] %||% 0,
+ rel_tol = col_rules[[col_nm]][["rel"]] %||% 0,
na_equal = na_equal
)
absdiff_cols[[i]] <- blocks$absdiff
@@ -307,7 +397,7 @@ add_tolerance_columns <- function(cmp, tol_cols, col_rules, ref_suffix, na_equal
names(absdiff_cols) <- paste0(tol_cols, "__absdiff")
names(thresh_cols) <- paste0(tol_cols, "__thresh")
- names(ok_cols) <- paste0(tol_cols, "__ok")
+ names(ok_cols) <- datadiff_ok_col(tol_cols)
cbind(cmp, list2DF(c(absdiff_cols, thresh_cols, ok_cols)))
}
diff --git a/R/utils.R b/R/utils.R
index f3fc49e..8bde131 100644
--- a/R/utils.R
+++ b/R/utils.R
@@ -1,24 +1,36 @@
-#' Operator for default values
-#'
-#' Returns y if x is NULL, otherwise returns x
-#' @name or_operator
-#' @param x Value to check
-#' @param y Default value to return if x is NULL
-#' @return x if not NULL, otherwise y
-#' @export
-`%||%` <- function(x, y) if (is.null(x)) y else x
+# Default-value operator: y if x is NULL, otherwise x. Internal: exporting it
+# would mask rlang's and base R's (R >= 4.4) own %||% at load time.
+`%||%` <- function(x, y) {
+ if (is.null(x)) {
+ y
+ } else {
+ x
+ }
+}
get_col_names <- function(x) {
nms <- colnames(x)
if (length(nms) == 0) names(x) else nms
}
+is_arrow <- function(x) {
+ inherits(x, c("ArrowObject", "arrow_dplyr_query"))
+}
+
is_non_local <- function(x) {
- inherits(x, "tbl_lazy") || inherits(x, c("ArrowObject", "arrow_dplyr_query"))
+ inherits(x, "tbl_lazy") || is_arrow(x)
}
-is_arrow <- function(x) {
- inherits(x, c("ArrowObject", "arrow_dplyr_query"))
+# Package-local mutable state (temp-table counter).
+.datadiff_state <- new.env(parent = emptyenv())
+
+# Unique temp-table name: per-process counter + PID, so that two calls in the
+# same session never collide (a clock-derived name could repeat within the
+# same millisecond) and two R processes sharing a database cannot either.
+datadiff_tmp_table_name <- function() {
+ counter <- get0("tmp_tbl_counter", envir = .datadiff_state, ifnotfound = 0L) + 1L
+ assign("tmp_tbl_counter", value = counter, envir = .datadiff_state)
+ sprintf("datadiff_tmp_%d_%d", Sys.getpid(), counter)
}
# Convert an Arrow dataset to a DuckDB tbl_lazy on `con`.
@@ -33,10 +45,19 @@ arrow_dataset_to_duckdb <- function(ds, con, tbl_name) {
})
if (!is.null(files) && length(files) > 0 &&
all(grepl("\\.parquet$", files, ignore.case = TRUE))) {
- paths_sql <- paste0("'", gsub("\\\\", "/", files), "'", collapse = ", ")
+ # dbQuoteString: a path containing a quote (l'export.parquet) must not
+ # break the SQL. union_by_name: bind the files by column NAME, matching
+ # the unified-schema guarantee of the arrow::to_duckdb() fallback even
+ # when the physical column order differs across files.
+ quoted_paths <- vapply(X = gsub("\\\\", "/", files), FUN = function(p) {
+ as.character(DBI::dbQuoteString(con, x = p))
+ }, FUN.VALUE = character(1), USE.NAMES = FALSE)
DBI::dbExecute(con, paste0(
- "CREATE OR REPLACE TEMP TABLE \"", tbl_name, "\" AS ",
- "SELECT * FROM read_parquet([", paths_sql, "])"
+ "CREATE OR REPLACE TEMP TABLE ",
+ as.character(DBI::dbQuoteIdentifier(con, x = tbl_name)), " AS ",
+ "SELECT * FROM read_parquet([",
+ paste(quoted_paths, collapse = ", "),
+ "], union_by_name = true)"
))
return(dplyr::tbl(con, tbl_name))
}
diff --git a/R/validation.R b/R/validation.R
index 38ca862..32ca2f7 100644
--- a/R/validation.R
+++ b/R/validation.R
@@ -1,6 +1,8 @@
lazy_nrow <- function(x) {
if (is_non_local(x)) {
- dplyr::pull(dplyr::collect(dplyr::count(x)), n)
+ # Explicit count name: do not rely on count()'s implicit "n"/"nn" naming
+ count_col <- "..datadiff_n"
+ dplyr::pull(dplyr::collect(dplyr::count(x, name = count_col)), var = count_col)
} else {
nrow(x)
}
@@ -15,6 +17,12 @@ lazy_nrow <- function(x) {
#' @param data_reference_p Preprocessed reference dataframe
#' @param data_candidate_p Preprocessed candidate dataframe
#' @param rules List of validation rules containing row_validation settings
+#' @param count_rows Logical; when `FALSE`, the row counts are not computed and
+#' `ref_count`/`cand_count` are `NA`. On lazy tables each count is a full
+#' `COUNT(*)` scan, wasted when nothing consumes it (keyed comparison with
+#' `check_count: false`). Ignored when the rules activate `check_count`:
+#' the counts are then always computed, so the returned structure stays
+#' consistent (an active check never sees `NA` counts).
#' @return A list with validation information for pointblank integration
#' @examples
#' ref <- data.frame(a = 1:3)
@@ -22,11 +30,14 @@ lazy_nrow <- function(x) {
#' rules <- list(row_validation = list(check_count = TRUE, expected_count = 3, tolerance = 0))
#' validate_row_counts(ref, cand, rules)
#' @export
-validate_row_counts <- function(data_reference_p, data_candidate_p, rules) {
+validate_row_counts <- function(data_reference_p, data_candidate_p, rules,
+ count_rows = TRUE) {
+ check_count <- isTRUE(rules$row_validation$check_count)
+ do_count <- count_rows || check_count
list(
- check_count = isTRUE(rules$row_validation$check_count),
- ref_count = lazy_nrow(data_reference_p),
- cand_count = lazy_nrow(data_candidate_p),
+ check_count = check_count,
+ ref_count = if (do_count) lazy_nrow(data_reference_p) else NA_integer_,
+ cand_count = if (do_count) lazy_nrow(data_candidate_p) else NA_integer_,
expected_count = rules$row_validation$expected_count,
tolerance = rules$row_validation$tolerance %||% 0
)
diff --git a/README.Rmd b/README.Rmd
index f0f9712..0c8d68d 100644
--- a/README.Rmd
+++ b/README.Rmd
@@ -46,16 +46,20 @@ candidate <- data.frame(
category = c("a", "b", "c") # Different case
)
-# Generate a rules template
+# Zero configuration: near-exact rules generated from the reference structure
+result <- compare_datasets_from_yaml(reference, candidate, key = "id")
+result$all_passed # FALSE here: exact rules reject the differences above
+
+# To tune tolerances, generate a rules template and edit it
+rules_path <- tempfile(fileext = ".yaml")
write_rules_template(reference, key = "id",
- path = "validation_rules.yaml",
+ path = rules_path,
numeric_abs = 0.01,
character_case_insensitive = TRUE)
-# Edit the rules file to configure validation (e.g., set tolerances, case sensitivity)
-
-# Compare datasets
-result <- compare_datasets_from_yaml(reference, candidate, path = "validation_rules.yaml")
+# Compare datasets with the tuned rules (key stays explicit)
+result <- compare_datasets_from_yaml(reference, candidate,
+ key = "id", path = rules_path)
result$all_passed # overall verdict (TRUE/FALSE)
result$coverage # one row per check performed: column, type, n, n_failed, status
@@ -63,7 +67,7 @@ result$summary # aggregate counts (n_checks, n_pass, n_fail, ...)
# Printing the response lazily renders the full pointblank-style report
# (built on demand, only when displayed):
-print(result$reponse)
+print(result$response)
# ...or export that report to a standalone HTML file:
datadiff_report_html(result, file = "report.html")
@@ -80,7 +84,7 @@ datadiff_report_html(result, file = "report.html")
- **Fast on wide tables**: an all-pass comparison short-circuits the per-column
pointblank agent, so validating hundreds/thousands of columns stays quick
- **Faithful coverage**: `result$coverage` always lists every check performed
- (even when everything passes), and `result$reponse` renders the full
+ (even when everything passes), and `result$response` renders the full
pointblank HTML report lazily, only when printed
## Dependencies
@@ -401,24 +405,11 @@ MIT License
## Dev part
-This `README` has been compiled on the
-
-```{r}
-Sys.time()
-```
-
-Here are the test & coverage results:
+Run the checks and the coverage locally (embedded outputs went stale as the
+package evolved, so this README no longer freezes them at compile time):
-```{r error = TRUE}
-devtools::check(quiet = TRUE)
-```
-
-```{r echo = FALSE}
-unloadNamespace("datadiff")
-```
-
-```{r error = TRUE}
-Sys.setenv("NOT_CRAN" = TRUE)
+```r
+devtools::check()
covr::package_coverage()
```
diff --git a/README.md b/README.md
index 3c3cee6..fe81665 100644
--- a/README.md
+++ b/README.md
@@ -37,16 +37,20 @@ candidate <- data.frame(
category = c("a", "b", "c") # Different case
)
-# Generate a rules template
+# Zero configuration: near-exact rules generated from the reference structure
+result <- compare_datasets_from_yaml(reference, candidate, key = "id")
+result$all_passed # FALSE here: exact rules reject the differences above
+
+# To tune tolerances, generate a rules template and edit it
+rules_path <- tempfile(fileext = ".yaml")
write_rules_template(reference, key = "id",
- path = "validation_rules.yaml",
+ path = rules_path,
numeric_abs = 0.01,
character_case_insensitive = TRUE)
-# Edit the rules file to configure validation (e.g., set tolerances, case sensitivity)
-
-# Compare datasets
-result <- compare_datasets_from_yaml(reference, candidate, path = "validation_rules.yaml")
+# Compare datasets with the tuned rules (key stays explicit)
+result <- compare_datasets_from_yaml(reference, candidate,
+ key = "id", path = rules_path)
result$all_passed # overall verdict (TRUE/FALSE)
result$coverage # one row per check performed: column, type, n, n_failed, status
@@ -54,7 +58,7 @@ result$summary # aggregate counts (n_checks, n_pass, n_fail, ...)
# Printing the response lazily renders the full pointblank-style report
# (built on demand, only when displayed):
-print(result$reponse)
+print(result$response)
# ...or export that report to a standalone HTML file:
datadiff_report_html(result, file = "report.html")
@@ -77,7 +81,7 @@ datadiff_report_html(result, file = "report.html")
per-column pointblank agent, so validating hundreds/thousands of
columns stays quick
- **Faithful coverage**: `result$coverage` always lists every check
- performed (even when everything passes), and `result$reponse` renders
+ performed (even when everything passes), and `result$response` renders
the full pointblank HTML report lazily, only when printed
## Dependencies
@@ -142,13 +146,13 @@ by_name:
`by_name` taking precedence. A column not listed in `by_name` uses its
`by_type` defaults unchanged.
-| Column | Effective rule | Source |
-|----|----|----|
-| `id` | `abs: 0` | `by_type.integer` |
-| `amount` | `abs: 0.01, rel: 0` | `by_name.amount` overrides `by_type.numeric` |
-| `unit_price` | `abs: 0.001, rel: 0.0001` | `by_name.unit_price` overrides `by_type.numeric` |
-| `category` | `case_insensitive: yes, trim: yes` | `by_name.category` overrides `by_type.character` |
-| `created_at` | `equal_mode: exact` | `by_type.date` (by_name is redundant here) |
+| Column | Effective rule | Source |
+|--------------|------------------------------------|--------------------------------------------------|
+| `id` | `abs: 0` | `by_type.integer` |
+| `amount` | `abs: 0.01, rel: 0` | `by_name.amount` overrides `by_type.numeric` |
+| `unit_price` | `abs: 0.001, rel: 0.0001` | `by_name.unit_price` overrides `by_type.numeric` |
+| `category` | `case_insensitive: yes, trim: yes` | `by_name.category` overrides `by_type.character` |
+| `created_at` | `equal_mode: exact` | `by_type.date` (by_name is redundant here) |
### Numeric tolerance: formula, edge effects, and best practices
@@ -162,10 +166,10 @@ threshold**:
The two parameters **add up**: they are not two independent guards.
-| Parameter | Role | Default value |
-|----|----|----|
-| `abs` | Absolute tolerance: fixed floor, independent of the magnitude of values | `1e-9` |
-| `rel` | Relative tolerance: fraction of the reference value added to the threshold | `0` |
+| Parameter | Role | Default value |
+|-----------|----------------------------------------------------------------------------|---------------|
+| `abs` | Absolute tolerance: fixed floor, independent of the magnitude of values | `1e-9` |
+| `rel` | Relative tolerance: fraction of the reference value added to the threshold | `0` |
#### Pure absolute mode (recommended by default)
@@ -201,11 +205,11 @@ by_type:
rel: 0.01 # tolerance of 1% of the reference value
```
-| Reference | Candidate | Difference | Threshold (1%) | Result |
-|----|---:|---:|---:|----|
-| 100.00 | 100.50 | 0.50 | 1.00 | OK |
-| 1 000 000.00 | 1 005 000.00 | 5 000 | 10 000 | OK |
-| 0.00 | 0.001 | 0.001 | **0** | **ERROR** (implicit division by zero) |
+| Reference | Candidate | Difference | Threshold (1%) | Result |
+|--------------|-------------:|-----------:|---------------:|---------------------------------------|
+| 100.00 | 100.50 | 0.50 | 1.00 | OK |
+| 1 000 000.00 | 1 005 000.00 | 5 000 | 10 000 | OK |
+| 0.00 | 0.001 | 0.001 | **0** | **ERROR** (implicit division by zero) |
> **Warning**: if the reference value is `0`, the relative threshold is
> `0`, so any difference, even tiny, will be detected as an error.
@@ -286,7 +290,7 @@ row_validation:
This validates that the candidate dataset has between 950 and 1050 rows
(1000 +/- 50).
-If `expected_count` is not specified, the reference dataset's row count
+If `expected_count` is not specified, the reference dataset’s row count
is used as the expected value.
## Comparing Parquet files (large datasets)
@@ -294,7 +298,7 @@ is used as the expected value.
`datadiff` can compare Parquet files that are too large to fit in RAM.
The recommended approach uses `arrow::open_dataset()` - **do not call
`arrow::to_duckdb()` yourself** before passing to `datadiff`; the
-package handles the Arrow -> DuckDB conversion internally with a single
+package handles the Arrow -\> DuckDB conversion internally with a single
connection.
### Recommended strategy: Arrow Dataset (lazy, out-of-core)
@@ -322,12 +326,12 @@ Internally, `compare_datasets_from_yaml()`:
1. Opens a private DuckDB connection (`fresh_con`).
2. Materialises each Parquet dataset as a DuckDB physical temp table
- via `read_parquet()` - all memory is managed by DuckDB's buffer
+ via `read_parquet()` - all memory is managed by DuckDB’s buffer
pool, so disk spilling works correctly.
3. Runs the full join + 125 boolean expressions as a single lazy SQL
query.
4. `dplyr::compute()` materialises only the slim boolean result table
- (~125 logical columns * N rows) - the wide source data is never
+ (~125 logical columns \* N rows) - the wide source data is never
loaded into R.
5. `dplyr::collect()` brings the slim boolean table (~few GB) into R
and passes a plain `data.frame` to pointblank - no live DuckDB
@@ -336,7 +340,7 @@ Internally, `compare_datasets_from_yaml()`:
### Memory tuning: `duckdb_memory_limit`
-DuckDB's default memory cap (80 % of total RAM) can leave insufficient
+DuckDB’s default memory cap (80 % of total RAM) can leave insufficient
headroom when R, Arrow, and the OS are already using significant memory.
The `duckdb_memory_limit` parameter controls how much RAM DuckDB may use
before spilling intermediate results to `tempdir()`:
@@ -365,12 +369,12 @@ limit only applies when Arrow datasets are used - it has no effect for
### Strategy comparison
-| Strategy | Input type | RAM usage | Requires |
-|----|----|----|----|
-| **Arrow Dataset** ✅ recommended | `arrow::open_dataset()` | Slim boolean table only (~few GB) | arrow, duckdb |
-| Arrow Table | `arrow::read_parquet(as_data_frame=FALSE)` | Same as Arrow Dataset | arrow, duckdb |
-| Lazy table (dbplyr) | `tbl(con, "table_name")` | Slim boolean table only | DBI, dbplyr |
-| `data.frame` | `read.csv()`, `readr::read_csv()`, etc. | Full data in RAM | - |
+| Strategy | Input type | RAM usage | Requires |
+|----------------------------------|--------------------------------------------|-----------------------------------|---------------|
+| **Arrow Dataset** ✅ recommended | `arrow::open_dataset()` | Slim boolean table only (~few GB) | arrow, duckdb |
+| Arrow Table | `arrow::read_parquet(as_data_frame=FALSE)` | Same as Arrow Dataset | arrow, duckdb |
+| Lazy table (dbplyr) | `tbl(con, "table_name")` | Slim boolean table only | DBI, dbplyr |
+| `data.frame` | `read.csv()`, `readr::read_csv()`, etc. | Full data in RAM | \- |
### What NOT to do
@@ -389,7 +393,7 @@ ref_df <- dplyr::collect(ds_ref) # loads full 4 GB into R RAM
DuckDB spills to `tempdir()` when the memory limit is reached. On
Windows this is typically `C:\Users\\AppData\Local\Temp`. Ensure
-that directory has sufficient free disk space (up to ~2-3* the size of
+that directory has sufficient free disk space (up to ~2-3\* the size of
your Parquet files in the worst case).
## Main Functions
@@ -421,36 +425,11 @@ MIT License
## Dev part
-This `README` has been compiled on the
-
-``` r
-Sys.time()
-#> [1] "2026-03-11 18:07:04 CET"
-```
-
-Here are the test & coverage results:
-
-``` r
-devtools::check(quiet = TRUE)
-#> ℹ Loading datadiff
-#> ── R CMD check results ───────────────────────────────────── datadiff 0.4.2 ────
-#> Duration: 4m 47.4s
-#>
-#> ❯ checking for future file timestamps ... NOTE
-#> unable to verify current time
-#>
-#> 0 errors ✔ | 0 warnings ✔ | 1 note ✖
-```
+Run the checks and the coverage locally (embedded outputs went stale as
+the package evolved, so this README no longer freezes them at compile
+time):
``` r
-Sys.setenv("NOT_CRAN" = TRUE)
+devtools::check()
covr::package_coverage()
-#> datadiff Coverage: 98.98%
-#> R/preprocessing.R: 97.22%
-#> R/compare_datasets_from_yaml.R: 98.58%
-#> R/data_types.R: 100.00%
-#> R/pointblank_setup.R: 100.00%
-#> R/tolerance.R: 100.00%
-#> R/utils.R: 100.00%
-#> R/validation.R: 100.00%
```
diff --git a/dev/RFC-api-0.5.md b/dev/RFC-api-0.5.md
new file mode 100644
index 0000000..1cd4404
--- /dev/null
+++ b/dev/RFC-api-0.5.md
@@ -0,0 +1,123 @@
+# RFC : surface d'API datadiff 0.5
+
+Statut : proposition a discuter (issue #33). Rien ici n'est implemente ;
+les quick wins non cassants identifies sont listes en fin de document avec
+leur issue de rattachement.
+
+## Constat
+
+La fonction centrale `compare_datasets_from_yaml()` a grossi par accretion :
+17 parametres a plat, un nom qui ne reflete plus l'usage (le YAML est
+optionnel depuis 0.1.5), un retour a 8 champs partiellement redondants, et un
+melange de langues (arguments anglais, champ historique `$reponse` en
+francais, renomme `$response` en 0.6.0 avec alias deprecie, rapport par
+defaut en francais, messages en anglais).
+
+## Proposition
+
+### 1. Nom et alias
+
+```r
+compare_datasets(reference, candidate, key = NULL, rules = NULL, ...)
+```
+
+- `compare_datasets()` devient le point d'entree documente.
+- `compare_datasets_from_yaml()` reste exporte comme alias retrocompatible
+ (one-liner qui delegue), documente dans une section "Legacy".
+- `reference`/`candidate` remplacent `data_reference`/`data_candidate`
+ (les anciens noms restent acceptes par l'alias).
+
+### 2. Objets d'options pour les passe-plats
+
+Cinq parametres sont du pur passe-plat vers `pointblank::interrogate()` et un
+n'agit que sur le chemin Arrow. Regroupement :
+
+```r
+# NB : lang/locale montres avec les defauts ACTUELS (0.4.x, fr/fr_FR) ;
+# la section 5 propose de basculer le defaut 0.5 vers en/en_US
+compare_datasets(
+ reference, candidate, key = NULL, rules = NULL,
+ extract = extract_opts(failed = TRUE, first_n = NULL, sample_n = NULL,
+ sample_frac = NULL, limit = 5000),
+ engine = engine_opts(duckdb_memory_limit = "8GB"),
+ lang = getOption("datadiff.lang", "fr"),
+ locale = getOption("datadiff.locale", "fr_FR")
+)
+```
+
+- `extract_opts()` / `engine_opts()` sont des constructeurs valides
+ (erreurs franches, defauts documentes en un seul endroit).
+- La signature centrale descend de 17 a ~8 parametres.
+
+### 3. Retour : classe `datadiff_result`
+
+Etat actuel : `all_passed` existe en 3 exemplaires (`$all_passed`,
+`$summary$all_passed`, `pointblank::all_passed($response)`) ; `$agent` n'est
+pas interroge (et il est factice sur le fast-path all-pass) sans usage
+utilisateur identifie. Depuis 0.6.0, le champ s'appelle `$response` (la
+classe `datadiff_result` et la mecanique de depreciation `$`/`[[` proposees
+ici existent deja ; `$reponse` reste lisible avec warning).
+
+Cible :
+
+```r
+res$passed # le verdict, une seule fois
+res$coverage # inchange
+res$summary # inchange (sans all_passed duplique)
+res$report # l'agent interroge (ex-$response), print() paresseux inchange
+ # NB : renommer response -> report imposerait une 2e migration
+ # aux utilisateurs ; a arbitrer sur l'issue #33
+res$applied_rules, res$missing_in_candidate, res$extra_in_candidate
+```
+
+- `$response` (et l'alias historique `$reponse`) et `$all_passed` restent
+ presents une version avec un warning de depreciation a l'acces (la methode
+ `$.datadiff_result` de 0.6.0 fournit deja la mecanique).
+- `$agent` est retire du contrat documente (garde interne si necessaire).
+
+### 4. warn_at / stop_at
+
+Les deux valent 1e-14 : WARN et STOP se declenchent toujours ensemble, le
+niveau WARN n'apporte rien. Proposition : un unique `fail_at = 1e-14`
+(fraction), les deux niveaux pointblank cales dessus ; `warn_at`/`stop_at`
+acceptes par l'alias legacy avec warning si differents.
+
+### 5. Langue
+
+- Deux ecoles : (a) tout anglais par defaut (`lang = "en"`), coherent avec
+ les messages ; (b) statu quo francais documente. La 0.4.4 avait annonce (a)
+ puis un commit l'a re-bascule sans NEWS (issue #26 : la doc est desormais
+ alignee sur le defaut reel "fr").
+- Proposition : basculer a `"en"` au moment du passage 0.5 (major-ish), via
+ `getOption("datadiff.lang", "en")`, avec entree NEWS Breaking changes.
+
+### 6. Messages
+
+Uniformiser sur le style des bons warnings existants (doublons de cles,
+type_mismatch : contexte + consequence + action). En particulier remplacer
+`message("key is missing")` par une note explicite unique documentant le mode
+positionnel, ou la supprimer (le mode positionnel est un choix legitime).
+
+### 7. write_rules_template()
+
+- 19 parametres au nommage incoherent (`na_equal_default` vs `numeric_abs`) :
+ harmoniser en `0.5` (`na_equal`, `numeric_abs`, ...) via l'alias.
+- Ne plus ecrire par defaut `rules.yaml` dans le repertoire courant :
+ `path` obligatoire ou defaut `tempfile()`.
+- `ref_suffix` : detail d'implementation, a retirer de la signature publique.
+
+## Cycle de depreciation
+
+1. 0.5.0 : nouvelle surface + alias retrocompatibles complets, warnings de
+ depreciation conditionnels (une fois par session), vignette reecrite sur la
+ nouvelle surface, annexe migration.
+2. 0.6.0 : warnings inconditionnels.
+3. 1.0.0 : retrait des alias.
+
+## Quick wins deja traites ou rattaches a d'autres issues
+
+- Precedence argument > YAML (issue #20, fait en 0.4.10).
+- Validation label / key / duckdb_memory_limit (issues #16/#20/#30, fait).
+- Contradictions NEWS %||% / lang / "comparaison" (issue #26).
+- Parametre mort cols_reference (issue #25).
+- equal_mode inerte (issue #27).
diff --git a/dev/bench-0.4.10/bench_one.R b/dev/bench-0.4.10/bench_one.R
new file mode 100644
index 0000000..16bfb36
--- /dev/null
+++ b/dev/bench-0.4.10/bench_one.R
@@ -0,0 +1,113 @@
+# Execute UN scenario du banc dans un processus R frais et ecrit une ligne
+# JSON (temps, verdict, memoire R) dans le fichier de sortie.
+# Usage : Rscript bench_one.R
+# Scenarios : local_green | local_na | local_fail | lazy_green | lazy_fail
+
+args <- commandArgs(trailingOnly = TRUE)
+libdir <- args[[1]]
+scenario <- args[[2]]
+out_file <- args[[3]]
+data_dir <- args[[4]]
+
+.libPaths(new = c(libdir, .libPaths()))
+suppressPackageStartupMessages({
+ library(datadiff, lib.loc = libdir)
+ library(dplyr)
+})
+ver <- as.character(utils::packageVersion("datadiff"))
+
+n_rows_local <- 200000L
+n_cols_local <- 300L
+
+make_local_ref <- function() {
+ set.seed(4242)
+ m <- matrix(
+ data = rnorm(n_rows_local * n_cols_local),
+ nrow = n_rows_local,
+ ncol = n_cols_local
+ )
+ df <- as.data.frame(m)
+ names(df) <- sprintf("c%03d", seq_len(n_cols_local))
+ df
+}
+
+result <- NULL
+con <- NULL
+
+if (scenario %in% c("local_green", "local_na", "local_fail")) {
+ ref <- make_local_ref()
+ cand <- ref
+ if (scenario == "local_na") {
+ # NA (sans Inf) sur les 150 premieres colonnes, ~5 % des cellules,
+ # aux memes positions des deux cotes : cible le fast-path #23.
+ set.seed(777)
+ for (j in seq_len(150)) {
+ idx <- sample.int(n_rows_local, size = n_rows_local %/% 20)
+ ref[[j]][idx] <- NA_real_
+ }
+ cand <- ref
+ }
+ if (scenario == "local_fail") {
+ set.seed(777)
+ for (j in seq_len(5)) {
+ idx <- sample.int(n_rows_local, size = 1000)
+ cand[[j]][idx] <- cand[[j]][idx] + 1
+ }
+ }
+
+ gc(reset = TRUE)
+ tm <- system.time({
+ res <- compare_datasets_from_yaml(
+ data_reference = ref,
+ data_candidate = cand
+ )
+ })
+} else {
+ suppressPackageStartupMessages(library(duckdb))
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+ ref_path <- gsub("'", "''", file.path(data_dir, "ref.parquet"))
+ cand_file <- if (scenario == "lazy_green") {
+ "ref.parquet"
+ } else {
+ "cand_fail.parquet"
+ }
+ cand_path <- gsub("'", "''", file.path(data_dir, cand_file))
+ DBI::dbExecute(con, sprintf(
+ "CREATE VIEW ref_v AS SELECT * FROM read_parquet('%s')", ref_path
+ ))
+ DBI::dbExecute(con, sprintf(
+ "CREATE VIEW cand_v AS SELECT * FROM read_parquet('%s')", cand_path
+ ))
+ ref <- tbl(con, "ref_v")
+ cand <- tbl(con, "cand_v")
+
+ gc(reset = TRUE)
+ tm <- system.time({
+ res <- compare_datasets_from_yaml(
+ data_reference = ref,
+ data_candidate = cand,
+ key = "id"
+ )
+ })
+}
+
+g <- gc()
+mem_mb <- sum(g[, "max used"] * c(56, 8)) / 1e6
+
+all_passed <- tryCatch(isTRUE(res$all_passed), error = function(e) {
+ NA
+})
+
+line <- sprintf(
+ paste0(
+ '{"version":"%s","lib":"%s","scenario":"%s",',
+ '"elapsed":%.3f,"user":%.3f,"sys":%.3f,',
+ '"all_passed":%s,"r_peak_mb":%.0f}'
+ ),
+ ver, basename(libdir), scenario,
+ tm[["elapsed"]], tm[["user.self"]], tm[["sys.self"]],
+ tolower(as.character(all_passed)), mem_mb
+)
+cat(line, "\n", sep = "", file = out_file, append = TRUE)
+cat(line, "\n", sep = "")
diff --git a/dev/bench-0.4.10/gen_data.R b/dev/bench-0.4.10/gen_data.R
new file mode 100644
index 0000000..45b6b0c
--- /dev/null
+++ b/dev/bench-0.4.10/gen_data.R
@@ -0,0 +1,62 @@
+# Genere les fichiers Parquet des scenarios lazy du banc CRAN 0.5.0 vs PR #58.
+# Usage : Rscript gen_data.R
+# Tables : 4M lignes x 125 colonnes (id + 74 DOUBLE + 30 INTEGER + 20 VARCHAR),
+# le cas cite dans la PR (#22). La table candidate "fail" differe de la
+# reference sur 5 colonnes, 40 lignes chacune (id %% 100000 == 7).
+
+args <- commandArgs(trailingOnly = TRUE)
+data_dir <- args[[1]]
+if (!dir.exists(data_dir)) {
+ dir.create(data_dir, recursive = TRUE)
+}
+
+suppressPackageStartupMessages({
+ library(duckdb)
+})
+
+con <- DBI::dbConnect(duckdb::duckdb())
+on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+n_rows <- 4e6
+
+dbl_cols <- sprintf("round(random() * 1000, 4)::DOUBLE AS d%03d", 1:74)
+int_cols <- sprintf("(random() * 100000)::INTEGER AS i%03d", 1:30)
+chr_cols <- sprintf(
+ "('code_' || ((range * %d) %% 5000)::VARCHAR) AS s%03d",
+ seq(7, by = 2, length.out = 20),
+ 1:20
+)
+
+sql_ref <- sprintf(
+ "CREATE TABLE ref AS SELECT range AS id, %s FROM range(%d)",
+ paste(c(dbl_cols, int_cols, chr_cols), collapse = ", "),
+ n_rows
+)
+
+message("Generation table reference 4M x 125...")
+DBI::dbExecute(con, sql_ref)
+
+ref_path <- file.path(data_dir, "ref.parquet")
+DBI::dbExecute(
+ con,
+ sprintf("COPY ref TO '%s' (FORMAT PARQUET)", gsub("'", "''", ref_path))
+)
+
+# Candidate "fail" : 5 colonnes touchees sur les lignes id %% 100000 == 7
+# (40 lignes par colonne, 200 cellules en echec au total).
+message("Generation table candidate (fail)...")
+cand_path <- file.path(data_dir, "cand_fail.parquet")
+DBI::dbExecute(con, sprintf(
+ paste(
+ "COPY (SELECT * REPLACE (",
+ " CASE WHEN id %% 100000 = 7 THEN d001 + 1 ELSE d001 END AS d001,",
+ " CASE WHEN id %% 100000 = 7 THEN d002 + 1 ELSE d002 END AS d002,",
+ " CASE WHEN id %% 100000 = 7 THEN i001 + 1 ELSE i001 END AS i001,",
+ " CASE WHEN id %% 100000 = 7 THEN s001 || '_X' ELSE s001 END AS s001,",
+ " CASE WHEN id %% 100000 = 7 THEN s002 || '_X' ELSE s002 END AS s002",
+ ") FROM ref) TO '%s' (FORMAT PARQUET)"
+ ),
+ gsub("'", "''", cand_path)
+))
+
+message("OK : ", ref_path, " et ", cand_path)
diff --git a/dev/bench-0.4.10/run_bench.sh b/dev/bench-0.4.10/run_bench.sh
new file mode 100644
index 0000000..4ae3493
--- /dev/null
+++ b/dev/bench-0.4.10/run_bench.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+# Driver du banc CRAN 0.5.0 vs PR #58. Chaque mesure dans un processus R
+# frais, versions alternees a chaque repetition pour lisser la derive machine.
+# Usage : run_bench.sh
+set -eu
+SP="$1"
+OUT="$2"
+BENCH="$SP/datadiff-pr/dev/bench-0.4.10/bench_one.R"
+DATA="$SP/benchdata"
+
+for rep in 1 2 3; do
+ for scen in local_green local_na local_fail; do
+ for lib in lib_cran lib_pr; do
+ echo "== rep $rep $scen $lib =="
+ Rscript "$BENCH" "$SP/$lib" "$scen" "$OUT" "$DATA" 2>&1 | tail -1
+ done
+ done
+done
+
+for rep in 1 2; do
+ for scen in lazy_green lazy_fail; do
+ for lib in lib_cran lib_pr; do
+ echo "== rep $rep $scen $lib =="
+ Rscript "$BENCH" "$SP/$lib" "$scen" "$OUT" "$DATA" 2>&1 | tail -1
+ done
+ done
+done
diff --git a/tests/testthat/test-huge-parquet.R b/dev/manual-tests/huge-parquet.R
similarity index 100%
rename from tests/testthat/test-huge-parquet.R
rename to dev/manual-tests/huge-parquet.R
diff --git a/inst/templates/rules_template.yaml b/inst/templates/rules_template.yaml
deleted file mode 100644
index 95a28f3..0000000
--- a/inst/templates/rules_template.yaml
+++ /dev/null
@@ -1,37 +0,0 @@
-version: 1
-defaults:
- na_equal: yes
- ignore_columns: [] # Columns to ignore during schema comparison
- keys: []
- label: ~
-row_validation:
- check_count: no
- expected_count: ~
- tolerance: 0.0
-by_type:
- numeric:
- abs: 1.0e-09
- rel: 1.0e-09
- integer:
- abs: 0
- character:
- equal_mode: exact
- case_insensitive: no
- trim: no
- date:
- equal_mode: exact
- datetime:
- equal_mode: exact
- logical:
- equal_mode: exact
-by_name:
- # Column-specific rules override type rules
- # Examples:
- # amount:
- # abs: 0.001 # Absolute tolerance
- # rel: 0.001 # Relative tolerance
- # category:
- # case_insensitive: yes
- # trim: yes
- # name:
- # equal_mode: normalized
diff --git a/man/compare_datasets_from_yaml.Rd b/man/compare_datasets_from_yaml.Rd
index 71bc074..628a436 100644
--- a/man/compare_datasets_from_yaml.Rd
+++ b/man/compare_datasets_from_yaml.Rd
@@ -29,7 +29,10 @@ compare_datasets_from_yaml(
\item{data_candidate}{Candidate dataframe to validate against reference}
-\item{key}{Optional character vector of column names to use as join keys for ordered comparison}
+\item{key}{Optional character vector of column names to use as join keys for
+ordered comparison. Every key column must exist in both datasets; otherwise
+an error is raised naming the missing column(s) and the dataset(s) concerned.
+See the "Argument vs YAML precedence" section.}
\item{path}{Path to YAML file containing validation rules. If NULL, default rules are
generated automatically based on the reference dataset structure.}
@@ -40,9 +43,12 @@ generated automatically based on the reference dataset structure.}
\item{ref_suffix}{Suffix for reference columns in comparison dataframe (default: "__reference")}
-\item{label}{Descriptive label for the validation report}
+\item{label}{Descriptive label for the validation report. See the
+"Argument vs YAML precedence" section.}
-\item{error_msg_no_key}{Error message when datasets have different row counts without keys}
+\item{error_msg_no_key}{Text of the error raised when a positional (key-less)
+comparison receives datasets with different row counts; the actual row
+counts of both datasets are appended to this text.}
\item{lang}{Language code for pointblank reports. Defaults to the
\code{datadiff.lang} option if set, otherwise \code{"fr"}. Override globally
@@ -76,16 +82,25 @@ when Arrow datasets are used (default: \code{"8GB"}). Controls how much RAM Duck
may use before spilling intermediate results to \code{tempdir()}. The default leaves
headroom for R, Arrow, and the OS alongside DuckDB. Raise it (e.g. \code{"16GB"})
on machines with ample free RAM to reduce disk I/O; lower it (e.g. \code{"4GB"})
-when memory is very constrained. Has no effect when both inputs are plain
-\code{data.frame}s or \code{tbl_lazy} objects.}
+when memory is very constrained. The value is always validated as a size
+literal (an invalid one is an error on every path), but it only takes
+effect when Arrow datasets are used: plain \code{data.frame}s or \code{tbl_lazy}
+inputs ignore a valid value.}
}
\value{
-A list containing:
-\item{agent}{Configured pointblank agent with validation results}
-\item{reponse}{Interrogated pointblank agent (class \code{datadiff_report}):
+A list of class \code{datadiff_result} containing:
+\item{all_passed}{Logical; \code{TRUE} when every check passed. The
+first element of the returned list and the single verdict consumers
+should read.}
+\item{agent}{The configured pointblank agent as built (NOT interrogated:
+the verdict lives in \code{response}, which is the interrogated one)}
+\item{response}{Interrogated pointblank agent (class \code{datadiff_report}):
usable by \code{pointblank::all_passed()} / \code{get_data_extracts()};
printing it lazily renders a full pointblank-style report from
-\code{coverage} (built on demand, memoized).}
+\code{coverage} (built on demand, memoized). Reading it under its
+historical name \code{reponse} still works but is deprecated and
+emits a warning (once per session); the alias will be removed in a
+future release.}
\item{missing_in_candidate}{Columns missing in candidate data}
\item{extra_in_candidate}{Extra columns in candidate data}
\item{applied_rules}{Final column-specific rules applied}
@@ -101,6 +116,31 @@ Main function for comparing reference and candidate datasets using configurable
validation rules defined in a YAML file. Supports exact matching, tolerance-based
comparisons, text normalization, and row count validation.
}
+\section{Argument vs YAML precedence}{
+
+Some settings can come both from an explicit argument and from the YAML
+rules file. The resolution is always: explicit argument first, then the
+YAML \code{defaults} section, then the built-in default.\tabular{llll}{
+ Setting \tab Explicit argument \tab YAML \code{defaults} field \tab Built-in default \cr
+ join key \tab \code{key} \tab \code{keys} (legacy alias: \code{key}) \tab none (positional) \cr
+ report label \tab \code{label} \tab \code{label} \tab "Comparing candidate vs reference" \cr
+}
+
+
+When the YAML contains both \code{keys} and the legacy singular \code{key} field,
+\code{keys} (the canonical field written by \code{\link[=write_rules_template]{write_rules_template()}}) wins.
+
+The \code{key} argument must be a character vector (a non-character value is an
+error), while YAML-sourced key values are coerced to character (YAML being
+stringly typed, \verb{keys: [2024]} designates the column named "2024").
+
+Note for \code{path = NULL}: the auto-generated rules template itself carries a
+label (\code{"Comparison with default rules"}, or the explicit \code{label} argument),
+so that is the label effectively used without a YAML file; the built-in
+default above only applies when a YAML file is supplied with an empty or
+missing \code{defaults$label}.
+}
+
\examples{
# Create test data
ref <- data.frame(id = 1:3, value = c(1.0, 2.0, 3.0))
@@ -116,5 +156,5 @@ result <- compare_datasets_from_yaml(ref, cand, key = "id")
tmp <- tempfile(fileext = ".yaml")
write_rules_template(ref, key = "id", path = tmp)
result <- compare_datasets_from_yaml(ref, cand, key = "id", path = tmp)
-result$reponse
+result$response
}
diff --git a/man/datadiff_report_html.Rd b/man/datadiff_report_html.Rd
index bf6dae5..28d5180 100644
--- a/man/datadiff_report_html.Rd
+++ b/man/datadiff_report_html.Rd
@@ -4,13 +4,23 @@
\alias{datadiff_report_html}
\title{Render a comparison result as a pointblank HTML report}
\usage{
-datadiff_report_html(res, file = NULL)
+datadiff_report_html(res, file = NULL, extracts_dir = NULL)
}
\arguments{
\item{res}{The list returned by \code{\link[=compare_datasets_from_yaml]{compare_datasets_from_yaml()}}.}
\item{file}{Optional path to write the report to as a self-contained HTML
file (via \code{\link[pointblank:export_report]{pointblank::export_report()}}). When \code{NULL}, nothing is written.}
+
+\item{extracts_dir}{Optional directory where the failing-row extracts are
+also written as plain CSV files, one per failing validation step, named
+\verb{extract__.csv} with a zero-padded 4-digit step number
+(e.g. \code{extract_0002_price.csv}). Column names are sanitized for the
+file system: any character outside \verb{[A-Za-z0-9_.-]} becomes \verb{_}. The CSV buttons inside the HTML report are
+\verb{data:} URI downloads, which some viewers block (Positron / Posit
+Workbench webview): files on disk are the robust alternative. The
+directory is created when needed; nothing is created when there is no
+extract (all-pass comparison or \code{extract_failed = FALSE}).}
}
\value{
The pointblank agent report object, invisibly.
diff --git a/man/detect_column_types.Rd b/man/detect_column_types.Rd
index 60f96d0..6f2487c 100644
--- a/man/detect_column_types.Rd
+++ b/man/detect_column_types.Rd
@@ -16,6 +16,13 @@ A named character vector with column names as names and types as values
Analyzes each column of a dataframe to determine its data type
(integer, numeric, date, datetime, logical, or character)
}
+\details{
+Any column matching none of the first five types falls in the "character"
+bucket and receives the character rules. That is intentional for factors
+(compared as the character values they display, see
+\code{\link[=preprocess_dataframe]{preprocess_dataframe()}}) and works for types whose \code{==} behaves like
+character (e.g. \code{bit64::integer64}). List-columns are not supported.
+}
\examples{
df <- data.frame(a = 1:3, b = c(1.1, 2.2, 3.3), c = c("x", "y", "z"))
detect_column_types(df)
diff --git a/man/or_operator.Rd b/man/or_operator.Rd
deleted file mode 100644
index 20863e8..0000000
--- a/man/or_operator.Rd
+++ /dev/null
@@ -1,20 +0,0 @@
-% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/utils.R
-\name{or_operator}
-\alias{or_operator}
-\alias{\%||\%}
-\title{Operator for default values}
-\usage{
-x \%||\% y
-}
-\arguments{
-\item{x}{Value to check}
-
-\item{y}{Default value to return if x is NULL}
-}
-\value{
-x if not NULL, otherwise y
-}
-\description{
-Returns y if x is NULL, otherwise returns x
-}
diff --git a/man/preprocess_dataframe.Rd b/man/preprocess_dataframe.Rd
index eef4dc8..91057ab 100644
--- a/man/preprocess_dataframe.Rd
+++ b/man/preprocess_dataframe.Rd
@@ -16,13 +16,20 @@ types when \code{df} is a lazy table. Obtained via
\code{dplyr::collect(utils::head(df, 0L))}.}
}
\value{
-Preprocessed dataframe (or lazy table) with transformations applied
+Preprocessed dataframe (or lazy table) with transformations applied.
+Factor columns of a local data.frame come back as character vectors.
}
\description{
Applies preprocessing transformations to dataframe columns based on validation rules,
such as text normalization for character columns. Supports both local data.frames
and lazy tables (tbl_lazy) via dplyr::mutate().
}
+\details{
+On a local data.frame, every factor column (including key and non-compared
+columns) is first converted to character: factors are compared as the
+character values they display, so the text normalization rules apply to
+them and factors with differing level sets compare cleanly.
+}
\examples{
df <- data.frame(text_col = c(" HELLO ", "world"))
rules <- list(text_col = list(equal_mode = "normalized", case_insensitive = TRUE, trim = TRUE))
diff --git a/man/print.datadiff_report.Rd b/man/print.datadiff_report.Rd
index ec81399..dfc3f47 100644
--- a/man/print.datadiff_report.Rd
+++ b/man/print.datadiff_report.Rd
@@ -7,7 +7,7 @@
\method{print}{datadiff_report}(x, ...)
}
\arguments{
-\item{x}{A \code{datadiff_report} (the \code{reponse} element of a comparison result).}
+\item{x}{A \code{datadiff_report} (the \code{response} element of a comparison result).}
\item{...}{Unused.}
}
diff --git a/man/setup_pointblank_agent.Rd b/man/setup_pointblank_agent.Rd
index 0f22844..7ef1bc3 100644
--- a/man/setup_pointblank_agent.Rd
+++ b/man/setup_pointblank_agent.Rd
@@ -6,7 +6,7 @@
\usage{
setup_pointblank_agent(
cmp,
- cols_reference,
+ cols_reference = NULL,
common_cols,
tol_cols,
row_validation_info = NULL,
@@ -25,9 +25,12 @@ setup_pointblank_agent(
\arguments{
\item{cmp}{Comparison dataframe with candidate and reference data}
-\item{cols_reference}{Character vector of reference column names}
+\item{cols_reference}{Deprecated and unused; supplying any non-NULL value
+raises a warning (an explicit NULL is silent) and the argument will be
+removed in a future release}
-\item{common_cols}{Character vector of columns present in both datasets}
+\item{common_cols}{Character vector of equality columns to validate (the
+internal pipeline passes only the failing ones on its failure path)}
\item{tol_cols}{Character vector of columns with tolerance rules}
@@ -69,10 +72,26 @@ Configured pointblank agent ready for interrogation
Creates and configures a pointblank validation agent with all necessary validation steps
including column existence checks, exact value comparisons, and tolerance validations.
}
+\details{
+The equality steps validate a \verb{__eq} boolean (one-sided NA fails,
+two-sided NA follows \code{na_equal}, matching the tolerance kernel and the lazy
+SQL): when \code{cmp} does not already carry it, it is derived on the fly for a
+local data.frame. Tolerance steps expect the \verb{__ok} booleans to be
+precomputed (see \code{\link[=add_tolerance_columns]{add_tolerance_columns()}}). A lazy \code{cmp} must carry every
+boolean column already.
+}
\examples{
-cmp <- data.frame(a = 1:3, a__reference = 1:3, b = c(1.1, 2.2, 3.3),
- b__reference = c(1.0, 2.0, 3.0))
+cmp <- data.frame(
+ a = 1:3, a__reference = 1:3,
+ b = c(1.1, 2.2, 3.3), b__reference = c(1.0, 2.0, 3.0),
+ b__ok = c(FALSE, FALSE, FALSE)
+)
row_info <- list(check_count = FALSE)
-setup_pointblank_agent(cmp, c("a", "b"), c("a", "b"), "b", row_info,
- "__reference", 0.1, 0.1, "Test", TRUE)
+agent <- setup_pointblank_agent(
+ cmp,
+ common_cols = "a", tol_cols = "b", row_validation_info = row_info,
+ ref_suffix = "__reference", warn_at = 0.1, stop_at = 0.1,
+ label = "Test", na_equal = TRUE
+)
+pointblank::all_passed(pointblank::interrogate(agent))
}
diff --git a/man/validate_row_counts.Rd b/man/validate_row_counts.Rd
index c977dc6..f85d6d8 100644
--- a/man/validate_row_counts.Rd
+++ b/man/validate_row_counts.Rd
@@ -4,7 +4,12 @@
\alias{validate_row_counts}
\title{Validate row counts according to rules}
\usage{
-validate_row_counts(data_reference_p, data_candidate_p, rules)
+validate_row_counts(
+ data_reference_p,
+ data_candidate_p,
+ rules,
+ count_rows = TRUE
+)
}
\arguments{
\item{data_reference_p}{Preprocessed reference dataframe}
@@ -12,6 +17,13 @@ validate_row_counts(data_reference_p, data_candidate_p, rules)
\item{data_candidate_p}{Preprocessed candidate dataframe}
\item{rules}{List of validation rules containing row_validation settings}
+
+\item{count_rows}{Logical; when \code{FALSE}, the row counts are not computed and
+\code{ref_count}/\code{cand_count} are \code{NA}. On lazy tables each count is a full
+\verb{COUNT(*)} scan, wasted when nothing consumes it (keyed comparison with
+\code{check_count: false}). Ignored when the rules activate \code{check_count}:
+the counts are then always computed, so the returned structure stays
+consistent (an active check never sees \code{NA} counts).}
}
\value{
A list with validation information for pointblank integration
diff --git a/man/write_rules_template.Rd b/man/write_rules_template.Rd
index 30e42c9..b153de3 100644
--- a/man/write_rules_template.Rd
+++ b/man/write_rules_template.Rd
@@ -54,17 +54,24 @@ comparison. If NULL, comparison is positional (row by row).}
\item{integer_abs}{Default absolute tolerance for integer columns}
-\item{character_equal_mode}{Default comparison mode for character columns ("exact", "normalized")}
+\item{character_equal_mode}{Default comparison mode for character columns:
+"exact", or "normalized" which implies \code{case_insensitive = TRUE} and
+\code{trim = TRUE} unless those flags are set explicitly (an explicit value
+always wins over the mode)}
\item{character_case_insensitive}{Logical for case-insensitive character comparison}
\item{character_trim}{Logical for trimming whitespace in character comparison}
-\item{date_equal_mode}{Default comparison mode for date columns}
+\item{date_equal_mode}{Default comparison mode for date columns. Only
+"exact" has an effect: text normalization applies to character columns
+only, so "normalized" is accepted but changes nothing for this type.}
-\item{datetime_equal_mode}{Default comparison mode for datetime columns}
+\item{datetime_equal_mode}{Default comparison mode for datetime columns
+(same caveat as \code{date_equal_mode})}
-\item{logical_equal_mode}{Default comparison mode for logical columns}
+\item{logical_equal_mode}{Default comparison mode for logical columns
+(same caveat as \code{date_equal_mode})}
}
\value{
The \code{path} to the written YAML file, returned invisibly.
diff --git a/tests/testthat/helper-equivalence.R b/tests/testthat/helper-equivalence.R
index 58dc798..4f267e6 100644
--- a/tests/testthat/helper-equivalence.R
+++ b/tests/testthat/helper-equivalence.R
@@ -12,10 +12,36 @@ underlying_col <- function(col) {
sub("__(ok|eq)$", "", col)
}
+# Named integer vector: for every failing validation step, the underlying
+# data column mapped to its number of failing rows. This is the shape both
+# backends can promise: lazy extracts carry the boolean check columns only
+# (no key columns, no data values), so per-cell identification stays a
+# local-path capability.
+failing_row_counts <- function(res) {
+ rep <- res$response
+ if (is.null(rep)) {
+ return(integer(0))
+ }
+ ex <- pointblank::get_data_extracts(rep)
+ if (length(ex) == 0) {
+ return(integer(0))
+ }
+ vs <- rep$validation_set
+ cols <- vapply(X = names(ex), FUN = function(nm) {
+ i <- as.integer(nm)
+ underlying_col(vs$column[[match(i, vs$i)]][1])
+ }, FUN.VALUE = character(1))
+ counts <- vapply(X = ex, FUN = function(df) {
+ nrow(as.data.frame(df))
+ }, FUN.VALUE = integer(1))
+ names(counts) <- unname(cols)
+ counts[order(names(counts))]
+}
+
# Sorted character vector of "@" for every failing cell
# surfaced by get_data_extracts().
failing_cells <- function(res, key) {
- rep <- res$reponse
+ rep <- res$response
if (is.null(rep)) {
return(character(0))
}
@@ -27,7 +53,7 @@ failing_cells <- function(res, key) {
out <- character(0)
for (nm in names(ex)) {
i <- as.integer(nm)
- col <- underlying_col(vs$column[[i]])
+ col <- underlying_col(vs$column[[match(i, vs$i)]][1])
df <- as.data.frame(ex[[nm]])
if (nrow(df) == 0) {
next
@@ -41,10 +67,16 @@ failing_cells <- function(res, key) {
}
# Run a comparison with explicit tolerances, returning the captured verdict.
+# The same comparison also runs on the lazy backend (DuckDB) and both backends
+# must agree on the verdict, the exact failing cells and the column
+# bookkeeping; set compare_lazy = FALSE for a case that only makes sense
+# locally. The returned verdict is the local one.
run_compare <- function(ref, cand, key,
numeric_abs = 0.101, integer_abs = 0L,
- na_equal_default = TRUE, check_count_default = TRUE) {
+ na_equal_default = TRUE, check_count_default = TRUE,
+ compare_lazy = TRUE) {
tmp <- tempfile(fileext = ".yml")
+ on.exit(unlink(tmp), add = TRUE)
write_rules_template(
ref,
key = key,
@@ -57,11 +89,45 @@ run_compare <- function(ref, cand, key,
res <- suppressWarnings(suppressMessages(
compare_datasets_from_yaml(ref, cand, key = key, path = tmp)
))
- list(
+ out <- list(
all_passed = res$all_passed,
cells = failing_cells(res, key = key),
missing = res$missing_in_candidate,
extra = res$extra_in_candidate,
res = res
)
+ if (isTRUE(compare_lazy) &&
+ requireNamespace("DBI", quietly = TRUE) &&
+ requireNamespace("duckdb", quietly = TRUE) &&
+ requireNamespace("dbplyr", quietly = TRUE)) {
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+ duckdb::duckdb_register(con, "ref_equiv", ref)
+ duckdb::duckdb_register(con, "cand_equiv", cand)
+ res_lazy <- suppressWarnings(suppressMessages(
+ compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_equiv"),
+ dplyr::tbl(con, "cand_equiv"),
+ key = key,
+ path = tmp
+ )
+ ))
+ testthat::expect_identical(
+ res_lazy$all_passed, out$all_passed,
+ info = "lazy backend must agree on the verdict"
+ )
+ testthat::expect_identical(
+ failing_row_counts(res_lazy), failing_row_counts(res),
+ info = "lazy backend must agree on the failing columns and their failing-row counts"
+ )
+ testthat::expect_identical(
+ res_lazy$missing_in_candidate, out$missing,
+ info = "lazy backend must agree on the missing columns"
+ )
+ testthat::expect_identical(
+ res_lazy$extra_in_candidate, out$extra,
+ info = "lazy backend must agree on the extra columns"
+ )
+ }
+ out
}
diff --git a/tests/testthat/rules.yaml b/tests/testthat/rules.yaml
deleted file mode 100644
index 9fb092c..0000000
--- a/tests/testthat/rules.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-version: 1
-defaults:
- na_equal: yes
- ignore_columns: []
- keys: ~
- label: comparaison df
-row_validation:
- check_count: no
- expected_count: ~
- tolerance: 0.0
-by_type:
- numeric:
- abs: 1.0e-09
- rel: 1.0e-09
- integer:
- abs: 0
- character:
- equal_mode: exact
- case_insensitive: no
- trim: no
- date:
- equal_mode: exact
- datetime:
- equal_mode: exact
- logical:
- equal_mode: exact
-by_name:
- id: []
- value: []
diff --git a/tests/testthat/test-arrow-dataset-to-duckdb.R b/tests/testthat/test-arrow-dataset-to-duckdb.R
new file mode 100644
index 0000000..dc4bd4a
--- /dev/null
+++ b/tests/testthat/test-arrow-dataset-to-duckdb.R
@@ -0,0 +1,82 @@
+# arrow_dataset_to_duckdb(): SQL built from file paths must survive special
+# characters, and multi-file datasets must keep a unified schema (as the
+# arrow::to_duckdb() fallback always did).
+
+test_that("a Parquet path containing a quote works end to end", {
+ skip_if_not_installed("arrow")
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+
+ # Unique base dir per run (parallel/leftover safety), apostrophe kept in
+ # the leaf name since that is the load-bearing part of the test
+ d <- file.path(tempfile(pattern = "datadiff_quote_"), "l'export datadiff")
+ dir.create(d, recursive = TRUE, showWarnings = FALSE)
+ on.exit(unlink(dirname(d), recursive = TRUE), add = TRUE)
+ arrow::write_parquet(
+ data.frame(id = 1:3, x = c(1.0, 2.0, 3.0)),
+ file.path(d, "part-0.parquet")
+ )
+ ds <- arrow::open_dataset(d)
+
+ res <- suppressMessages(compare_datasets_from_yaml(ds, ds, key = "id"))
+ expect_true(res$all_passed)
+})
+
+test_that("multi-file datasets with shifted schemas are unified by name", {
+ skip_if_not_installed("arrow")
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+
+ d <- tempfile(pattern = "datadiff_union_by_name_")
+ dir.create(d, showWarnings = FALSE)
+ on.exit(unlink(d, recursive = TRUE), add = TRUE)
+ # Same columns, different physical order in the second file: a positional
+ # binding would silently misalign x and y
+ arrow::write_parquet(
+ data.frame(id = 1:2, x = c(1.0, 2.0), y = c(10.0, 20.0)),
+ file.path(d, "part-0.parquet")
+ )
+ arrow::write_parquet(
+ data.frame(y = c(30.0, 40.0), x = c(3.0, 4.0), id = 3:4),
+ file.path(d, "part-1.parquet")
+ )
+ ds <- arrow::open_dataset(d)
+
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+ tbl_lazy <- arrow_dataset_to_duckdb(ds, con, "datadiff_union_test")
+ collected <- dplyr::collect(tbl_lazy)
+ collected <- collected[order(collected$id), , drop = FALSE]
+
+ expect_setequal(names(collected), c("id", "x", "y"))
+ expect_identical(collected$x, c(1.0, 2.0, 3.0, 4.0))
+ expect_identical(collected$y, c(10.0, 20.0, 30.0, 40.0))
+})
+
+test_that("non-file-backed Arrow objects still use the to_duckdb fallback", {
+ skip_if_not_installed("arrow")
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+
+ tbl_arrow <- arrow::arrow_table(data.frame(id = 1:3, x = c(1.0, 2.0, 3.0)))
+ res <- suppressMessages(compare_datasets_from_yaml(tbl_arrow, tbl_arrow, key = "id"))
+ expect_true(res$all_passed)
+})
+
+test_that("an invalid duckdb_memory_limit is rejected before reaching SQL", {
+ skip_if_not_installed("arrow")
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+
+ tbl_arrow <- arrow::arrow_table(data.frame(id = 1:3, x = c(1.0, 2.0, 3.0)))
+ expect_error(
+ compare_datasets_from_yaml(tbl_arrow, tbl_arrow, key = "id",
+ duckdb_memory_limit = "8GB'; DROP TABLE x; --"),
+ regexp = "duckdb_memory_limit"
+ )
+ expect_error(
+ compare_datasets_from_yaml(tbl_arrow, tbl_arrow, key = "id",
+ duckdb_memory_limit = "beaucoup"),
+ regexp = "duckdb_memory_limit"
+ )
+})
diff --git a/tests/testthat/test-avoidable-scans.R b/tests/testthat/test-avoidable-scans.R
new file mode 100644
index 0000000..2f5c0b1
--- /dev/null
+++ b/tests/testthat/test-avoidable-scans.R
@@ -0,0 +1,68 @@
+# Avoidable scans and transfers: row counts are only computed when consumed,
+# the keyed join carries only the key and the compared columns, and the lazy
+# duplicate-key detection aggregates in SQL instead of collecting every
+# duplicated group.
+
+test_that("validate_row_counts can skip the COUNT(*) when nothing consumes it", {
+ ref <- data.frame(id = 1:3, x = 1:3)
+ cand <- data.frame(id = 1:3, x = 1:3)
+ rules <- list(row_validation = list(check_count = FALSE, expected_count = NULL, tolerance = 0))
+
+ info <- validate_row_counts(ref, cand, rules, count_rows = FALSE)
+ expect_false(info$check_count)
+ expect_true(is.na(info$ref_count))
+ expect_true(is.na(info$cand_count))
+
+ # Default stays counting (backward compatible for direct callers)
+ info_default <- validate_row_counts(ref, cand, rules)
+ expect_identical(info_default$ref_count, 3L)
+ expect_identical(info_default$cand_count, 3L)
+})
+
+test_that("ignored and extra columns stay out of the joined comparison", {
+ ref <- data.frame(id = 1:3, x = c(1, 2, 3), noise = c("a", "b", "c"))
+ cand <- data.frame(id = 1:3, x = c(1, 2, 9), noise = c("zz", "zz", "zz"),
+ extra = c(TRUE, FALSE, TRUE))
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(ref, key = "id", path = yaml_path,
+ ignore_columns_default = "noise")
+
+ res <- suppressWarnings(suppressMessages(
+ compare_datasets_from_yaml(ref, cand, key = "id", path = yaml_path)
+ ))
+ expect_false(res$all_passed)
+
+ # The failing-row extract carries the key and the compared columns, not the
+ # ignored column nor the candidate-only extra column
+ ex <- pointblank::get_data_extracts(res$response)
+ ex1 <- as.data.frame(ex[[1]])
+ expect_true("id" %in% names(ex1))
+ expect_false("noise" %in% names(ex1))
+ expect_false("extra" %in% names(ex1))
+})
+
+test_that("lazy duplicate detection aggregates in SQL (few rows transferred)", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ # 1000 duplicated key values: the old code collected all 1000 groups,
+ # the aggregate path must return the same counts and 3 examples + "..."
+ df <- data.frame(id = rep(seq_len(1000), each = 2), v = 1)
+ duckdb::dbWriteTable(con, "t_many_dups", df)
+ info <- find_duplicate_keys(dplyr::tbl(con, "t_many_dups"), "id")
+ expect_equal(as.numeric(info$n_dup_keys), 1000)
+ expect_equal(as.numeric(info$n_dup_rows), 2000)
+ expect_length(info$examples, 4L) # 3 examples + "..."
+ expect_identical(info$examples[4], "...")
+
+ # Below the truncation threshold: no "..." marker
+ duckdb::dbWriteTable(con, "t_two_dups",
+ data.frame(id = c(1, 1, 2, 2, 3), v = 1:5))
+ info2 <- find_duplicate_keys(dplyr::tbl(con, "t_two_dups"), "id")
+ expect_equal(as.numeric(info2$n_dup_keys), 2)
+ expect_length(info2$examples, 2L)
+})
diff --git a/tests/testthat/test-boolean-column-guards.R b/tests/testthat/test-boolean-column-guards.R
new file mode 100644
index 0000000..a944de9
--- /dev/null
+++ b/tests/testthat/test-boolean-column-guards.R
@@ -0,0 +1,34 @@
+# A missing __ok/__eq boolean column must fail loudly: all(NULL) is TRUE, so a
+# silently dropped column would turn into a false "all pass" with n = 0 in the
+# coverage - the worst failure mode for a non-regression tool.
+
+test_that("tol_col_bool errors on a missing __ok column", {
+ tbl <- data.frame(a = 1:3)
+ expect_error(
+ tol_col_bool(tbl, col = "a"),
+ regexp = "internal error.*a__ok"
+ )
+})
+
+test_that("eq_col_bool errors when both __eq and the raw pair are missing", {
+ tbl_no_ref <- data.frame(a = 1:3)
+ expect_error(
+ eq_col_bool(tbl_no_ref, col = "a", ref_suffix = "__reference", na_equal = TRUE),
+ regexp = "internal error.*a__reference"
+ )
+
+ tbl_no_cand <- data.frame(a__reference = 1:3)
+ expect_error(
+ eq_col_bool(tbl_no_cand, col = "a", ref_suffix = "__reference", na_equal = TRUE),
+ regexp = "internal error.*'a'"
+ )
+})
+
+test_that("the count reducers inherit the guard instead of PASS with n = 0", {
+ tbl <- data.frame(a = 1:3)
+ expect_error(tol_col_counts(tbl, col = "a"), regexp = "internal error")
+ expect_error(
+ eq_col_counts(tbl, col = "a", ref_suffix = "__reference", na_equal = TRUE),
+ regexp = "internal error"
+ )
+})
diff --git a/tests/testthat/test-coverage.R b/tests/testthat/test-coverage.R
index 1b2a938..acbd82d 100644
--- a/tests/testthat/test-coverage.R
+++ b/tests/testthat/test-coverage.R
@@ -2,7 +2,7 @@
# already-computed boolean validation columns into one row per check actually
# performed (column, check type, n rows, n_failed, status). It must list EVERY
# column and stay consistent with the verdict (status == PASS iff the column
-# passes in all_validations_pass / failing_columns).
+# passes when deriving the verdict).
# --- build_coverage: value checks (tolerance) -------------------------------
diff --git a/tests/testthat/test-default-rules.R b/tests/testthat/test-default-rules.R
index 2893dcd..cfe142d 100644
--- a/tests/testthat/test-default-rules.R
+++ b/tests/testthat/test-default-rules.R
@@ -7,7 +7,7 @@ test_that("compare_datasets_from_yaml works without path (default rules)", {
result <- compare_datasets_from_yaml(ref, cand)
expect_true(result$all_passed)
- expect_s3_class(result$reponse, "ptblank_agent")
+ expect_s3_class(result$response, "ptblank_agent")
})
test_that("compare_datasets_from_yaml with key but no path works", {
@@ -17,7 +17,7 @@ test_that("compare_datasets_from_yaml with key but no path works", {
result <- compare_datasets_from_yaml(ref, cand, key = "id")
expect_true(result$all_passed)
- expect_s3_class(result$reponse, "ptblank_agent")
+ expect_s3_class(result$response, "ptblank_agent")
})
test_that("compare_datasets_from_yaml detects differences without path", {
@@ -204,7 +204,7 @@ test_that("custom label is used when path is NULL", {
result <- compare_datasets_from_yaml(ref, cand, label = "Custom Test Label")
# The label should be set in the agent
- expect_s3_class(result$reponse, "ptblank_agent")
+ expect_s3_class(result$response, "ptblank_agent")
})
test_that("comparison without path handles missing columns", {
diff --git a/tests/testthat/test-duplicate-keys.R b/tests/testthat/test-duplicate-keys.R
index ad0f651..aa9a6ce 100644
--- a/tests/testthat/test-duplicate-keys.R
+++ b/tests/testthat/test-duplicate-keys.R
@@ -55,3 +55,95 @@ test_that("lazy tables produce the same structure (SQL path)", {
expect_equal(info$n_dup_keys, 1L)
expect_equal(info$n_dup_rows, 2L)
})
+
+# A user column named "n" collides with dplyr::count()'s default output name:
+# count() then names its result "nn" (documented count()/tally() behavior).
+# The lazy helpers must use a reserved name instead of relying on "n".
+
+test_that("lazy duplicate detection works when the key column is named 'n'", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ # Key values are large on purpose: if filter()/sum() read the key column
+ # instead of the count, n_dup_keys / n_dup_rows become absurd.
+ duckdb::dbWriteTable(con, "t_n", data.frame(n = c(100, 100, 200), v = 1:3))
+ info <- find_duplicate_keys(dplyr::tbl(con, "t_n"), "n")
+ expect_equal(info$n_dup_keys, 1L)
+ expect_equal(info$n_dup_rows, 2L)
+ expect_equal(info$examples, "n = 100")
+
+ # No duplicates: values > 1 in the key column must not be mistaken
+ # for duplicate counts
+ duckdb::dbWriteTable(con, "t_n_uniq", data.frame(n = c(100, 200, 300), v = 1:3))
+ expect_null(find_duplicate_keys(dplyr::tbl(con, "t_n_uniq"), "n"))
+})
+
+test_that("the reserved count name itself cannot collide with a key column", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ # A key literally named like the reserved count column must not reproduce
+ # the value-vs-count confusion on a different name
+ df <- data.frame(a = c(100, 100, 200), v = 1:3)
+ names(df)[1] <- "..datadiff_n"
+ duckdb::dbWriteTable(con, "t_reserved", df)
+ info <- find_duplicate_keys(dplyr::tbl(con, "t_reserved"), "..datadiff_n")
+ expect_equal(info$n_dup_keys, 1L)
+ expect_equal(info$n_dup_rows, 2L)
+ # The example must show the duplicated KEY VALUE (100), not the count (2):
+ # count(name = ) silently replaces the key column
+ expect_equal(info$examples, "..datadiff_n = 100")
+})
+
+test_that("lazy_nrow works on a table with a column named 'n'", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ duckdb::dbWriteTable(con, "t_nrow", data.frame(n = c(10, 20, 30), v = 1:3))
+ expect_equal(lazy_nrow(dplyr::tbl(con, "t_nrow")), 3)
+})
+
+test_that("full lazy comparison works on tables with a column named 'n'", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ df <- data.frame(n = c(1, 1, 2), v = c(10, 10, 20))
+ duckdb::dbWriteTable(con, "ref_n", df)
+ duckdb::dbWriteTable(con, "cand_n", df)
+
+ # Key named "n" with genuine duplicates: the warning must carry the real
+ # counts (1 duplicated key value affecting 2 rows), not sums of key values
+ warns <- character(0)
+ withCallingHandlers(
+ {
+ res <- compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_n"), dplyr::tbl(con, "cand_n"),
+ key = "n"
+ )
+ },
+ warning = function(w) {
+ warns <<- c(warns, conditionMessage(w))
+ invokeRestart("muffleWarning")
+ }
+ )
+ dup_warns <- grep("Duplicate keys detected", warns, value = TRUE)
+ expect_length(dup_warns, 1L)
+ expect_match(dup_warns, "1 duplicate key value\\(s\\) affecting 2 rows")
+
+ # Column "n" as a plain compared (non-key) column
+ duckdb::dbWriteTable(con, "ref_n2", data.frame(id = 1:3, n = c(10, 20, 30)))
+ duckdb::dbWriteTable(con, "cand_n2", data.frame(id = 1:3, n = c(10, 20, 30)))
+ res2 <- compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_n2"), dplyr::tbl(con, "cand_n2"),
+ key = "id"
+ )
+ expect_true(res2$all_passed)
+})
diff --git a/tests/testthat/test-edge-cases.R b/tests/testthat/test-edge-cases.R
index b2735f4..b8e3a77 100644
--- a/tests/testthat/test-edge-cases.R
+++ b/tests/testthat/test-edge-cases.R
@@ -791,34 +791,91 @@ test_that("no crash and no spurious warning when all column types match", {
unlink(template_path)
})
-test_that("message is shown for positional comparison when row counts differ", {
+# Helper for the positional-path tests: run expr, return the error message
+# and every message emitted (the error text must never leak on the message
+# stream).
+catch_error_and_messages <- function(expr) {
+ msgs <- character(0)
+ err <- tryCatch(
+ withCallingHandlers(
+ expr,
+ message = function(m) {
+ msgs <<- c(msgs, conditionMessage(m))
+ invokeRestart("muffleMessage")
+ }
+ ),
+ error = function(e) {
+ conditionMessage(e)
+ }
+ )
+ list(error = err, messages = msgs)
+}
+
+test_that("positional comparison emits no debug message about the missing key", {
+ ref <- data.frame(value = c(1.0, 2.0, 3.0))
+ cand <- ref
+ expect_no_message(
+ compare_datasets_from_yaml(ref, cand),
+ message = "key is missing"
+ )
+})
+
+test_that("positional comparison with unequal row counts raises a clear error", {
ref <- data.frame(value = 1:5)
cand <- data.frame(value = 1:3)
- # No key -> positional comparison; row counts differ -> message emitted then
- # R errors on column assignment. Capture every message before the error.
- capture_messages <- function(expr) {
- msgs <- character(0)
- tryCatch(
- withCallingHandlers(
- suppressWarnings(expr),
- message = function(m) {
- msgs <<- c(msgs, conditionMessage(m))
- invokeRestart("muffleMessage")
- }
- ),
- error = function(e) NULL
- )
- msgs
- }
+ # No key -> positional comparison; row counts differ -> a single clear error
+ # built from error_msg_no_key, mentioning both row counts.
+ out <- catch_error_and_messages(compare_datasets_from_yaml(ref, cand))
+ expect_match(out$error, "same number of rows", fixed = TRUE)
+ expect_match(out$error, "data_reference: 5 rows", fixed = TRUE)
+ expect_match(out$error, "data_candidate: 3 rows", fixed = TRUE)
+ # The error text must be raised, not emitted as an informational message
+ expect_false(any(grepl("same number of rows", out$messages, fixed = TRUE)))
+
+ # A custom error_msg_no_key must be the text of the raised error.
+ out_custom <- catch_error_and_messages(
+ compare_datasets_from_yaml(ref, cand, error_msg_no_key = "CUSTOM_NO_KEY_MSG")
+ )
+ expect_match(out_custom$error, "CUSTOM_NO_KEY_MSG", fixed = TRUE)
+ expect_false(any(grepl("CUSTOM_NO_KEY_MSG", out_custom$messages, fixed = TRUE)))
+})
- # Default: the row-count message uses error_msg_no_key's default text.
- msgs <- capture_messages(compare_datasets_from_yaml(ref, cand))
- expect_true(any(grepl("same number of rows", msgs)))
+test_that("unequal-count positional comparison on lazy tables aborts before collecting", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+ duckdb::dbWriteTable(con, "ref5", data.frame(value = 1:5))
+ duckdb::dbWriteTable(con, "cand3", data.frame(value = 1:3))
- # A custom error_msg_no_key must actually reach the emitted message.
- msgs_custom <- capture_messages(
- compare_datasets_from_yaml(ref, cand, error_msg_no_key = "CUSTOM_NO_KEY_MSG")
+ out <- catch_error_and_messages(
+ compare_datasets_from_yaml(dplyr::tbl(con, "ref5"), dplyr::tbl(con, "cand3"))
+ )
+ expect_match(out$error, "same number of rows", fixed = TRUE)
+ # The mismatch is decidable from the precomputed counts: no table
+ # materialisation (and no misleading collecting note) before the stop
+ expect_false(any(grepl("collecting non-local tables", out$messages, fixed = TRUE)))
+})
+
+test_that("mixed local/lazy inputs work on the positional path", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ df <- data.frame(value = c(1.0, 2.0, 3.0))
+ duckdb::dbWriteTable(con, "t3", df)
+
+ # Local reference + lazy candidate: the candidate must be collected too
+ res <- suppressMessages(
+ compare_datasets_from_yaml(df, dplyr::tbl(con, "t3"))
+ )
+ expect_true(res$all_passed)
+
+ # Lazy reference + local candidate
+ res2 <- suppressMessages(
+ compare_datasets_from_yaml(dplyr::tbl(con, "t3"), df)
)
- expect_true(any(grepl("CUSTOM_NO_KEY_MSG", msgs_custom)))
+ expect_true(res2$all_passed)
})
diff --git a/tests/testthat/test-equivalence-guard.R b/tests/testthat/test-equivalence-guard.R
index 7db51a9..5b4fd9b 100644
--- a/tests/testthat/test-equivalence-guard.R
+++ b/tests/testthat/test-equivalence-guard.R
@@ -1,10 +1,15 @@
-# Equivalence guard for the speed optimization of compare_datasets_from_yaml.
+# Equivalence guard for compare_datasets_from_yaml.
#
# These tests pin BOTH the verdict (all_passed) AND the exact set of failing
# cells (as recovered through pointblank::get_data_extracts, the way the
-# enc.mco oracle consumes the result). They must stay green before and after
-# the optimization: the optimized code may change HOW the verdict is produced,
-# never WHAT it produces.
+# enc.mco oracle consumes the result). Optimizations may change HOW the
+# verdict is produced, never WHAT it produces. Every case also runs on the
+# lazy backend (see run_compare in helper-equivalence.R): both backends must
+# agree on the verdict, the failing columns and their failing-row counts.
+# The per-cell pinning (key tuples) is local-only, because lazy extracts
+# carry the boolean check columns without the key columns or data values.
+# A documented semantic fix (with a NEWS "Breaking changes" entry) MAY
+# re-pin a verdict here, deliberately and never silently.
KEY <- c("id", ".row")
@@ -101,31 +106,36 @@ test_that("multiple failing cells across columns are all surfaced", {
expect_equal(out$cells, c("a@3|1", "n@5|1", "txt@2|1"))
})
-test_that("character NA (any side) passes under na_equal = TRUE", {
- for (mutate in list(
- function(r, c) { r$txt[2] <- NA; c$txt[2] <- NA; list(r, c) },
- function(r, c) { c$txt[2] <- NA; list(r, c) },
- function(r, c) { r$txt[2] <- NA; list(r, c) }
- )) {
- ref <- make_ref(); cand <- ref
- m <- mutate(ref, cand); ref <- m[[1]]; cand <- m[[2]]
- out <- run_compare(ref, cand, key = KEY, na_equal_default = TRUE)
- expect_true(out$all_passed)
- expect_equal(out$cells, character(0))
- }
-})
+# NA semantics for equality columns (aligned with the tolerance kernel and the
+# lazy SQL path): a two-sided NA follows na_equal, a one-sided NA is always a
+# difference.
-test_that("character NA (any side) fails under na_equal = FALSE", {
- for (mutate in list(
- function(r, c) { r$txt[2] <- NA; c$txt[2] <- NA; list(r, c) },
- function(r, c) { c$txt[2] <- NA; list(r, c) },
- function(r, c) { r$txt[2] <- NA; list(r, c) }
- )) {
- ref <- make_ref(); cand <- ref
- m <- mutate(ref, cand); ref <- m[[1]]; cand <- m[[2]]
- out <- run_compare(ref, cand, key = KEY, na_equal_default = FALSE)
- expect_false(out$all_passed)
- expect_equal(out$cells, "txt@2|1")
+test_that("character NA on both sides follows na_equal", {
+ ref <- make_ref(); cand <- ref
+ ref$txt[2] <- NA
+ cand$txt[2] <- NA
+
+ out_true <- run_compare(ref, cand, key = KEY, na_equal_default = TRUE)
+ expect_true(out_true$all_passed)
+ expect_equal(out_true$cells, character(0))
+
+ out_false <- run_compare(ref, cand, key = KEY, na_equal_default = FALSE)
+ expect_false(out_false$all_passed)
+ expect_equal(out_false$cells, "txt@2|1")
+})
+
+test_that("character NA on one side only fails regardless of na_equal", {
+ for (na_equal in c(TRUE, FALSE)) {
+ for (mutate in list(
+ function(r, c) { c$txt[2] <- NA; list(r, c) },
+ function(r, c) { r$txt[2] <- NA; list(r, c) }
+ )) {
+ ref <- make_ref(); cand <- ref
+ m <- mutate(ref, cand); ref <- m[[1]]; cand <- m[[2]]
+ out <- run_compare(ref, cand, key = KEY, na_equal_default = na_equal)
+ expect_false(out$all_passed)
+ expect_equal(out$cells, "txt@2|1")
+ }
}
})
diff --git a/tests/testthat/test-extract-diagnostics.R b/tests/testthat/test-extract-diagnostics.R
index 55baed3..f0b93f6 100644
--- a/tests/testthat/test-extract-diagnostics.R
+++ b/tests/testthat/test-extract-diagnostics.R
@@ -22,7 +22,7 @@ test_that("failing extracts expose the measured deviation and threshold", {
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
+ extracts <- pointblank::get_data_extracts(result$response)
expect_true(length(extracts) >= 1)
ex <- extracts[[1]]
@@ -53,7 +53,7 @@ test_that("diagnostics are computed for failing tolerance columns only", {
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
+ extracts <- pointblank::get_data_extracts(result$response)
ex <- extracts[[1]]
expect_true(all(c("bad__absdiff", "bad__thresh") %in% names(ex)))
@@ -72,7 +72,7 @@ test_that("all-pass fast path materialises no diagnostic columns", {
expect_true(result$all_passed)
expect_false(any(grepl("__absdiff$|__thresh$", colnames(result$agent$tbl))))
- expect_length(pointblank::get_data_extracts(result$reponse), 0L)
+ expect_length(pointblank::get_data_extracts(result$response), 0L)
})
test_that("add_diff_columns appends only __absdiff/__thresh, matching the kernel", {
diff --git a/tests/testthat/test-extraction-params.R b/tests/testthat/test-extraction-params.R
index 2156e11..18bd66c 100644
--- a/tests/testthat/test-extraction-params.R
+++ b/tests/testthat/test-extraction-params.R
@@ -26,7 +26,7 @@ test_that("extract_failed defaults to TRUE", {
expect_false(result$all_passed)
# With extract_failed = TRUE (default), extracts should contain data
- extracts <- pointblank::get_data_extracts(result$reponse)
+ extracts <- pointblank::get_data_extracts(result$response)
expect_true(length(extracts) > 0 || is.list(extracts))
})
@@ -40,12 +40,11 @@ test_that("extract_failed = FALSE prevents row extraction", {
expect_false(result$all_passed)
# With extract_failed = FALSE, no rows should be extracted
- extracts <- pointblank::get_data_extracts(result$reponse)
- # Extracts should be empty or contain empty data frames
- if (length(extracts) > 0) {
- total_rows <- sum(vapply(extracts, nrow, integer(1)))
- expect_equal(total_rows, 0)
- }
+ extracts <- pointblank::get_data_extracts(result$response)
+ # Extracts must be empty or contain only empty data frames: computing the
+ # total over an empty list gives 0, so the assertion never silently skips
+ total_rows <- sum(vapply(extracts, nrow, integer(1)))
+ expect_equal(total_rows, 0)
})
test_that("extract_failed accepts logical values only in practice", {
@@ -83,15 +82,15 @@ test_that("get_first_n limits extracted rows", {
)
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
+ extracts <- pointblank::get_data_extracts(result$response)
# Each extract should have at most 5 rows
- if (length(extracts) > 0) {
- for (ext in extracts) {
- if (is.data.frame(ext) && nrow(ext) > 0) {
- expect_lte(nrow(ext), 5)
- }
- }
+ # Assert the precondition first: a failing comparison with extraction
+ # enabled must produce at least one extract (a silent empty list would
+ # make the cap check vacuous)
+ expect_gt(length(extracts), 0)
+ for (ext in extracts) {
+ expect_lte(nrow(as.data.frame(ext)), 5)
}
})
@@ -104,14 +103,14 @@ test_that("get_first_n = 1 extracts only first failure", {
)
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
-
- if (length(extracts) > 0) {
- for (ext in extracts) {
- if (is.data.frame(ext) && nrow(ext) > 0) {
- expect_lte(nrow(ext), 1)
- }
- }
+ extracts <- pointblank::get_data_extracts(result$response)
+
+ # Assert the precondition first: a failing comparison with extraction
+ # enabled must produce at least one extract (a silent empty list would
+ # make the cap check vacuous)
+ expect_gt(length(extracts), 0)
+ for (ext in extracts) {
+ expect_lte(nrow(as.data.frame(ext)), 1)
}
})
@@ -158,14 +157,14 @@ test_that("sample_n limits extracted rows via random sampling", {
)
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
-
- if (length(extracts) > 0) {
- for (ext in extracts) {
- if (is.data.frame(ext) && nrow(ext) > 0) {
- expect_lte(nrow(ext), 10)
- }
- }
+ extracts <- pointblank::get_data_extracts(result$response)
+
+ # Assert the precondition first: a failing comparison with extraction
+ # enabled must produce at least one extract (a silent empty list would
+ # make the cap check vacuous)
+ expect_gt(length(extracts), 0)
+ for (ext in extracts) {
+ expect_lte(nrow(as.data.frame(ext)), 10)
}
})
@@ -178,14 +177,14 @@ test_that("sample_n = 1 extracts only one random failure", {
)
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
-
- if (length(extracts) > 0) {
- for (ext in extracts) {
- if (is.data.frame(ext) && nrow(ext) > 0) {
- expect_lte(nrow(ext), 1)
- }
- }
+ extracts <- pointblank::get_data_extracts(result$response)
+
+ # Assert the precondition first: a failing comparison with extraction
+ # enabled must produce at least one extract (a silent empty list would
+ # make the cap check vacuous)
+ expect_gt(length(extracts), 0)
+ for (ext in extracts) {
+ expect_lte(nrow(as.data.frame(ext)), 1)
}
})
@@ -277,14 +276,14 @@ test_that("sample_limit caps sample_frac results", {
)
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
-
- if (length(extracts) > 0) {
- for (ext in extracts) {
- if (is.data.frame(ext) && nrow(ext) > 0) {
- expect_lte(nrow(ext), 5)
- }
- }
+ extracts <- pointblank::get_data_extracts(result$response)
+
+ # Assert the precondition first: a failing comparison with extraction
+ # enabled must produce at least one extract (a silent empty list would
+ # make the cap check vacuous)
+ expect_gt(length(extracts), 0)
+ for (ext in extracts) {
+ expect_lte(nrow(as.data.frame(ext)), 5)
}
})
@@ -298,14 +297,14 @@ test_that("sample_limit = 1 limits to single row", {
)
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
-
- if (length(extracts) > 0) {
- for (ext in extracts) {
- if (is.data.frame(ext) && nrow(ext) > 0) {
- expect_lte(nrow(ext), 1)
- }
- }
+ extracts <- pointblank::get_data_extracts(result$response)
+
+ # Assert the precondition first: a failing comparison with extraction
+ # enabled must produce at least one extract (a silent empty list would
+ # make the cap check vacuous)
+ expect_gt(length(extracts), 0)
+ for (ext in extracts) {
+ expect_lte(nrow(as.data.frame(ext)), 1)
}
})
@@ -336,13 +335,11 @@ test_that("extract_failed = FALSE overrides other extraction params", {
)
expect_false(result$all_passed)
- extracts <- pointblank::get_data_extracts(result$reponse)
+ extracts <- pointblank::get_data_extracts(result$response)
# With extract_failed = FALSE, should have no extracted rows
- if (length(extracts) > 0) {
- total_rows <- sum(vapply(extracts, nrow, integer(1)))
- expect_equal(total_rows, 0)
- }
+ total_rows <- sum(vapply(extracts, nrow, integer(1)))
+ expect_equal(total_rows, 0)
})
test_that("get_first_n and sample_n are mutually exclusive (last one wins or pointblank handles)", {
@@ -436,7 +433,7 @@ test_that("lightweight mode (extract_failed = FALSE) produces smaller output", {
expect_equal(result_full$all_passed, result_light$all_passed)
# Light version should have no extracts
- extracts_light <- pointblank::get_data_extracts(result_light$reponse)
+ extracts_light <- pointblank::get_data_extracts(result_light$response)
if (length(extracts_light) > 0) {
total_rows_light <- sum(vapply(extracts_light, nrow, integer(1)))
expect_equal(total_rows_light, 0)
@@ -457,8 +454,8 @@ test_that("get_first_n reduces extract size compared to no limit", {
get_first_n = 5
)
- extracts_unlimited <- pointblank::get_data_extracts(result_unlimited$reponse)
- extracts_limited <- pointblank::get_data_extracts(result_limited$reponse)
+ extracts_unlimited <- pointblank::get_data_extracts(result_unlimited$response)
+ extracts_limited <- pointblank::get_data_extracts(result_limited$response)
# Count total rows in extracts
count_rows <- function(extracts) {
diff --git a/tests/testthat/test-factor-columns.R b/tests/testthat/test-factor-columns.R
new file mode 100644
index 0000000..6d14f1c
--- /dev/null
+++ b/tests/testthat/test-factor-columns.R
@@ -0,0 +1,99 @@
+# Factor columns are classed "character" by detect_column_types() and receive
+# the character rules, so they must be compared as the character values they
+# display: preprocessing converts them, making the verdict independent of
+# stringsAsFactors / haven-style imports.
+
+test_that("factor and character candidates get the same verdict under normalization", {
+ ref <- data.frame(id = 1:2, s = c("alpha", "beta"), stringsAsFactors = FALSE)
+ cand_chr <- data.frame(id = 1:2, s = c("ALPHA", " beta"), stringsAsFactors = FALSE)
+ cand_fct <- data.frame(id = 1:2, s = factor(c("ALPHA", " beta")))
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(
+ ref,
+ key = "id", path = yaml_path,
+ character_case_insensitive = TRUE, character_trim = TRUE
+ )
+
+ res_chr <- compare_datasets_from_yaml(ref, cand_chr, key = "id", path = yaml_path)
+ res_fct <- compare_datasets_from_yaml(ref, cand_fct, key = "id", path = yaml_path)
+ expect_true(res_chr$all_passed)
+ expect_true(res_fct$all_passed)
+})
+
+test_that("factor columns are normalized on the reference side and both sides", {
+ ref_fct <- data.frame(id = 1:2, s = factor(c("ALPHA", " beta")))
+ cand_chr <- data.frame(id = 1:2, s = c("alpha", "beta"), stringsAsFactors = FALSE)
+ cand_fct <- data.frame(id = 1:2, s = factor(c("alpha", "beta")))
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(
+ data.frame(id = 1:2, s = c("x", "y"), stringsAsFactors = FALSE),
+ key = "id", path = yaml_path,
+ character_case_insensitive = TRUE, character_trim = TRUE
+ )
+
+ res_ref_side <- compare_datasets_from_yaml(ref_fct, cand_chr, key = "id", path = yaml_path)
+ expect_true(res_ref_side$all_passed)
+
+ res_both <- compare_datasets_from_yaml(ref_fct, cand_fct, key = "id", path = yaml_path)
+ expect_true(res_both$all_passed)
+})
+
+test_that("factors with differing level sets compare cleanly", {
+ # == on two factors with different level sets errors in base R; after the
+ # character conversion the comparison must work and give the right verdict
+ ref <- data.frame(id = 1:3, s = factor(c("a", "b", "c"), levels = c("a", "b", "c")))
+ cand <- data.frame(id = 1:3, s = factor(c("a", "b", "d"), levels = c("a", "b", "d")))
+
+ res <- compare_datasets_from_yaml(ref, cand, key = "id")
+ expect_false(res$all_passed)
+ cov <- res$coverage
+ expect_identical(
+ as.integer(cov$n_failed[cov$column == "s" & cov$check == "equality"]),
+ 1L
+ )
+
+ # Identical displayed values with different (unused) levels pass
+ cand_same <- data.frame(id = 1:3, s = factor(c("a", "b", "c"), levels = c("c", "b", "a", "z")))
+ res_same <- compare_datasets_from_yaml(ref, cand_same, key = "id")
+ expect_true(res_same$all_passed)
+})
+
+test_that("a factor key column joins correctly", {
+ ref <- data.frame(id = factor(c("k1", "k2", "k3")), x = c(1.0, 2.0, 3.0))
+ cand <- data.frame(id = c("k3", "k1", "k2"), x = c(3.0, 1.0, 2.0),
+ stringsAsFactors = FALSE)
+
+ res <- compare_datasets_from_yaml(ref, cand, key = "id")
+ expect_true(res$all_passed)
+})
+
+test_that("Arrow dictionary (factor) columns are normalized on the lazy path", {
+ skip_if_not_installed("arrow")
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ skip_if_not_installed("stringr")
+
+ # Arrow carries factors as dictionary columns: the raw 0-row schema still
+ # answers is.factor() TRUE, which must route them through the lazy
+ # normalization path
+ ref <- data.frame(id = 1:2, s = c("alpha", "beta"), stringsAsFactors = FALSE)
+ cand <- data.frame(id = 1:2, s = factor(c("ALPHA", " beta")))
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(
+ ref,
+ key = "id", path = yaml_path,
+ character_case_insensitive = TRUE, character_trim = TRUE
+ )
+
+ res <- suppressMessages(compare_datasets_from_yaml(
+ arrow::arrow_table(ref), arrow::arrow_table(cand),
+ key = "id", path = yaml_path
+ ))
+ expect_true(res$all_passed)
+})
diff --git a/tests/testthat/test-input-validation.R b/tests/testthat/test-input-validation.R
index 2811fc0..9399278 100644
--- a/tests/testthat/test-input-validation.R
+++ b/tests/testthat/test-input-validation.R
@@ -9,13 +9,13 @@ test_that("write_rules_template handles invalid key parameter", {
unlink(temp_path)
# Test empty key vector (should error)
- expect_error(write_rules_template(df, key = character(0), path = "test.yaml"))
+ expect_error(write_rules_template(df, key = character(0), path = tempfile(fileext = ".yaml")))
# Test non-character key (should error)
- expect_error(write_rules_template(df, key = 123, path = "test.yaml"))
+ expect_error(write_rules_template(df, key = 123, path = tempfile(fileext = ".yaml")))
# Test non-existent column as key (should error)
- expect_error(write_rules_template(df, key = "nonexistent", path = "test.yaml"))
+ expect_error(write_rules_template(df, key = "nonexistent", path = tempfile(fileext = ".yaml")))
})
test_that("write_rules_template handles invalid ignore_columns_default", {
@@ -96,16 +96,17 @@ test_that("write_rules_template handles extreme values", {
test_that("write_rules_template handles invalid data types", {
# Test with non-dataframe input (should error)
- expect_error(write_rules_template("not_a_dataframe", key = "id", path = "test.yaml"))
+ expect_error(write_rules_template("not_a_dataframe", key = "id", path = tempfile(fileext = ".yaml")))
# Test with empty dataframe (should error on key)
empty_df <- data.frame()
- expect_error(write_rules_template(empty_df, key = "id", path = "test.yaml"))
+ expect_error(write_rules_template(empty_df, key = "id", path = tempfile(fileext = ".yaml")))
# Test with dataframe having no rows (should work - creates template with empty by_name)
no_rows_df <- data.frame(id = integer(0), value = numeric(0))
- expect_no_error(write_rules_template(no_rows_df, key = "id", path = "test_no_rows.yaml"))
- if (file.exists("test_no_rows.yaml")) unlink("test_no_rows.yaml")
+ no_rows_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(no_rows_path), add = TRUE)
+ expect_no_error(write_rules_template(no_rows_df, key = "id", path = no_rows_path))
})
test_that("write_rules_template handles file path issues", {
@@ -138,18 +139,18 @@ test_that("write_rules_template handles label edge cases", {
df <- data.frame(id = 1:2, value = 1:2)
# Test NULL label (should use default)
- template_path <- "test_null_label.yaml"
+ template_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(template_path), add = TRUE)
write_rules_template(df, key = "id", label = NULL, path = template_path)
rules <- read_rules(template_path)
- expect_match(rules$defaults$label, "comparaison")
- unlink(template_path)
+ expect_match(rules$defaults$label, "comparison")
# Test empty label (should use default)
- template_path <- "test_empty_label.yaml"
- write_rules_template(df, key = "id", label = "", path = template_path)
- rules <- read_rules(template_path)
- expect_match(rules$defaults$label, "comparaison")
- unlink(template_path)
+ template_path2 <- tempfile(fileext = ".yaml")
+ on.exit(unlink(template_path2), add = TRUE)
+ write_rules_template(df, key = "id", label = "", path = template_path2)
+ rules <- read_rules(template_path2)
+ expect_match(rules$defaults$label, "comparison")
# Test very long label
long_label <- paste(rep("very_long_label", 100), collapse = "_")
diff --git a/tests/testthat/test-internal-coverage.R b/tests/testthat/test-internal-coverage.R
index d2b6fa9..e9c55f2 100644
--- a/tests/testthat/test-internal-coverage.R
+++ b/tests/testthat/test-internal-coverage.R
@@ -21,7 +21,7 @@ test_that("build_report_agent returns an agent for empty coverage (no real agent
})
test_that("datadiff_report_html errors when the result has no coverage", {
- expect_error(datadiff_report_html(list(reponse = NULL)), "coverage")
+ expect_error(datadiff_report_html(list(response = NULL)), "coverage")
})
test_that("datadiff_render_report tolerates a missing cache environment", {
@@ -33,7 +33,7 @@ test_that("datadiff_render_report tolerates a missing cache environment", {
res <- suppressMessages(
compare_datasets_from_yaml(ref, ref, key = c("id", ".row"), path = tmp)
)
- x <- res$reponse
+ x <- res$response
attr(x, "datadiff_render") <- NULL # force the fallback branch
rep <- datadiff_render_report(x)
expect_s3_class(rep, "gt_tbl")
diff --git a/tests/testthat/test-internal-helpers.R b/tests/testthat/test-internal-helpers.R
new file mode 100644
index 0000000..b8c38e3
--- /dev/null
+++ b/tests/testthat/test-internal-helpers.R
@@ -0,0 +1,47 @@
+# Direct unit tests for small internal helpers that previously had no
+# dedicated coverage.
+
+test_that("format_key_examples formats and truncates", {
+ keys <- data.frame(id = c(1, 2, 3, 4), grp = c("a", "b", "c", "d"))
+ out <- format_key_examples(keys, c("id", "grp"))
+ expect_length(out, 4L) # 3 examples + "..."
+ expect_identical(out[4], "...")
+ expect_match(out[1], "id = 1")
+ expect_match(out[1], "grp = a")
+
+ out2 <- format_key_examples(keys[1:2, , drop = FALSE], c("id", "grp"))
+ expect_length(out2, 2L) # below threshold: no marker
+})
+
+test_that("get_col_names works on data.frames and 0-column inputs", {
+ expect_identical(get_col_names(data.frame(a = 1, b = 2)), c("a", "b"))
+ expect_length(get_col_names(data.frame()), 0L)
+})
+
+test_that("is_non_local and is_arrow classify inputs", {
+ df <- data.frame(a = 1)
+ expect_false(is_non_local(df))
+ expect_false(is_arrow(df))
+
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+ duckdb::dbWriteTable(con, "t", df)
+ lazy <- dplyr::tbl(con, "t")
+ expect_true(is_non_local(lazy))
+ expect_false(is_arrow(lazy))
+
+ skip_if_not_installed("arrow")
+ at <- arrow::arrow_table(df)
+ expect_true(is_non_local(at))
+ expect_true(is_arrow(at))
+})
+
+test_that("datadiff_report_html with file = NULL returns the report invisibly", {
+ ref <- data.frame(id = 1:2, x = c(1, 2))
+ res <- suppressMessages(compare_datasets_from_yaml(ref, ref, key = "id"))
+ vis <- withVisible(datadiff_report_html(res, file = NULL))
+ expect_false(vis$visible)
+ expect_false(is.null(vis$value))
+})
diff --git a/tests/testthat/test-key-parameter.R b/tests/testthat/test-key-parameter.R
index 346b5a7..9968132 100644
--- a/tests/testthat/test-key-parameter.R
+++ b/tests/testthat/test-key-parameter.R
@@ -26,22 +26,22 @@ by_type:
abs: 0.5
'
- writeLines(yaml_content, 'test_key_param.yaml')
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines(yaml_content, con = yaml_path)
# Test 1: Without key parameter, should use YAML key (customer_id)
- result_yaml <- compare_datasets_from_yaml(ref, cand, path = 'test_key_param.yaml')
+ result_yaml <- compare_datasets_from_yaml(ref, cand, path = yaml_path)
expect_true(result_yaml$all_passed)
# Test 2: With key parameter "id", should override YAML and use "id"
- result_param <- compare_datasets_from_yaml(ref, cand, key = "id", path = 'test_key_param.yaml')
+ result_param <- compare_datasets_from_yaml(ref, cand, key = "id", path = yaml_path)
expect_true(result_param$all_passed)
# Test 3: With multiple keys parameter, should work
- result_multi <- compare_datasets_from_yaml(ref, cand, key = c("id", "customer_id"), path = 'test_key_param.yaml')
+ result_multi <- compare_datasets_from_yaml(ref, cand, key = c("id", "customer_id"), path = yaml_path)
expect_true(result_multi$all_passed)
- # Clean up
- unlink('test_key_param.yaml')
})
test_that("compare_datasets_from_yaml key parameter handles NULL and missing keys", {
@@ -58,21 +58,21 @@ by_type:
abs: 0.5
'
- writeLines(yaml_content, 'test_key_null.yaml')
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines(yaml_content, con = yaml_path)
# Test 1: NULL key parameter with no keys in YAML should work (no key comparison)
- result_null <- compare_datasets_from_yaml(ref, cand, key = NULL, path = 'test_key_null.yaml')
+ result_null <- compare_datasets_from_yaml(ref, cand, key = NULL, path = yaml_path)
expect_true(result_null$all_passed)
# Test 2: No key parameter (default NULL) with no keys in YAML
- result_default <- compare_datasets_from_yaml(ref, cand, path = 'test_key_null.yaml')
+ result_default <- compare_datasets_from_yaml(ref, cand, path = yaml_path)
expect_true(result_default$all_passed)
- # Clean up
- unlink('test_key_null.yaml')
})
-test_that("compare_datasets_from_yaml key parameter validation works", {
+test_that("compare_datasets_from_yaml errors when key columns are absent", {
ref <- data.frame(id = 1:3, value = c(10.0, 20.0, 30.0))
cand <- data.frame(id = 1:3, value = c(10.1, 20.1, 30.1))
@@ -85,17 +85,92 @@ by_type:
abs: 0.5
'
- writeLines(yaml_content, 'test_key_validation.yaml')
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines(yaml_content, con = yaml_path)
- # Test: Invalid key should return an empty output with all_passed FALSE
+ # Key absent from both datasets: explicit error naming both datasets
+ err_both <- tryCatch(
+ compare_datasets_from_yaml(ref, cand, key = "nonexistent", path = yaml_path),
+ error = function(e) {
+ conditionMessage(e)
+ }
+ )
+ expect_match(err_both, "Key column\\(s\\) not found")
+ expect_match(err_both, "'nonexistent'", fixed = TRUE)
+ expect_match(err_both, "data_reference", fixed = TRUE)
+ expect_match(err_both, "data_candidate", fixed = TRUE)
+
+ # Key present in the reference but absent from the candidate:
+ # only the candidate is named
+ cand_no_id <- data.frame(idx = 1:3, value = c(10.1, 20.1, 30.1))
+ err_cand <- tryCatch(
+ compare_datasets_from_yaml(ref, cand_no_id, key = "id", path = yaml_path),
+ error = function(e) {
+ conditionMessage(e)
+ }
+ )
+ expect_match(err_cand, "'id'", fixed = TRUE)
+ expect_match(err_cand, "data_candidate", fixed = TRUE)
+ expect_no_match(err_cand, "data_reference", fixed = TRUE)
+
+ # Same scenario without an explicit YAML path (auto-generated template)
+ expect_error(
+ compare_datasets_from_yaml(ref, cand_no_id, key = "id"),
+ regexp = "missing in data_candidate"
+ )
+
+ # Without an explicit YAML path, a key absent from the *reference* must get
+ # the same explicit error format (not the reference-only error of
+ # write_rules_template on the auto-generated-template path)
+ ref_no_id <- data.frame(idx = 1:3, value = c(10.0, 20.0, 30.0))
+ cand_id <- data.frame(id = 1:3, value = c(10.1, 20.1, 30.1))
+ err_ref <- tryCatch(
+ compare_datasets_from_yaml(ref_no_id, cand_id, key = "id"),
+ error = function(e) {
+ conditionMessage(e)
+ }
+ )
+ expect_match(err_ref, "'id'", fixed = TRUE)
+ expect_match(err_ref, "missing in data_reference", fixed = TRUE)
+ expect_no_match(err_ref, "data_candidate", fixed = TRUE)
+
+ # Multi-column key with a single absent column: only that column is reported
+ ref_multi <- data.frame(id = 1:3, grp = 1:3, value = 1:3)
+ cand_multi <- data.frame(id = 1:3, value = 1:3)
+ err_multi <- tryCatch(
+ compare_datasets_from_yaml(ref_multi, cand_multi, key = c("id", "grp"), path = yaml_path),
+ error = function(e) {
+ conditionMessage(e)
+ }
+ )
+ expect_match(err_multi, "'grp'", fixed = TRUE)
+ expect_no_match(err_multi, "'id'", fixed = TRUE)
+})
- res <- compare_datasets_from_yaml(ref, cand, key = "nonexistent", path = 'test_key_validation.yaml')
+test_that("compare_datasets_from_yaml rejects empty or non-character keys", {
+ ref <- data.frame(id = 1:3, value = c(10.0, 20.0, 30.0))
+ cand <- data.frame(id = 1:3, value = c(10.1, 20.1, 30.1))
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(ref, key = "id", path = yaml_path)
- expect_false(res$all_passed)
- expect_null(res$agent)
- expect_null(res$reponse)
- # Clean up
- unlink('test_key_validation.yaml')
+ # An empty key must not silently fall through to a keyless cross join
+ expect_error(
+ compare_datasets_from_yaml(ref, cand, key = character(0), path = yaml_path),
+ regexp = "non-empty character vector"
+ )
+ expect_error(
+ compare_datasets_from_yaml(ref, cand, key = character(0)),
+ regexp = "non-empty character vector"
+ )
+
+ # A non-character key gets a clear type error, not a missing-column error
+ expect_error(
+ compare_datasets_from_yaml(ref, cand, key = 1L, path = yaml_path),
+ regexp = "non-empty character vector"
+ )
})
test_that("key parameter takes precedence over YAML in edge cases", {
@@ -123,15 +198,15 @@ by_type:
abs: 0.1
'
- writeLines(yaml_content, 'test_key_precedence.yaml')
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines(yaml_content, con = yaml_path)
# Using YAML key (category) would compare X->X, Y->Z, Z->Y which fails
# But we override with id parameter
- result <- compare_datasets_from_yaml(ref, cand, key = "id", path = 'test_key_precedence.yaml')
+ result <- compare_datasets_from_yaml(ref, cand, key = "id", path = yaml_path)
expect_true(result$all_passed)
- # Clean up
- unlink('test_key_precedence.yaml')
})
# --- Duplicate key detection tests ---
@@ -246,52 +321,6 @@ test_that("no warning when keys are unique", {
)
})
-test_that("compare_datasets_from_yaml uses key parameter over YAML rules", {
- # Create test data with multiple potential key columns
- ref <- data.frame(
- id = 1:3,
- customer_id = c("A", "B", "C"),
- value = c(10.0, 20.0, 30.0),
- name = c("Alice", "Bob", "Charlie")
- )
-
- # Candidate data - same values but different order to test key-based sorting
- cand <- data.frame(
- id = c(2, 1, 3),
- customer_id = c("B", "A", "C"),
- value = c(20.1, 10.1, 30.1),
- name = c("Bob", "Alice", "Charlie")
- )
-
- # YAML with customer_id as key
- yaml_content <- '
-version: 1
-defaults:
- na_equal: yes
- keys: ["customer_id"]
-by_type:
- numeric:
- abs: 0.5
-'
-
- writeLines(yaml_content, 'test_key_param.yaml')
-
- # Test 1: Without key parameter, should use YAML key (customer_id)
- result_yaml <- compare_datasets_from_yaml(ref, cand, path = 'test_key_param.yaml')
- expect_true(result_yaml$all_passed)
-
- # Test 2: With key parameter "id", should override YAML and use "id"
- result_param <- compare_datasets_from_yaml(ref, cand, key = "id", path = 'test_key_param.yaml')
- expect_true(result_param$all_passed)
-
- # Test 3: With multiple keys parameter, should work
- result_multi <- compare_datasets_from_yaml(ref, cand, key = c("id", "customer_id"), path = 'test_key_param.yaml')
- expect_true(result_multi$all_passed)
-
- # Clean up
- unlink('test_key_param.yaml')
-})
-
test_that("warning truncates to 3 examples when more than 3 duplicate key values in reference", {
# 4 unique duplicate key values -> takes the else branch (n_dup_keys > 3)
diff --git a/tests/testthat/test-lazy-aggregates.R b/tests/testthat/test-lazy-aggregates.R
new file mode 100644
index 0000000..e8618c7
--- /dev/null
+++ b/tests/testthat/test-lazy-aggregates.R
@@ -0,0 +1,136 @@
+# Lazy verdict via SQL aggregates: the coverage counts come from one aggregate
+# scan in the database, and the boolean slim table is only collected for the
+# FAILING columns. A green lazy comparison must not load N rows of booleans
+# into R ("without loading data into R memory" is the documented promise).
+
+test_that("a green lazy comparison does not collect the boolean table", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ n <- 5e4
+ df <- data.frame(id = seq_len(n), a = rnorm(n), b = rnorm(n),
+ s = sample(letters, n, replace = TRUE))
+ duckdb::dbWriteTable(con, "ref_agg", df)
+ duckdb::dbWriteTable(con, "cand_agg", df)
+
+ res <- suppressMessages(compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_agg"), dplyr::tbl(con, "cand_agg"), key = "id"
+ ))
+ expect_true(res$all_passed)
+ # Coverage counts are intact (they now come from the SQL aggregates)
+ cov <- res$coverage
+ expect_identical(as.integer(cov$n[cov$column == "a" & cov$check == "tolerance"]), as.integer(n))
+ expect_identical(as.integer(sum(cov$n_failed)), 0L)
+ # The agent is the constant-size pass placeholder, not N collected rows
+ expect_lte(nrow(res$response$tbl), 1L)
+})
+
+test_that("a red lazy comparison collects only the failing boolean columns", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ n <- 1e4
+ df <- data.frame(id = seq_len(n), a = rnorm(n), b = rnorm(n),
+ s = sample(letters, n, replace = TRUE))
+ df2 <- df
+ df2$a[c(3, 7)] <- 999
+ duckdb::dbWriteTable(con, "ref_red2", df)
+ duckdb::dbWriteTable(con, "cand_red2", df2)
+
+ res <- suppressMessages(compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_red2"), dplyr::tbl(con, "cand_red2"), key = "id"
+ ))
+ expect_false(res$all_passed)
+
+ # Only the failing tolerance column (plus the row-count flag consumed by
+ # its validation step) reaches the local agent: b__ok and s__eq stay in
+ # the database
+ agent_cols <- names(res$response$tbl)
+ expect_true("a__ok" %in% agent_cols)
+ expect_false("b__ok" %in% agent_cols)
+ expect_false("s__eq" %in% agent_cols)
+
+ # The verdict details are unchanged: 2 failing rows on a, everything else
+ # green in the coverage
+ cov <- res$coverage
+ expect_identical(as.integer(cov$n_failed[cov$column == "a" & cov$check == "tolerance"]), 2L)
+ expect_identical(as.integer(cov$n_failed[cov$column == "b" & cov$check == "tolerance"]), 0L)
+ expect_identical(as.integer(cov$n_failed[cov$column == "s" & cov$check == "equality"]), 0L)
+
+ # And the extracts still surface the failing rows
+ ex <- pointblank::get_data_extracts(res$response)
+ expect_gte(length(ex), 1L)
+})
+
+test_that("SQL-aggregated counts match the collected booleans on SQLite too", {
+ skip_if_not_installed("RSQLite")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
+ on.exit(DBI::dbDisconnect(con), add = TRUE)
+
+ df <- data.frame(id = 1:50, x = c(rep(1, 45), rep(2, 5)),
+ s = rep(c("u", "v"), 25))
+ ref <- data.frame(id = 1:50, x = rep(1, 50), s = rep(c("u", "v"), 25))
+ DBI::dbWriteTable(con, "ref_sq", ref)
+ DBI::dbWriteTable(con, "cand_sq", df)
+
+ res <- suppressMessages(compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_sq"), dplyr::tbl(con, "cand_sq"), key = "id"
+ ))
+ expect_false(res$all_passed)
+ cov <- res$coverage
+ expect_identical(as.integer(cov$n_failed[cov$column == "x" & cov$check == "tolerance"]), 5L)
+ expect_identical(as.integer(cov$n_failed[cov$column == "s" & cov$check == "equality"]), 0L)
+})
+
+test_that("structural-only lazy failure keeps a failing verdict (no value checks)", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ duckdb::dbWriteTable(con, "ref_struct", data.frame(id = 1:3, x = c(1, 2, 3), extra_ref = 1:3))
+ duckdb::dbWriteTable(con, "cand_struct", data.frame(id = 1:3, x = c(1, 2, 3)))
+
+ p <- tempfile(fileext = ".yaml")
+ on.exit(unlink(p), add = TRUE)
+ writeLines(
+ "version: 1\ndefaults: {keys: [id], na_equal: yes}\nrow_validation: {check_count: no}\nby_type:\n numeric: {abs: 0.001}\n",
+ con = p
+ )
+ res <- suppressMessages(suppressWarnings(compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_struct"), dplyr::tbl(con, "cand_struct"),
+ key = "id", path = p
+ )))
+ # The missing column is the only failure: the agent verdict must agree
+ # with the coverage, not pass on a 0-unit dummy step
+ expect_false(res$all_passed)
+ expect_false(pointblank::all_passed(res$response))
+ expect_identical(res$missing_in_candidate, "extra_ref")
+})
+
+test_that("key-only lazy comparison runs without value columns", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ duckdb::dbWriteTable(con, "ref_keys", data.frame(id = 1:3))
+ duckdb::dbWriteTable(con, "cand_keys", data.frame(id = 1:3))
+
+ p <- tempfile(fileext = ".yaml")
+ on.exit(unlink(p), add = TRUE)
+ writeLines(
+ "version: 1\ndefaults: {keys: [id], na_equal: yes}\nrow_validation: {check_count: no}\n",
+ con = p
+ )
+ res <- suppressMessages(compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_keys"), dplyr::tbl(con, "cand_keys"),
+ key = "id", path = p
+ ))
+ expect_true(res$all_passed)
+})
diff --git a/tests/testthat/test-main.R b/tests/testthat/test-main.R
index e505206..2471942 100644
--- a/tests/testthat/test-main.R
+++ b/tests/testthat/test-main.R
@@ -60,3 +60,68 @@ test_that("read_rules validates YAML structure", {
expect_error(object = read_rules(invalid_path))
})
+
+test_that("read_rules gives an actionable error for unsupported versions", {
+ p <- tempfile(fileext = ".yaml")
+ on.exit(unlink(p), add = TRUE)
+ writeLines('version: 2\ndefaults: {na_equal: yes}', con = p)
+ err <- tryCatch(read_rules(p), error = function(e) {
+ conditionMessage(e)
+ })
+ expect_match(err, "version", ignore.case = TRUE)
+ expect_match(err, "2", fixed = TRUE) # the offending version
+ expect_match(err, "1", fixed = TRUE) # the supported version
+ expect_no_match(err, "is not TRUE", fixed = TRUE) # no raw stopifnot output
+})
+
+test_that("read_rules warns on unknown top-level and defaults fields", {
+ p <- tempfile(fileext = ".yaml")
+ on.exit(unlink(p), add = TRUE)
+ writeLines('
+version: 1
+defaults: {keys: [id], na_equal: yes, no_equal: yes}
+by_nmae:
+ numeric: {abs: 0.1}
+', con = p)
+ warns <- character(0)
+ withCallingHandlers(
+ rules <- read_rules(p),
+ warning = function(w) {
+ warns <<- c(warns, conditionMessage(w))
+ invokeRestart("muffleWarning")
+ }
+ )
+ expect_true(any(grepl("by_nmae", warns))) # typo of by_name flagged
+ expect_true(any(grepl("no_equal", warns))) # typo in defaults flagged
+})
+
+test_that("write_rules_template rejects an unsupported version upfront", {
+ df <- data.frame(id = 1:2, x = c(1, 2))
+ expect_error(
+ write_rules_template(df, key = "id", path = tempfile(fileext = ".yaml"),
+ version = 2),
+ regexp = "version"
+ )
+})
+
+test_that("read_rules stays actionable on non-scalar versions", {
+ p <- tempfile(fileext = ".yaml")
+ on.exit(unlink(p), add = TRUE)
+ writeLines('version: {v: 1}\ndefaults: {na_equal: yes}', con = p)
+ err <- tryCatch(read_rules(p), error = function(e) {
+ conditionMessage(e)
+ })
+ expect_match(err, "version", ignore.case = TRUE)
+ expect_no_match(err, "invalid format", fixed = TRUE) # no raw sprintf crash
+})
+
+test_that("read_rules rejects non-mapping sections explicitly", {
+ p <- tempfile(fileext = ".yaml")
+ on.exit(unlink(p), add = TRUE)
+ writeLines('version: 1\ndefaults: yes', con = p)
+ err <- tryCatch(read_rules(p), error = function(e) {
+ conditionMessage(e)
+ })
+ expect_match(err, "defaults")
+ expect_match(err, "mapping", ignore.case = TRUE)
+})
diff --git a/tests/testthat/test-na-equality-semantics.R b/tests/testthat/test-na-equality-semantics.R
new file mode 100644
index 0000000..f5fe6c7
--- /dev/null
+++ b/tests/testthat/test-na-equality-semantics.R
@@ -0,0 +1,82 @@
+# NA semantics for equality (non-tolerance) columns, aligned across the local
+# and lazy paths: a one-sided NA is always a difference; a two-sided NA follows
+# na_equal. Same semantics as the numeric tolerance kernel.
+
+compare_both_paths <- function(ref, cand, key, na_equal) {
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines(sprintf('
+version: 1
+defaults:
+ keys: [%s]
+ na_equal: %s
+row_validation:
+ check_count: no
+', key, if (na_equal) "yes" else "no"), con = yaml_path)
+
+ res_local <- suppressWarnings(suppressMessages(
+ compare_datasets_from_yaml(ref, cand, key = key, path = yaml_path)
+ ))
+
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+ duckdb::dbWriteTable(con, "ref_na", ref)
+ duckdb::dbWriteTable(con, "cand_na", cand)
+ res_lazy <- suppressWarnings(suppressMessages(
+ compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_na"), dplyr::tbl(con, "cand_na"),
+ key = key, path = yaml_path
+ )
+ ))
+ list(local = res_local$all_passed, lazy = res_lazy$all_passed)
+}
+
+test_that("one-sided NA on an equality column fails identically on both paths", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ ref <- data.frame(id = 1:2, s = c("a", NA), stringsAsFactors = FALSE)
+ cand <- data.frame(id = 1:2, s = c("a", "b"), stringsAsFactors = FALSE)
+
+ for (na_equal in c(TRUE, FALSE)) {
+ out <- compare_both_paths(ref, cand, key = "id", na_equal = na_equal)
+ expect_false(out$local)
+ expect_identical(out$local, out$lazy)
+ }
+
+ # Mirror case: NA on the candidate side
+ for (na_equal in c(TRUE, FALSE)) {
+ out <- compare_both_paths(cand, ref, key = "id", na_equal = na_equal)
+ expect_false(out$local)
+ expect_identical(out$local, out$lazy)
+ }
+})
+
+test_that("two-sided NA on an equality column follows na_equal on both paths", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ ref <- data.frame(id = 1:2, s = c("a", NA), stringsAsFactors = FALSE)
+ cand <- data.frame(id = 1:2, s = c("a", NA), stringsAsFactors = FALSE)
+
+ out_true <- compare_both_paths(ref, cand, key = "id", na_equal = TRUE)
+ expect_true(out_true$local)
+ expect_identical(out_true$local, out_true$lazy)
+
+ out_false <- compare_both_paths(ref, cand, key = "id", na_equal = FALSE)
+ expect_false(out_false$local)
+ expect_identical(out_false$local, out_false$lazy)
+})
+
+test_that("a candidate row with no reference match fails on both paths", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ ref <- data.frame(id = 1:2, s = c("a", "b"), stringsAsFactors = FALSE)
+ cand <- data.frame(id = 1:3, s = c("a", "b", "c"), stringsAsFactors = FALSE)
+
+ # The left join fills the reference side with NA: one-sided NA, hence a
+ # failure, even under na_equal = TRUE
+ for (na_equal in c(TRUE, FALSE)) {
+ out <- compare_both_paths(ref, cand, key = "id", na_equal = na_equal)
+ expect_false(out$local)
+ expect_identical(out$local, out$lazy)
+ }
+})
diff --git a/tests/testthat/test-nan-inf-sql-equivalence.R b/tests/testthat/test-nan-inf-sql-equivalence.R
new file mode 100644
index 0000000..0e7eb8e
--- /dev/null
+++ b/tests/testthat/test-nan-inf-sql-equivalence.R
@@ -0,0 +1,147 @@
+# NaN/Inf semantics of the templated SQL booleans (lazy path), with the R
+# tolerance kernel as oracle (NOT the historical dplyr path, which shared the
+# same NULL-only blind spot): same-sign infinities pass, a one-sided
+# NA/NaN/Inf fails, NA/NaN on both sides follows na_equal.
+
+# Boolean vector produced by the templated SQL for one tolerance column.
+lazy_tol_ok <- function(con, cand, ref, abs_tol, rel_tol, na_equal) {
+ cmp <- data.frame(id = seq_along(cand), x = cand, x__reference = ref)
+ DBI::dbWriteTable(con, "cmp_nan_inf", cmp, overwrite = TRUE)
+ rules <- list(x = list(abs = abs_tol, rel = rel_tol))
+ out <- add_bool_cols_sql(
+ dplyr::tbl(con, "cmp_nan_inf"),
+ tol_cols = "x", eq_cols = character(0),
+ col_rules = rules, ref_suffix = "__reference", na_equal = na_equal
+ )
+ res <- dplyr::collect(dplyr::arrange(out, id))
+ as.logical(res$x__ok)
+}
+
+test_that("DuckDB tolerance booleans match the R kernel on NaN/Inf/NA", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ vals <- c(1, 2.5, NA, NaN, Inf, -Inf, 0)
+ grid <- expand.grid(cand = vals, ref = vals)
+
+ for (na_equal in c(TRUE, FALSE)) {
+ expected <- compute_tolerance_col(
+ grid$cand, grid$ref,
+ abs_tol = 0.1, rel_tol = 0, na_equal = na_equal
+ )$ok
+ got <- lazy_tol_ok(con, grid$cand, grid$ref,
+ abs_tol = 0.1, rel_tol = 0, na_equal = na_equal)
+ expect_identical(got, expected)
+ }
+
+ # Relative tolerance: an infinite reference must not produce an infinite
+ # threshold that lets a finite candidate pass
+ for (na_equal in c(TRUE, FALSE)) {
+ expected_rel <- compute_tolerance_col(
+ grid$cand, grid$ref,
+ abs_tol = 0, rel_tol = 0.5, na_equal = na_equal
+ )$ok
+ got_rel <- lazy_tol_ok(con, grid$cand, grid$ref,
+ abs_tol = 0, rel_tol = 0.5, na_equal = na_equal)
+ expect_identical(got_rel, expected_rel)
+ }
+})
+
+test_that("SQLite tolerance booleans match the R kernel on Inf/NA", {
+ skip_if_not_installed("RSQLite")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(RSQLite::SQLite(), ":memory:")
+ on.exit(DBI::dbDisconnect(con), add = TRUE)
+
+ # No NaN in the grid: SQLite has no NaN storage (it arrives as NULL,
+ # which the NA rules then cover)
+ vals <- c(1, 2.5, NA, Inf, -Inf, 0)
+ grid <- expand.grid(cand = vals, ref = vals)
+
+ for (na_equal in c(TRUE, FALSE)) {
+ expected <- compute_tolerance_col(
+ grid$cand, grid$ref,
+ abs_tol = 0.1, rel_tol = 0, na_equal = na_equal
+ )$ok
+ got <- lazy_tol_ok(con, grid$cand, grid$ref,
+ abs_tol = 0.1, rel_tol = 0, na_equal = na_equal)
+ expect_identical(got, expected)
+ }
+
+ # Relative tolerance: an infinite reference must not let a finite
+ # candidate pass through an infinite threshold (DBL_MAX detection)
+ for (na_equal in c(TRUE, FALSE)) {
+ expected_rel <- compute_tolerance_col(
+ grid$cand, grid$ref,
+ abs_tol = 0, rel_tol = 0.5, na_equal = na_equal
+ )$ok
+ got_rel <- lazy_tol_ok(con, grid$cand, grid$ref,
+ abs_tol = 0, rel_tol = 0.5, na_equal = na_equal)
+ expect_identical(got_rel, expected_rel)
+ }
+})
+
+test_that("numeric equality columns treat NaN as NA-like on DuckDB", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ run_eq <- function(cand, ref, na_equal) {
+ cmp <- data.frame(id = seq_along(cand), x = cand, x__reference = ref)
+ DBI::dbWriteTable(con, "cmp_eq_nan", cmp, overwrite = TRUE)
+ out <- add_bool_cols_sql(
+ dplyr::tbl(con, "cmp_eq_nan"),
+ tol_cols = character(0), eq_cols = "x",
+ col_rules = list(), ref_suffix = "__reference", na_equal = na_equal,
+ eq_num_cols = "x"
+ )
+ res <- dplyr::collect(dplyr::arrange(out, id))
+ as.logical(res$x__eq)
+ }
+
+ cand <- c(NaN, NaN, NaN, 1)
+ ref <- c(NaN, 1, NA, 1)
+
+ # Local-path oracle: is.na(NaN) is TRUE, so NaN follows the NA rules
+ # (one-sided = difference, two-sided follows na_equal)
+ expect_identical(run_eq(cand, ref, na_equal = TRUE),
+ c(TRUE, FALSE, TRUE, TRUE))
+ expect_identical(run_eq(cand, ref, na_equal = FALSE),
+ c(FALSE, FALSE, FALSE, TRUE))
+})
+
+test_that("local and DuckDB verdicts agree end to end on NaN/Inf data", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+
+ ref <- data.frame(id = 1:4, x = c(NaN, Inf, Inf, NaN))
+ cand <- data.frame(id = 1:4, x = c(0, 1, Inf, NaN))
+
+ res_local <- suppressMessages(
+ compare_datasets_from_yaml(ref, cand, key = "id")
+ )
+
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+ duckdb::dbWriteTable(con, "ref_e2e", ref)
+ duckdb::dbWriteTable(con, "cand_e2e", cand)
+ res_lazy <- suppressMessages(
+ compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_e2e"), dplyr::tbl(con, "cand_e2e"),
+ key = "id"
+ )
+ )
+
+ expect_identical(res_local$all_passed, res_lazy$all_passed)
+ # Same per-column failure counts, not just the same global verdict:
+ # rows 1 (NaN vs 0) and 2 (Inf vs 1) fail, row 3 (Inf vs Inf) and
+ # row 4 (NaN vs NaN, na_equal default TRUE) pass
+ tol_row_failures <- function(cov) {
+ as.integer(cov$n_failed[cov$column == "x" & cov$check == "tolerance"])
+ }
+ expect_identical(tol_row_failures(res_local$coverage), 2L)
+ expect_identical(tol_row_failures(res_lazy$coverage), 2L)
+})
diff --git a/tests/testthat/test-pointblank-setup.R b/tests/testthat/test-pointblank-setup.R
index 3b28d8b..ec7611b 100644
--- a/tests/testthat/test-pointblank-setup.R
+++ b/tests/testthat/test-pointblank-setup.R
@@ -12,14 +12,14 @@ test_that("setup_pointblank_agent creates valid agent", {
text__reference = c("a", "b", "c")
)
- cols_reference <- c("id", "value", "text")
common_cols <- c("id", "value", "text")
tol_cols <- c("value")
rules <- list()
ref_suffix <- "__reference"
# Test agent creation
- agent <- setup_pointblank_agent(cmp, cols_reference, common_cols, tol_cols, rules, ref_suffix,
+ agent <- setup_pointblank_agent(cmp, common_cols = common_cols, tol_cols = tol_cols,
+ row_validation_info = rules, ref_suffix = ref_suffix,
warn_at = 0.1, stop_at = 0.1, label = "Test", na_equal = TRUE)
# Check that agent was created
@@ -40,13 +40,13 @@ test_that("setup_pointblank_agent handles different column types", {
text__reference = c("a", "b")
)
- cols_reference <- c("id", "text")
common_cols <- c("id", "text")
tol_cols <- character(0) # No tolerance columns
rules <- list()
ref_suffix <- "__reference"
- agent <- setup_pointblank_agent(cmp, cols_reference, common_cols, tol_cols, rules, ref_suffix,
+ agent <- setup_pointblank_agent(cmp, common_cols = common_cols, tol_cols = tol_cols,
+ row_validation_info = rules, ref_suffix = ref_suffix,
warn_at = 0.5, stop_at = 0.5, label = "Test Exact", na_equal = FALSE)
expect_s3_class(agent, "ptblank_agent")
@@ -57,7 +57,8 @@ test_that("setup_pointblank_agent handles empty inputs", {
# Test with minimal data
cmp <- data.frame(id = 1, id__reference = 1)
- agent <- setup_pointblank_agent(cmp, c("id"), c("id"), character(0), list(), "__reference",
+ agent <- setup_pointblank_agent(cmp, common_cols = c("id"), tol_cols = character(0),
+ row_validation_info = list(), ref_suffix = "__reference",
warn_at = 1.0, stop_at = 1.0, label = "Minimal", na_equal = TRUE)
expect_s3_class(agent, "ptblank_agent")
@@ -67,7 +68,8 @@ test_that("setup_pointblank_agent handles empty inputs", {
test_that("setup_pointblank_agent uses French language by default", {
cmp <- data.frame(id = 1, id__reference = 1)
- agent <- setup_pointblank_agent(cmp, c("id"), c("id"), character(0), list(), "__reference",
+ agent <- setup_pointblank_agent(cmp, common_cols = c("id"), tol_cols = character(0),
+ row_validation_info = list(), ref_suffix = "__reference",
warn_at = 0.1, stop_at = 0.1, label = "Test", na_equal = TRUE)
expect_s3_class(agent, "ptblank_agent")
@@ -79,7 +81,8 @@ test_that("setup_pointblank_agent accepts custom language parameters", {
cmp <- data.frame(id = 1, id__reference = 1)
# Test English
- agent_en <- setup_pointblank_agent(cmp, c("id"), c("id"), character(0), list(), "__reference",
+ agent_en <- setup_pointblank_agent(cmp, common_cols = c("id"), tol_cols = character(0),
+ row_validation_info = list(), ref_suffix = "__reference",
warn_at = 0.1, stop_at = 0.1, label = "Test EN",
na_equal = TRUE, lang = "en", locale = "en_US")
@@ -88,7 +91,8 @@ test_that("setup_pointblank_agent accepts custom language parameters", {
expect_equal(agent_en$locale, "en_US")
# Test German
- agent_de <- setup_pointblank_agent(cmp, c("id"), c("id"), character(0), list(), "__reference",
+ agent_de <- setup_pointblank_agent(cmp, common_cols = c("id"), tol_cols = character(0),
+ row_validation_info = list(), ref_suffix = "__reference",
warn_at = 0.1, stop_at = 0.1, label = "Test DE",
na_equal = TRUE, lang = "de", locale = "de_DE")
@@ -101,21 +105,24 @@ test_that("setup_pointblank_agent accepts various supported languages", {
cmp <- data.frame(id = 1, id__reference = 1)
# Test Spanish
- agent_es <- setup_pointblank_agent(cmp, c("id"), c("id"), character(0), list(), "__reference",
+ agent_es <- setup_pointblank_agent(cmp, common_cols = c("id"), tol_cols = character(0),
+ row_validation_info = list(), ref_suffix = "__reference",
warn_at = 0.1, stop_at = 0.1, label = "Test ES",
na_equal = TRUE, lang = "es", locale = "es_ES")
expect_equal(agent_es$lang, "es")
expect_equal(agent_es$locale, "es_ES")
# Test Portuguese
- agent_pt <- setup_pointblank_agent(cmp, c("id"), c("id"), character(0), list(), "__reference",
+ agent_pt <- setup_pointblank_agent(cmp, common_cols = c("id"), tol_cols = character(0),
+ row_validation_info = list(), ref_suffix = "__reference",
warn_at = 0.1, stop_at = 0.1, label = "Test PT",
na_equal = TRUE, lang = "pt", locale = "pt_BR")
expect_equal(agent_pt$lang, "pt")
expect_equal(agent_pt$locale, "pt_BR")
# Test Italian
- agent_it <- setup_pointblank_agent(cmp, c("id"), c("id"), character(0), list(), "__reference",
+ agent_it <- setup_pointblank_agent(cmp, common_cols = c("id"), tol_cols = character(0),
+ row_validation_info = list(), ref_suffix = "__reference",
warn_at = 0.1, stop_at = 0.1, label = "Test IT",
na_equal = TRUE, lang = "it", locale = "it_IT")
expect_equal(agent_it$lang, "it")
@@ -135,7 +142,6 @@ test_that("setup_pointblank_agent handles lazy table with missing_in_candidate a
# Passing a lazy table exercises the dplyr::mutate() path (lines 52 and 63)
agent <- setup_pointblank_agent(
lazy_tbl,
- cols_reference = character(0),
common_cols = character(0),
tol_cols = c("val"),
row_validation_info = list(check_count = FALSE),
@@ -150,3 +156,46 @@ test_that("setup_pointblank_agent handles lazy table with missing_in_candidate a
expect_s3_class(agent, "ptblank_agent")
})
+
+test_that("cols_reference is deprecated and unused", {
+ cmp <- data.frame(a = 1:3, a__reference = 1:3)
+ row_info <- list(check_count = FALSE)
+ expect_warning(
+ setup_pointblank_agent(cmp, cols_reference = c("a"), common_cols = "a",
+ tol_cols = character(0), row_validation_info = row_info,
+ ref_suffix = "__reference", warn_at = 0.1, stop_at = 0.1,
+ label = "x", na_equal = TRUE),
+ regexp = "deprecated"
+ )
+ # Omitting it works, produces the same agent shape and stays silent
+ expect_no_warning(
+ agent <- setup_pointblank_agent(cmp, common_cols = "a",
+ tol_cols = character(0), row_validation_info = row_info,
+ ref_suffix = "__reference", warn_at = 0.1, stop_at = 0.1,
+ label = "x", na_equal = TRUE)
+ )
+ expect_s3_class(agent, "ptblank_agent")
+})
+
+test_that("direct equality steps use the shared NA semantics via a derived boolean", {
+ # One-sided NA with na_equal = TRUE: must FAIL (kernel/lazy semantics),
+ # not pass through col_vals_equal's na_pass
+ cmp <- data.frame(
+ s = c("a", NA, "c"),
+ s__reference = c("a", "b", "c"),
+ stringsAsFactors = FALSE
+ )
+ row_info <- list(check_count = FALSE)
+ agent <- setup_pointblank_agent(cmp, common_cols = "s",
+ tol_cols = character(0), row_validation_info = row_info,
+ ref_suffix = "__reference", warn_at = 0.1, stop_at = 0.1,
+ label = "na semantics", na_equal = TRUE)
+ reponse <- pointblank::interrogate(agent)
+ expect_false(pointblank::all_passed(reponse))
+
+ # The step no longer embeds the reference VECTOR in the agent: the equality
+ # is validated through a derived __eq boolean column
+ vs <- reponse$validation_set
+ step_cols <- vapply(vs$column, function(cc) cc[1], character(1))
+ expect_true("s__eq" %in% step_cols)
+})
diff --git a/tests/testthat/test-report-extracts-dir.R b/tests/testthat/test-report-extracts-dir.R
new file mode 100644
index 0000000..3dfe31c
--- /dev/null
+++ b/tests/testthat/test-report-extracts-dir.R
@@ -0,0 +1,72 @@
+# datadiff_report_html(extracts_dir =): the failing-row extracts are also
+# written as plain CSV files. The HTML report's CSV buttons are data-URI
+# downloads, which some viewers (Positron / Posit Workbench webview) block:
+# real files on disk are the robust alternative.
+
+test_that("extracts_dir writes one CSV per failing step", {
+ ref <- data.frame(id = 1:3, x = c(1, 2, 3), s = c("a", "b", "c"))
+ cand <- data.frame(id = 1:3, x = c(1, 2, 9), s = c("a", "ZZ", "c"))
+ res <- suppressMessages(compare_datasets_from_yaml(ref, cand, key = "id"))
+ expect_false(res$all_passed)
+
+ out_dir <- tempfile(pattern = "datadiff_extracts_")
+ on.exit(unlink(out_dir, recursive = TRUE), add = TRUE)
+ datadiff_report_html(res, file = NULL, extracts_dir = out_dir)
+
+ csvs <- list.files(out_dir, pattern = "\\.csv$", full.names = TRUE)
+ expect_length(csvs, 2L) # x (tolerance) + s (equality)
+ expect_true(any(grepl("_x\\.csv$", csvs)))
+ expect_true(any(grepl("_s\\.csv$", csvs)))
+
+ # Content: the x extract carries the failing row (id 3)
+ x_csv <- utils::read.csv(csvs[grepl("_x\\.csv$", csvs)])
+ expect_true(3 %in% x_csv$id)
+})
+
+test_that("extracts_dir is not created on an all-pass comparison", {
+ ref <- data.frame(id = 1:3, x = c(1, 2, 3))
+ res <- suppressMessages(compare_datasets_from_yaml(ref, ref, key = "id"))
+ expect_true(res$all_passed)
+
+ out_dir <- tempfile(pattern = "datadiff_extracts_")
+ on.exit(unlink(out_dir, recursive = TRUE), add = TRUE)
+ datadiff_report_html(res, file = NULL, extracts_dir = out_dir)
+ expect_false(dir.exists(out_dir))
+})
+
+test_that("column names are sanitized in extract file names", {
+ ref <- data.frame(id = 1:2, x = c(1, 2))
+ names(ref)[2] <- "montant (EUR)/annee"
+ cand <- ref
+ cand[[2]][2] <- 99
+ res <- suppressWarnings(suppressMessages(
+ compare_datasets_from_yaml(ref, cand, key = "id")
+ ))
+ expect_false(res$all_passed)
+
+ out_dir <- tempfile(pattern = "datadiff_extracts_")
+ on.exit(unlink(out_dir, recursive = TRUE), add = TRUE)
+ datadiff_report_html(res, file = NULL, extracts_dir = out_dir)
+
+ csvs <- list.files(out_dir, pattern = "\\.csv$")
+ expect_length(csvs, 1L)
+ expect_false(grepl("[ ()/]", csvs))
+})
+
+test_that("an unreadable response warns explicitly and writes nothing", {
+ res_broken <- list(
+ coverage = structure(
+ data.frame(column = "x", check = "tolerance", n = 1L, n_failed = 1L,
+ status = "FAIL", stringsAsFactors = FALSE),
+ class = c("datadiff_coverage", "data.frame")
+ ),
+ response = list()
+ )
+ out_dir <- tempfile(pattern = "datadiff_extracts_")
+ on.exit(unlink(out_dir, recursive = TRUE), add = TRUE)
+ expect_warning(
+ datadiff_report_html(res_broken, file = NULL, extracts_dir = out_dir),
+ regexp = "could not read the data extracts"
+ )
+ expect_false(dir.exists(out_dir))
+})
diff --git a/tests/testthat/test-report-robustness.R b/tests/testthat/test-report-robustness.R
new file mode 100644
index 0000000..123bfa7
--- /dev/null
+++ b/tests/testthat/test-report-robustness.R
@@ -0,0 +1,71 @@
+# Report construction robustness: the HTML export shares the print()
+# memoization, and a genuine evaluation error on the real agent must reach the
+# report instead of being silently replaced by synthetic passing rows.
+
+test_that("datadiff_report_html reads and feeds the print() memoization", {
+ ref <- data.frame(id = 1:2, x = c(1, 2))
+ cand <- data.frame(id = 1:2, x = c(1, 3))
+ res <- suppressMessages(compare_datasets_from_yaml(ref, cand, key = "id"))
+
+ cache <- attr(res$response, "datadiff_render")
+ expect_true(is.environment(cache))
+ expect_null(cache$report)
+
+ # Rendering through the HTML entry point must populate the shared cache...
+ r1 <- datadiff_report_html(res, file = NULL)
+ expect_false(is.null(cache$report))
+
+ # ...and a second render must return the memoized object itself
+ r2 <- datadiff_report_html(res, file = NULL)
+ expect_identical(r2, cache$report)
+})
+
+test_that("an eval_error on the real agent reaches the report", {
+ ref <- data.frame(id = 1:2, x = c(1, 2))
+ cand <- data.frame(id = 1:2, x = c(1, 3))
+ res <- suppressMessages(compare_datasets_from_yaml(ref, cand, key = "id"))
+
+ # Simulate a step whose interrogation failed to evaluate: pointblank stores
+ # eval_error = TRUE and n_failed = NA for such steps. Target the x__ok value
+ # step (the one the report maps back to the "x" coverage row).
+ vs <- res$response$validation_set
+ err_idx <- which(vapply(vs$column, function(cc) {
+ identical(cc[1], "x__ok")
+ }, logical(1)))[1]
+ vs$eval_error[err_idx] <- TRUE
+ vs$n_failed[err_idx] <- NA_real_
+ res$response$validation_set <- vs
+
+ agent <- build_report_agent(
+ coverage = res$coverage,
+ label = "eval error propagation",
+ real_agent = res$response
+ )
+
+ # The synthetic branch must not swallow the failure: the rebuilt validation
+ # set still carries the eval_error of the mapped real step
+ expect_true(any(agent$validation_set$eval_error, na.rm = TRUE))
+})
+
+test_that("an all-NA n_failed agent (pure eval_error) is not treated as all-pass", {
+ ref <- data.frame(id = 1:2, x = c(1, 2))
+ cand <- data.frame(id = 1:2, x = c(1, 3))
+ res <- suppressMessages(compare_datasets_from_yaml(ref, cand, key = "id"))
+
+ # Every real step errored: n_failed is NA everywhere, eval_error TRUE
+ vs <- res$response$validation_set
+ vs$eval_error <- rep(TRUE, nrow(vs))
+ vs$n_failed <- rep(NA_real_, nrow(vs))
+ res$response$validation_set <- vs
+
+ agent <- build_report_agent(
+ coverage = res$coverage,
+ label = "pure eval error",
+ real_agent = res$response
+ )
+
+ # Pre-fix, the any(n_failed > 0, na.rm = TRUE) guard was FALSE and the
+ # synthetic count-only branch silently dropped the real agent (and its
+ # extracts); the eval_error must survive instead
+ expect_true(any(agent$validation_set$eval_error, na.rm = TRUE))
+})
diff --git a/tests/testthat/test-report.R b/tests/testthat/test-report.R
index 9b34e99..1f38e2d 100644
--- a/tests/testthat/test-report.R
+++ b/tests/testthat/test-report.R
@@ -1,8 +1,8 @@
# TDD spec for the lazy pointblank-style HTML report.
#
-# The fast path keeps res$reponse a real interrogated agent (so all_passed() and
+# The fast path keeps res$response a real interrogated agent (so all_passed() and
# get_data_extracts() keep working), but prefixes class "datadiff_report" and
-# attaches the coverage so that PRINTING res$reponse lazily builds a full
+# attaches the coverage so that PRINTING res$response lazily builds a full
# pointblank report (one step per column) on demand - the cost is paid only when
# the report is displayed, never on the compute path.
@@ -86,7 +86,7 @@ test_that("failing report merges the real extract and lists every column", {
expect_false(res$all_passed)
ag <- build_report_agent(res$coverage, label = "L", lang = "en",
- locale = "en_US", real_agent = res$reponse)
+ locale = "en_US", real_agent = res$response)
vs <- ag$validation_set
# every coverage column is represented (full "X tests" overview)
@@ -116,7 +116,7 @@ test_that("col_exists rows stay PASS even when the column's value check fails",
res <- run(ref, cand)
expect_false(res$all_passed)
ag <- build_report_agent(res$coverage, label = "L", lang = "en",
- locale = "en_US", real_agent = res$reponse)
+ locale = "en_US", real_agent = res$response)
vs <- ag$validation_set
is_a <- vapply(vs$column, identical, logical(1), "a")
@@ -142,7 +142,7 @@ test_that("report counts match coverage for structural checks (augment path)", {
expect_false(res$all_passed)
ag <- build_report_agent(res$coverage, label = "L", lang = "en",
- locale = "en_US", real_agent = res$reponse)
+ locale = "en_US", real_agent = res$response)
vs <- ag$validation_set
# the missing_column row must report coverage's counts (1/1), not nrow
@@ -185,7 +185,7 @@ test_that("report presents col_exists AND col_vals_equal as distinct checks", {
res <- run(mk_ref(), mk_ref()) # all green
expect_true(res$all_passed)
ag <- build_report_agent(res$coverage, label = "L", lang = "en",
- locale = "en_US", real_agent = res$reponse)
+ locale = "en_US", real_agent = res$response)
types <- ag$validation_set$assertion_type
# both kinds of check must appear, not collapsed into one
expect_true("col_exists" %in% types)
@@ -326,47 +326,47 @@ test_that("end-to-end: all-pass comparison HTML report shows row counts (both pa
expect_false(has_banner(res_lazy))
})
-# --- API compatibility: res$reponse stays a usable agent --------------------
+# --- API compatibility: res$response stays a usable agent --------------------
-test_that("green res$reponse is a datadiff_report that is still a usable agent", {
+test_that("green res$response is a datadiff_report that is still a usable agent", {
res <- run(mk_ref(), mk_ref())
expect_true(res$all_passed)
- expect_identical(class(res$reponse)[1], "datadiff_report")
- expect_true(inherits(res$reponse, "ptblank_agent"))
- expect_true(inherits(res$reponse, "has_intel"))
- expect_true(pointblank::all_passed(res$reponse))
- expect_length(pointblank::get_data_extracts(res$reponse), 0L)
+ expect_identical(class(res$response)[1], "datadiff_report")
+ expect_true(inherits(res$response, "ptblank_agent"))
+ expect_true(inherits(res$response, "has_intel"))
+ expect_true(pointblank::all_passed(res$response))
+ expect_length(pointblank::get_data_extracts(res$response), 0L)
})
-test_that("red res$reponse keeps failing cells extractable", {
+test_that("red res$response keeps failing cells extractable", {
ref <- mk_ref(); cand <- ref
cand$a[2] <- 99
res <- run(ref, cand)
expect_false(res$all_passed)
- expect_identical(class(res$reponse)[1], "datadiff_report")
- expect_true(inherits(res$reponse, "ptblank_agent"))
+ expect_identical(class(res$response)[1], "datadiff_report")
+ expect_true(inherits(res$response, "ptblank_agent"))
expect_equal(failing_cells(res, key = KEYR), "a@2|1")
})
# --- print renders, in green and red ----------------------------------------
-test_that("printing res$reponse renders without error (green and red)", {
+test_that("printing res$response renders without error (green and red)", {
res_g <- run(mk_ref(), mk_ref())
- expect_output(print(res_g$reponse))
+ expect_output(print(res_g$response))
ref <- mk_ref(); cand <- ref; cand$txt[3] <- "Z"
res_r <- run(ref, cand)
- expect_output(print(res_r$reponse))
+ expect_output(print(res_r$response))
})
# --- memoization: second render reuses the cache ----------------------------
test_that("the rendered report is memoized after first print", {
res <- run(mk_ref(), mk_ref())
- cache <- attr(res$reponse, "datadiff_render")
+ cache <- attr(res$response, "datadiff_render")
expect_true(is.environment(cache))
expect_null(cache$report)
- invisible(capture.output(print(res$reponse)))
+ invisible(capture.output(print(res$response)))
expect_false(is.null(cache$report)) # populated by the first print
})
@@ -399,6 +399,6 @@ test_that("lazy comparison also yields a printable datadiff_report", {
dplyr::tbl(con, "ref"), dplyr::tbl(con, "cand"), key = KEYR, path = tmp
))
expect_true(res$all_passed)
- expect_identical(class(res$reponse)[1], "datadiff_report")
- expect_output(print(res$reponse))
+ expect_identical(class(res$response)[1], "datadiff_report")
+ expect_output(print(res$response))
})
diff --git a/tests/testthat/test-response-field.R b/tests/testthat/test-response-field.R
new file mode 100644
index 0000000..b1d9f5b
--- /dev/null
+++ b/tests/testthat/test-response-field.R
@@ -0,0 +1,76 @@
+# The comparison result exposes the interrogated agent under `response`.
+# The historical `reponse` name stays readable for one release through the
+# `datadiff_result` accessors, with a deprecation warning, so existing
+# pipelines keep working while they migrate.
+
+make_pair <- function() {
+ ref <- data.frame(id = 1:3, value = c(1.0, 2.0, 3.0))
+ list(ref = ref, cand = ref)
+}
+
+test_that("the result carries the interrogated agent under `response`", {
+ d <- make_pair()
+ res <- suppressMessages(
+ compare_datasets_from_yaml(d$ref, d$cand, key = "id")
+ )
+ expect_s3_class(res, "datadiff_result")
+ expect_true("response" %in% names(res))
+ expect_false("reponse" %in% names(res))
+ expect_s3_class(res$response, "ptblank_agent")
+ expect_s3_class(res$response, "datadiff_report")
+ expect_true(pointblank::all_passed(res$response))
+})
+
+test_that("`$reponse` still resolves, with a deprecation warning", {
+ rlang::local_options(rlib_warning_verbosity = "verbose")
+ d <- make_pair()
+ res <- suppressMessages(
+ compare_datasets_from_yaml(d$ref, d$cand, key = "id")
+ )
+ expect_warning(via_dollar <- res$reponse, "reponse")
+ expect_identical(via_dollar, res$response)
+})
+
+test_that("`[[\"reponse\"]]` still resolves, with a deprecation warning", {
+ rlang::local_options(rlib_warning_verbosity = "verbose")
+ d <- make_pair()
+ res <- suppressMessages(
+ compare_datasets_from_yaml(d$ref, d$cand, key = "id")
+ )
+ expect_warning(via_brackets <- res[["reponse"]], "reponse")
+ expect_identical(via_brackets, res$response)
+})
+
+test_that("the other result fields keep plain list access semantics", {
+ d <- make_pair()
+ res <- suppressMessages(
+ compare_datasets_from_yaml(d$ref, d$cand, key = "id")
+ )
+ expect_identical(res$all_passed, res[["all_passed"]])
+ expect_true(res$all_passed)
+ expect_null(res$does_not_exist)
+ expect_identical(res[[1L]], res$all_passed)
+ expect_s3_class(res$coverage, "data.frame")
+})
+
+test_that("printing the result does not leak the class attribute", {
+ d <- make_pair()
+ res <- suppressMessages(
+ compare_datasets_from_yaml(d$ref, d$cand, key = "id")
+ )
+ printed <- capture.output(print(res))
+ expect_false(any(grepl("datadiff_result", printed, fixed = TRUE)))
+})
+
+test_that("datadiff_report_html() accepts a result saved by an older version", {
+ d <- make_pair()
+ res <- suppressMessages(
+ compare_datasets_from_yaml(d$ref, d$cand, key = "id")
+ )
+ legacy <- list(
+ all_passed = res$all_passed,
+ coverage = res$coverage,
+ reponse = res$response
+ )
+ expect_no_error(report <- datadiff_report_html(legacy))
+})
diff --git a/tests/testthat/test-temp-tables.R b/tests/testthat/test-temp-tables.R
new file mode 100644
index 0000000..d036b24
--- /dev/null
+++ b/tests/testthat/test-temp-tables.R
@@ -0,0 +1,58 @@
+# Lazy path temp-table hygiene: the slim boolean table computed on
+# the user's connection must not outlive the call, and its name must be unique
+# per process (no clock-derived names).
+
+test_that("no temp tables leak on a user-supplied connection", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ duckdb::dbWriteTable(con, "ref_leak", data.frame(id = 1:3, x = c(1.0, 2.0, 3.0)))
+ duckdb::dbWriteTable(con, "cand_leak", data.frame(id = 1:3, x = c(1.0, 2.0, 3.0)))
+
+ for (i in 1:3) {
+ res <- compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_leak"), dplyr::tbl(con, "cand_leak"),
+ key = "id"
+ )
+ expect_true(res$all_passed)
+ }
+
+ leftover <- DBI::dbGetQuery(con, paste0(
+ "SELECT table_name FROM information_schema.tables ",
+ "WHERE table_name LIKE 'datadiff%'"
+ ))
+ expect_equal(nrow(leftover), 0L)
+})
+
+test_that("temp tables are cleaned up even when the comparison fails", {
+ skip_if_not_installed("duckdb")
+ skip_if_not_installed("dbplyr")
+ con <- DBI::dbConnect(duckdb::duckdb())
+ on.exit(DBI::dbDisconnect(con, shutdown = TRUE), add = TRUE)
+
+ duckdb::dbWriteTable(con, "ref_red", data.frame(id = 1:3, x = c(1.0, 2.0, 3.0)))
+ duckdb::dbWriteTable(con, "cand_red", data.frame(id = 1:3, x = c(9.0, 9.0, 9.0)))
+
+ res <- compare_datasets_from_yaml(
+ dplyr::tbl(con, "ref_red"), dplyr::tbl(con, "cand_red"),
+ key = "id"
+ )
+ expect_false(res$all_passed)
+
+ leftover <- DBI::dbGetQuery(con, paste0(
+ "SELECT table_name FROM information_schema.tables ",
+ "WHERE table_name LIKE 'datadiff%'"
+ ))
+ expect_equal(nrow(leftover), 0L)
+})
+
+test_that("temp table names are unique within a process", {
+ n1 <- datadiff_tmp_table_name()
+ n2 <- datadiff_tmp_table_name()
+ expect_false(identical(n1, n2))
+ expect_match(n1, "^datadiff_tmp_")
+ # The name embeds the PID so two R processes sharing a database cannot collide
+ expect_match(n1, as.character(Sys.getpid()), fixed = TRUE)
+})
diff --git a/tests/testthat/test-tolerance-ok.R b/tests/testthat/test-tolerance-ok.R
index b3f9677..788a2fd 100644
--- a/tests/testthat/test-tolerance-ok.R
+++ b/tests/testthat/test-tolerance-ok.R
@@ -30,6 +30,42 @@ test_that("special values fall back and still match the full kernel", {
expect_ok_equiv(Inf, Inf, 0.1, 0, FALSE)
})
+test_that("intermediate path (NA/NaN, no infinity) is bit-identical to the kernel", {
+ set.seed(7)
+ n <- 500
+ ref <- rnorm(n)
+ cand <- ref + 1e-12
+ cand[sample(n, 25)] <- NA_real_
+ ref[sample(n, 25)] <- NA_real_ # one-sided NAs plus a few two-sided ones
+ ref[1] <- NA_real_; cand[1] <- NA_real_ # guarantee at least one two-sided NA
+
+ for (na_equal in c(TRUE, FALSE)) {
+ expect_identical(
+ compute_tolerance_ok(cand, ref, 1e-9, 0, na_equal),
+ compute_tolerance_col(cand, ref, 1e-9, 0, na_equal)$ok
+ )
+ expect_identical(
+ compute_tolerance_ok(cand, ref, 0, 0.01, na_equal),
+ compute_tolerance_col(cand, ref, 0, 0.01, na_equal)$ok
+ )
+ }
+
+ # NaN stays on the intermediate path (is.na covers it); an infinity
+ # reroutes to the full kernel. Both must match the kernel bit for bit.
+ cand_nan <- cand; cand_nan[2] <- NaN
+ cand_inf <- cand; cand_inf[3] <- Inf
+ for (na_equal in c(TRUE, FALSE)) {
+ expect_identical(
+ compute_tolerance_ok(cand_nan, ref, 1e-9, 0, na_equal),
+ compute_tolerance_col(cand_nan, ref, 1e-9, 0, na_equal)$ok
+ )
+ expect_identical(
+ compute_tolerance_ok(cand_inf, ref, 1e-9, 0, na_equal),
+ compute_tolerance_col(cand_inf, ref, 1e-9, 0, na_equal)$ok
+ )
+ }
+})
+
test_that("randomised property check: ok-only equals full kernel", {
set.seed(42)
pool <- c(rnorm(40), NA, NaN, Inf, -Inf)
diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R
index b56b33a..e52e62f 100644
--- a/tests/testthat/test-utils.R
+++ b/tests/testthat/test-utils.R
@@ -5,8 +5,9 @@ test_that("%||% operator works correctly", {
expect_equal(object = NULL %||% NULL, expected = NULL)
})
-test_that("%||% is exported from the package namespace", {
- expect_true("%||%" %in% getNamespaceExports("datadiff"))
+test_that("%||% is internal (not exported), per NEWS 0.4.4", {
+ # Exporting it masks rlang's and base R's (R >= 4.4) own %||% at load time
+ expect_false("%||%" %in% getNamespaceExports("datadiff"))
})
test_that("normalize_text handles basic cases", {
@@ -47,15 +48,42 @@ test_that("preprocess_dataframe applies transformations correctly", {
expect_equal(result$case_col, c("mixed", "case"))
})
-test_that("preprocess_dataframe handles equal_mode normalized", {
+test_that("equal_mode normalized implies case_insensitive and trim by default", {
df <- data.frame(text_col = c(" HELLO ", "WORLD"))
rules <- list(text_col = list(equal_mode = "normalized"))
-
result <- preprocess_dataframe(df, rules)
+ expect_equal(result$text_col, c("hello", "world"))
+
+ # An explicit flag overrides the implied default
+ rules_no_trim <- list(text_col = list(equal_mode = "normalized", trim = FALSE))
+ result_no_trim <- preprocess_dataframe(df, rules_no_trim)
+ expect_equal(result_no_trim$text_col, c(" hello ", "world"))
- # Should not apply any transformation since case_insensitive and trim are not set
- expect_equal(result$text_col, c(" HELLO ", "WORLD"))
+ rules_no_case <- list(text_col = list(equal_mode = "normalized",
+ case_insensitive = FALSE))
+ result_no_case <- preprocess_dataframe(df, rules_no_case)
+ expect_equal(result_no_case$text_col, c("HELLO", "WORLD"))
+})
+
+test_that("equal_mode normalized alone changes the verdict (issue reprex)", {
+ ref <- data.frame(id = 1:2, s = c("A", " b"), stringsAsFactors = FALSE)
+ cand <- data.frame(id = 1:2, s = c("a", "b"), stringsAsFactors = FALSE)
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines('
+version: 1
+defaults: {keys: [id], na_equal: true}
+row_validation: {check_count: false}
+by_type:
+ character: {equal_mode: normalized}
+', con = yaml_path)
+
+ res <- suppressMessages(
+ compare_datasets_from_yaml(ref, cand, key = "id", path = yaml_path)
+ )
+ expect_true(res$all_passed)
})
test_that("preprocess_dataframe handles mixed rules", {
@@ -112,3 +140,32 @@ test_that("preprocess_dataframe applies stringr trim on Arrow objects", {
expect_equal(result$name, c("Alice", "Bob"))
})
+
+test_that("a normalized template does not neutralise its own mode", {
+ ref <- data.frame(id = 1:2, s = c("A", " b"), stringsAsFactors = FALSE)
+ cand <- data.frame(id = 1:2, s = c("a", "b"), stringsAsFactors = FALSE)
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(ref, key = "id", path = yaml_path,
+ character_equal_mode = "normalized")
+
+ rules <- read_rules(yaml_path)
+ # The default FALSE flags must not be co-written next to the mode
+ expect_null(rules$by_type$character$case_insensitive)
+ expect_null(rules$by_type$character$trim)
+
+ res <- suppressMessages(
+ compare_datasets_from_yaml(ref, cand, key = "id", path = yaml_path)
+ )
+ expect_true(res$all_passed)
+
+ # An explicitly supplied flag still wins over the mode
+ yaml_path2 <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path2), add = TRUE)
+ write_rules_template(ref, key = "id", path = yaml_path2,
+ character_equal_mode = "normalized",
+ character_trim = FALSE)
+ rules2 <- read_rules(yaml_path2)
+ expect_false(rules2$by_type$character$trim)
+})
diff --git a/tests/testthat/test-yaml-arg-precedence.R b/tests/testthat/test-yaml-arg-precedence.R
new file mode 100644
index 0000000..669db65
--- /dev/null
+++ b/tests/testthat/test-yaml-arg-precedence.R
@@ -0,0 +1,156 @@
+# Precedence between explicit arguments and YAML rules:
+# explicit argument > YAML defaults > built-in default.
+
+test_that("explicit label argument wins over the YAML label", {
+ ref <- data.frame(id = 1:2, x = c(1.0, 2.0))
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(ref, key = "id", label = "yaml label", path = yaml_path)
+
+ res <- compare_datasets_from_yaml(
+ ref, ref,
+ key = "id", path = yaml_path, label = "explicit label"
+ )
+ expect_identical(res$response$label, "explicit label")
+
+ # Without an explicit argument, the YAML label applies
+ res_yaml <- compare_datasets_from_yaml(ref, ref, key = "id", path = yaml_path)
+ expect_identical(res_yaml$response$label, "yaml label")
+
+ # An empty string means "no explicit label": the YAML label still applies
+ res_empty <- compare_datasets_from_yaml(ref, ref, key = "id", path = yaml_path,
+ label = "")
+ expect_identical(res_empty$response$label, "yaml label")
+})
+
+test_that("YAML 'keys' field is read without $ partial matching", {
+ ref <- data.frame(id = 1:3, value = c(1.0, 2.0, 3.0))
+ # Shuffled candidate: only a comparison joined on the YAML key passes
+ cand_shuffled <- ref[c(3, 1, 2), , drop = FALSE]
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines('
+version: 1
+defaults:
+ keys: [id]
+ na_equal: yes
+row_validation:
+ check_count: no
+by_type:
+ numeric:
+ abs: 0.000000001
+', con = yaml_path)
+
+ # warnPartialMatchDollar makes any $ partial-match resolution audible:
+ # without it, a regressed rules$defaults$key implementation would pass
+ # this test silently (both implementations resolve the key)
+ old <- options(warnPartialMatchDollar = TRUE)
+ on.exit(options(old), add = TRUE)
+
+ # Muffle ONLY partial-match warnings (the tested regression signal);
+ # any other warning stays audible and would surface in the test output
+ partial_warns <- character(0)
+ withCallingHandlers(
+ {
+ res <- compare_datasets_from_yaml(ref, cand_shuffled, path = yaml_path)
+ },
+ warning = function(w) {
+ if (grepl("partial match", conditionMessage(w))) {
+ partial_warns <<- c(partial_warns, conditionMessage(w))
+ invokeRestart("muffleWarning")
+ }
+ }
+ )
+ expect_true(res$all_passed)
+ expect_length(partial_warns, 0L)
+})
+
+test_that("an invalid label argument is rejected with a clear error", {
+ ref <- data.frame(id = 1:2, x = c(1.0, 2.0))
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ write_rules_template(ref, key = "id", path = yaml_path)
+
+ # NA and non-scalar labels must be rejected upfront, not crash later on
+ # an if (label == "") condition
+ expect_error(
+ compare_datasets_from_yaml(ref, ref, key = "id", path = yaml_path,
+ label = NA_character_),
+ regexp = "single character string"
+ )
+ expect_error(
+ compare_datasets_from_yaml(ref, ref, key = "id", label = NA_character_),
+ regexp = "single character string"
+ )
+ expect_error(
+ compare_datasets_from_yaml(ref, ref, key = "id", path = yaml_path,
+ label = c("a", "b")),
+ regexp = "single character string"
+ )
+ expect_error(
+ write_rules_template(ref, key = "id", label = NA_character_,
+ path = tempfile(fileext = ".yaml")),
+ regexp = "single character string"
+ )
+})
+
+test_that("YAML with both 'keys' and legacy 'key' fields: 'keys' wins", {
+ ref <- data.frame(id = 1:3, value = c(1.0, 2.0, 3.0))
+ cand_shuffled <- ref[c(3, 1, 2), , drop = FALSE]
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines('
+version: 1
+defaults:
+ keys: [id]
+ key: [nonexistent]
+row_validation:
+ check_count: no
+', con = yaml_path)
+
+ # If the legacy singular field won, the comparison would error on a
+ # missing key column
+ res <- compare_datasets_from_yaml(ref, cand_shuffled, path = yaml_path)
+ expect_true(res$all_passed)
+})
+
+test_that("legacy singular 'key' YAML field is still honored", {
+ ref <- data.frame(id = 1:3, value = c(1.0, 2.0, 3.0))
+ cand_shuffled <- ref[c(3, 1, 2), , drop = FALSE]
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines('
+version: 1
+defaults:
+ key: [id]
+row_validation:
+ check_count: no
+', con = yaml_path)
+
+ res <- compare_datasets_from_yaml(ref, cand_shuffled, path = yaml_path)
+ expect_true(res$all_passed)
+})
+
+test_that("key argument wins over the YAML keys field", {
+ ref <- data.frame(id = 1:3, other = c(10L, 20L, 30L), value = c(1.0, 2.0, 3.0))
+ cand_shuffled <- ref[c(3, 1, 2), , drop = FALSE]
+
+ yaml_path <- tempfile(fileext = ".yaml")
+ on.exit(unlink(yaml_path), add = TRUE)
+ writeLines('
+version: 1
+defaults:
+ keys: [nonexistent]
+row_validation:
+ check_count: no
+', con = yaml_path)
+
+ # The explicit argument must shadow the (broken) YAML keys entirely
+ res <- compare_datasets_from_yaml(ref, cand_shuffled, key = "id", path = yaml_path)
+ expect_true(res$all_passed)
+})
diff --git a/tests/testthat/test.yaml b/tests/testthat/test.yaml
deleted file mode 100644
index 9fb092c..0000000
--- a/tests/testthat/test.yaml
+++ /dev/null
@@ -1,29 +0,0 @@
-version: 1
-defaults:
- na_equal: yes
- ignore_columns: []
- keys: ~
- label: comparaison df
-row_validation:
- check_count: no
- expected_count: ~
- tolerance: 0.0
-by_type:
- numeric:
- abs: 1.0e-09
- rel: 1.0e-09
- integer:
- abs: 0
- character:
- equal_mode: exact
- case_insensitive: no
- trim: no
- date:
- equal_mode: exact
- datetime:
- equal_mode: exact
- logical:
- equal_mode: exact
-by_name:
- id: []
- value: []
diff --git a/vignettes/datadiff.Rmd b/vignettes/datadiff.Rmd
index 24a8adc..423acdd 100644
--- a/vignettes/datadiff.Rmd
+++ b/vignettes/datadiff.Rmd
@@ -86,11 +86,15 @@ names(result)
| `coverage` | One row per check actually performed (see below) |
| `summary` | Aggregate counts derived from `coverage` |
| `agent` | The configured pointblank agent (before interrogation) |
-| `reponse` | A `datadiff_report`: a real pointblank agent that renders the full report lazily when printed (see below) |
+| `response` | A `datadiff_report`: a real pointblank agent that renders the full report lazily when printed (see below) |
| `missing_in_candidate` | Columns present in reference but absent from candidate |
| `extra_in_candidate` | Columns present in candidate but absent from reference |
| `applied_rules` | The effective per-column rules that were applied |
+The agent used to be exposed under the French name `reponse`; that alias
+still resolves (with a once-per-session deprecation warning) and will be
+removed in a future release: switch to `response`.
+
### Seeing exactly what was checked
`result$coverage` is the first thing to look at: it lists **every check that
@@ -118,14 +122,14 @@ result$coverage[result$coverage$status == "FAIL", ]
### The full pointblank report
-`result$reponse` stays a genuine pointblank agent - `pointblank::all_passed()`
+`result$response` stays a genuine pointblank agent - `pointblank::all_passed()`
and `pointblank::get_data_extracts()` work on it as usual. **Printing** it
renders the full per-column pointblank report (HTML in interactive sessions).
That report is built only when you display it, so a comparison itself stays
fast even on very wide tables:
```{r print-report, eval = FALSE}
-print(result$reponse) # renders the report on demand
+print(result$response) # renders the report on demand
datadiff_report_html(result, file = "report.html") # ...or save it to a file
```
@@ -159,12 +163,16 @@ result_fail <- compare_datasets_from_yaml(ref_fail, cand_fail, key = "id")
result_fail$all_passed
# Rows that failed at least one step
-failed_rows <- pointblank::get_sundered_data(result_fail$reponse, type = "fail")
+failed_rows <- pointblank::get_sundered_data(result_fail$response, type = "fail")
failed_rows
```
The `type = "pass"` variant returns rows that passed all steps. This is
useful to understand the scope of the problem before investigating further.
+Note: on an all-pass LAZY comparison, `response` carries a constant-size
+placeholder rather than the source rows (the verdict is computed by SQL
+aggregates without loading the data into R), so sundering is only
+meaningful on a failing comparison.
For each failing tolerance column, the step extracts returned by
`pointblank::get_data_extracts()` (and the CSV download in the HTML report)
@@ -174,10 +182,20 @@ threshold that was applied. These are computed for the failing columns only,
so wide all-pass comparisons pay no extra cost.
```{r failing-extracts}
-extracts <- pointblank::get_data_extracts(result_fail$reponse)
+extracts <- pointblank::get_data_extracts(result_fail$response)
extracts[[1]]
```
+### Measured deviations in the extracts
+
+On the in-memory (data.frame) path, the extracts of a failing tolerance
+column also carry the measured deviation and the applied threshold
+(`__absdiff`, `__thresh`). The lazy path never had these
+diagnostics: its agent works on a slim boolean table and its extracts only
+show the boolean columns - no key columns and no data values. To inspect the
+failing rows of a lazy comparison with their keys and values, re-run the
+comparison locally on the failing subset, or query the source directly.
+
### Controlling how many failing rows are extracted
For large datasets, extracting all failing rows can consume significant memory.
@@ -240,6 +258,11 @@ defaults:
keys: id # join key (single or composite)
label: ref vs cand # label shown in the pointblank report
+# Note: when a setting exists both as a function argument and in the YAML,
+# the explicit argument wins: key > defaults$keys, label > defaults$label.
+# The canonical YAML field for the join key is "keys" (a legacy singular
+# "key" field is honored as fallback when "keys" is absent).
+
row_validation:
check_count: yes
expected_count: ~ # null = use reference row count
@@ -792,11 +815,42 @@ cmp_annotated <- add_tolerance_columns(cmp, "value", rules_debug,
cmp_annotated[, c("value__absdiff", "value__thresh", "value__ok")]
```
+### `normalize_text()`
+
+The text-normalization primitive used by `preprocess_dataframe()`, handy on
+its own to normalize a vector the same way a comparison would:
+
+```{r normalize-text}
+normalize_text(c(" Hello ", "WORLD"), case_insensitive = TRUE, trim = TRUE)
+```
+
+### `validate_row_counts()`
+
+Computes the row-count validation information (counts, expected count,
+tolerance) for a pair of datasets and a rules object, without running the
+full comparison:
+
+```{r validate-row-counts}
+rules_rc <- list(row_validation = list(check_count = TRUE,
+ expected_count = NULL, tolerance = 0))
+validate_row_counts(ref, cand, rules = rules_rc)
+```
+
+### `setup_pointblank_agent()`
+
+Builds the configured (not yet interrogated) pointblank agent the comparison
+uses, for advanced consumers who want to add their own steps before
+interrogating. The equality steps validate `__eq` booleans and the
+tolerance steps expect precomputed `__ok` booleans (see
+`add_tolerance_columns()`); most users never need to call it directly.
+
---
## 14. Language and locale
-By default, pointblank reports are rendered in English. You can change
+By default, pointblank reports are rendered in French (`lang = "fr"`,
+`locale = "fr_FR"`; override globally with
+`options(datadiff.lang = "en", datadiff.locale = "en_US")`). You can change
the language per call or globally for a session.
### Per call
@@ -842,7 +896,7 @@ number of failing columns, not the total.
This optimisation is invisible to you: `all_passed`, `coverage`, and the
extracted failing cells are exactly what a full per-column run would produce.
The only thing deferred is the **rendered** report, which is built on demand
-when you print `result$reponse` (section 2).
+when you print `result$response` (section 2).
### Lazy tables (dbplyr)
@@ -872,6 +926,9 @@ DBI::dbDisconnect(con)
```
This works with any DBI-compatible backend: SQLite, PostgreSQL, Snowflake, etc.
+Caveat: the NaN-aware semantics of the lazy booleans are wired for DuckDB
+(`isnan()`); a non-DuckDB backend that stores NaN keeps NULL-only rules for
+those values (see the `add_bool_cols_sql()` notes).
### Arrow / Parquet (out-of-core)