Skip to content

Commit 1a6d32b

Browse files
Merge pull request #58 from ThinkR-open/audit-0.4.10
datadiff 0.6.0 : traitement des 24 issues ouvertes de l'audit
2 parents 6e9ebbd + bb0fe6a commit 1a6d32b

67 files changed

Lines changed: 3892 additions & 921 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.Rbuildignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
^README\.Rmd$
1414
^doc$
1515
^Meta$
16+
^dev$

‎DESCRIPTION‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: datadiff
22
Title: Data Validation Based on 'YAML' Rules
3-
Version: 0.4.9.9000
3+
Version: 0.6.0
44
Authors@R: c(
55
person("Vincent", "Guyader", , "vincent@thinkr.fr", role = c("cre", "aut"),
66
comment = c(ORCID = "0000-0003-0671-9270")),
@@ -18,7 +18,6 @@ Imports:
1818
dplyr (>= 1.0.0),
1919
pointblank (>= 0.12.3),
2020
rlang (>= 0.4.0),
21-
stats,
2221
tidyselect (>= 1.0.0),
2322
utils,
2423
yaml (>= 2.2.0)

‎NAMESPACE‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Generated by roxygen2: do not edit by hand
22

3+
S3method("$",datadiff_result)
4+
S3method("[[",datadiff_result)
35
S3method(print,datadiff_coverage)
46
S3method(print,datadiff_report)
5-
export("%||%")
7+
S3method(print,datadiff_result)
68
export(add_tolerance_columns)
79
export(analyze_columns)
810
export(compare_datasets_from_yaml)
@@ -16,8 +18,6 @@ export(setup_pointblank_agent)
1618
export(validate_row_counts)
1719
export(write_rules_template)
1820
importFrom(dplyr,"%>%")
19-
importFrom(dplyr,across)
20-
importFrom(dplyr,arrange)
2121
importFrom(dplyr,collect)
2222
importFrom(dplyr,left_join)
2323
importFrom(pointblank,action_levels)
@@ -26,7 +26,7 @@ importFrom(pointblank,col_vals_equal)
2626
importFrom(pointblank,create_agent)
2727
importFrom(pointblank,interrogate)
2828
importFrom(rlang,":=")
29-
importFrom(stats,setNames)
29+
importFrom(rlang,.data)
3030
importFrom(tidyselect,all_of)
3131
importFrom(utils,modifyList)
3232
importFrom(yaml,read_yaml)

‎NEWS.md‎

Lines changed: 291 additions & 1 deletion
Large diffs are not rendered by default.

‎R/compare_datasets_from_yaml.R‎

Lines changed: 473 additions & 125 deletions
Large diffs are not rendered by default.

‎R/constants.R‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Internal naming conventions shared by the boolean producers (tolerance.R,
2+
# compare_datasets_from_yaml.R), the verdict consumers (fast_path.R,
3+
# coverage.R) and the report step mapping (report.R, pointblank_setup.R).
4+
# They live in one place because a drift on one side breaks the mapping
5+
# SILENTLY: the report step is simply not found and its extract is lost.
6+
7+
# Suffix of the per-column boolean produced for a tolerance column.
8+
datadiff_suffix_ok <- "__ok"
9+
10+
# Suffix of the per-column boolean produced for an equality column.
11+
datadiff_suffix_eq <- "__eq"
12+
13+
# Prefix of the dummy column carrying a missing-column failure step.
14+
datadiff_prefix_missing_col <- "__missing_col_"
15+
16+
# Prefix of the dummy column carrying a type-mismatch failure step.
17+
datadiff_prefix_type_mismatch <- "__type_mismatch_"
18+
19+
# Column name helpers: the only supported way to build these names.
20+
# Length-guarded: paste0(character(0), suffix) yields the bare suffix
21+
# (recycle0 is FALSE by default), a phantom column name.
22+
datadiff_ok_col <- function(col) {
23+
if (length(col) == 0) {
24+
return(character(0))
25+
}
26+
paste0(col, datadiff_suffix_ok)
27+
}
28+
datadiff_eq_col <- function(col) {
29+
if (length(col) == 0) {
30+
return(character(0))
31+
}
32+
paste0(col, datadiff_suffix_eq)
33+
}
34+
35+
# Default pointblank action levels (fractions of failing rows). Used as the
36+
# internal fallback everywhere a threshold is reconstructed from attributes;
37+
# the public compare_datasets_from_yaml() signature documents the same value.
38+
datadiff_default_warn_at <- 1e-14
39+
datadiff_default_stop_at <- 1e-14

‎R/coverage.R‎

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
# matching col_vals_equal(..., na_pass = FALSE).
1010
tol_col_counts <- function(tbl, col) {
1111
ok <- tol_col_bool(tbl, col = col)
12-
list(n = length(ok), n_failed = sum(is.na(ok) | !ok))
12+
# length - sum(TRUE) counts FALSE and NA in one pass without allocating
13+
# the two intermediate logical vectors of is.na(ok) | !ok
14+
list(n = length(ok), n_failed = length(ok) - sum(ok, na.rm = TRUE))
1315
}
1416

1517
# 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) {
1820
list(n = length(b), n_failed = sum(!b))
1921
}
2022

23+
# (n, n_failed) for every boolean validation column of a LAZY table, computed
24+
# in ONE SQL aggregate scan: SUM(CASE WHEN col THEN 0 ELSE 1 END) counts the
25+
# not-TRUE values (FALSE and NULL alike), matching the local reducers
26+
# (tolerance: is.na | !ok; equality: !b, NA-free by construction). This is
27+
# what lets the lazy verdict avoid collecting N x columns booleans into R.
28+
# Returns a named list keyed by the UNDERLYING column name:
29+
# list(<col> = list(n, n_failed), ...).
30+
lazy_boolean_counts <- function(tbl_lazy, tol_cols, eq_cols) {
31+
con <- dbplyr::remote_con(tbl_lazy)
32+
sub <- dbplyr::sql_render(tbl_lazy)
33+
q <- function(x) as.character(DBI::dbQuoteIdentifier(con, x = x))
34+
35+
bool_cols <- c(datadiff_ok_col(tol_cols), datadiff_eq_col(eq_cols))
36+
underlying <- c(tol_cols, eq_cols)
37+
nf_exprs <- vapply(X = seq_along(bool_cols), FUN = function(i) {
38+
sprintf("SUM(CASE WHEN %s THEN 0 ELSE 1 END) AS %s",
39+
q(bool_cols[i]), q(sprintf("..datadiff_nf_%d", i)))
40+
}, FUN.VALUE = character(1))
41+
42+
sql <- sprintf(
43+
"SELECT COUNT(*) AS %s%s FROM (%s) AS %s",
44+
q("..datadiff_total"),
45+
if (length(nf_exprs) > 0) {
46+
paste0(", ", paste(nf_exprs, collapse = ", "))
47+
} else {
48+
""
49+
},
50+
sub, q("datadiff_counts")
51+
)
52+
row <- DBI::dbGetQuery(con, statement = sql)
53+
54+
total <- as.numeric(row[["..datadiff_total"]])
55+
counts <- vector("list", length(underlying))
56+
names(counts) <- underlying
57+
for (i in seq_along(underlying)) {
58+
nf <- as.numeric(row[[sprintf("..datadiff_nf_%d", i)]])
59+
# SUM over an empty table is NULL: zero rows means zero failures
60+
counts[[i]] <- list(n = total, n_failed = if (is.na(nf)) {
61+
0
62+
} else {
63+
nf
64+
})
65+
}
66+
counts
67+
}
68+
2169
#' Build a faithful coverage summary of the checks performed
2270
#'
2371
#' @param tbl Local data.frame holding the boolean validation columns (and, on
2472
#' the local path, the raw + reference columns for equality recomputation).
73+
#' Ignored when `counts` is supplied.
2574
#' @param tol_cols,eq_cols Character vectors of tolerance / equality columns.
2675
#' @param missing_in_candidate,type_mismatch_cols Structural failures.
2776
#' @param row_validation_info List with `check_count`.
2877
#' @param row_count_ok Logical row-count outcome.
2978
#' @param ref_suffix Suffix identifying reference columns.
3079
#' @param na_equal Logical; NA equality semantics for equality columns.
80+
#' @param counts Optional precomputed per-column counts (named list
81+
#' `col -> list(n, n_failed)` covering every tolerance and equality column),
82+
#' as produced by `lazy_boolean_counts()`: the lazy path aggregates in SQL
83+
#' instead of collecting the booleans.
3184
#' @return A `datadiff_coverage` data.frame with columns `column`, `check`,
3285
#' `n`, `n_failed`, `status` (one row per check performed).
3386
#' @noRd
3487
build_coverage <- function(tbl, tol_cols, eq_cols,
3588
missing_in_candidate, type_mismatch_cols,
3689
row_validation_info, row_count_ok,
37-
ref_suffix, na_equal) {
90+
ref_suffix, na_equal, counts = NULL) {
3891
columns <- character(0)
3992
checks <- character(0)
40-
ns <- integer(0)
41-
n_failed <- integer(0)
93+
ns <- numeric(0)
94+
n_failed <- numeric(0)
4295

4396
add <- function(column, check, n, nf) {
4497
columns <<- c(columns, column)
4598
checks <<- c(checks, check)
46-
ns <<- c(ns, as.integer(n))
47-
n_failed <<- c(n_failed, as.integer(nf))
99+
# numeric, not integer: the lazy counts come from SQL aggregates and can
100+
# exceed 2^31 - 1, where as.integer() would overflow to NA and silently
101+
# corrupt the coverage and the verdict
102+
ns <<- c(ns, as.numeric(n))
103+
n_failed <<- c(n_failed, as.numeric(nf))
48104
}
49105

50106
# Existence checks: every common (non-type-mismatch) column gets a col_exists
51107
# check, distinct from its value check. These pass (the column is present in
52108
# both datasets by construction), mirroring a full per-column pointblank run.
53-
for (c in c(tol_cols, eq_cols)) {
54-
add(c, "col_exists", 1L, 0L)
109+
for (col_nm in c(tol_cols, eq_cols)) {
110+
add(col_nm, "col_exists", 1L, 0L)
55111
}
56-
for (c in tol_cols) {
57-
cnt <- tol_col_counts(tbl, col = c)
58-
add(c, "tolerance", cnt$n, cnt$n_failed)
112+
for (col_nm in tol_cols) {
113+
cnt <- counts[[col_nm]] %||% tol_col_counts(tbl, col = col_nm)
114+
add(col_nm, "tolerance", cnt$n, cnt$n_failed)
59115
}
60-
for (c in eq_cols) {
61-
cnt <- eq_col_counts(tbl, col = c, ref_suffix = ref_suffix, na_equal = na_equal)
62-
add(c, "equality", cnt$n, cnt$n_failed)
116+
for (col_nm in eq_cols) {
117+
cnt <- counts[[col_nm]] %||%
118+
eq_col_counts(tbl, col = col_nm, ref_suffix = ref_suffix, na_equal = na_equal)
119+
add(col_nm, "equality", cnt$n, cnt$n_failed)
63120
}
64-
for (c in missing_in_candidate) {
65-
add(c, "missing_column", 1L, 1L)
121+
for (col_nm in missing_in_candidate) {
122+
add(col_nm, "missing_column", 1L, 1L)
66123
}
67-
for (c in type_mismatch_cols) {
68-
add(c, "type_mismatch", 1L, 1L)
124+
for (col_nm in type_mismatch_cols) {
125+
add(col_nm, "type_mismatch", 1L, 1L)
69126
}
70127
if (isTRUE(row_validation_info$check_count)) {
71128
add("<row_count>", "row_count", 1L, if (isTRUE(row_count_ok)) 0L else 1L)

‎R/data_types.R‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
#' Analyzes each column of a dataframe to determine its data type
44
#' (integer, numeric, date, datetime, logical, or character)
55
#'
6+
#' Any column matching none of the first five types falls in the "character"
7+
#' bucket and receives the character rules. That is intentional for factors
8+
#' (compared as the character values they display, see
9+
#' [preprocess_dataframe()]) and works for types whose `==` behaves like
10+
#' character (e.g. `bit64::integer64`). List-columns are not supported.
11+
#'
612
#' @param data A dataframe or tibble
713
#' @return A named character vector with column names as names and types as values
814
#' @examples

‎R/datadiff-result.R‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Accessors for the result of compare_datasets_from_yaml().
2+
#
3+
# The result is a plain named list of class `datadiff_result`. The class only
4+
# exists to keep the historical `reponse` field name readable while user code
5+
# migrates to `response`: `$` and `[[` redirect the old name to the new one
6+
# with a deprecation warning, and behave like plain list access otherwise.
7+
8+
new_datadiff_result <- function(fields) {
9+
structure(fields, class = "datadiff_result")
10+
}
11+
12+
warn_reponse_deprecated <- function() {
13+
rlang::warn(
14+
message = paste(
15+
"The `reponse` field is deprecated as of {datadiff} 0.6.0:",
16+
"use `response` instead.",
17+
"`reponse` will be removed in a future release."
18+
),
19+
.frequency = "once",
20+
.frequency_id = "datadiff_reponse_deprecated",
21+
class = "datadiff_deprecated_reponse_warning"
22+
)
23+
}
24+
25+
#' @export
26+
#' @noRd
27+
`$.datadiff_result` <- function(x, name) {
28+
if (identical(name, "reponse")) {
29+
warn_reponse_deprecated()
30+
name <- "response"
31+
}
32+
# exact = FALSE replicates the silent partial matching of `$` on plain lists
33+
unclass(x)[[name, exact = FALSE]]
34+
}
35+
36+
#' @export
37+
#' @noRd
38+
`[[.datadiff_result` <- function(x, i, ...) {
39+
if (identical(i, "reponse")) {
40+
warn_reponse_deprecated()
41+
i <- "response"
42+
}
43+
unclass(x)[[i, ...]]
44+
}
45+
46+
#' @export
47+
#' @noRd
48+
print.datadiff_result <- function(x, ...) {
49+
print(unclass(x), ...)
50+
invisible(x)
51+
}

‎R/duplicate_keys.R‎

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,29 +10,57 @@
1010
# tables keep the SQL-native count()/group_by (cheap inside the database).
1111

1212
format_key_examples <- function(uniq_keys, key) {
13-
rows <- unname(apply(uniq_keys, 1, function(r) {
13+
n_groups <- nrow(uniq_keys)
14+
shown <- utils::head(uniq_keys, 3L)
15+
rows <- unname(apply(shown, 1, function(r) {
1416
paste(key, "=", r, collapse = ", ")
1517
}))
16-
if (length(rows) <= 3) {
17-
rows
18-
} else {
19-
c(rows[1:3], "...")
18+
if (n_groups > 3L) {
19+
rows <- c(rows, "...")
2020
}
21+
rows
2122
}
2223

24+
#' @importFrom rlang .data
25+
#' @noRd
2326
find_duplicate_keys <- function(data, key) {
2427
if (is_non_local(data)) {
25-
dups <- data %>%
26-
dplyr::count(dplyr::across(dplyr::all_of(key))) %>%
27-
dplyr::filter(n > 1L) %>%
28+
# Reserved count name: count()'s default "n" collides with a user column
29+
# named "n" (count() then stores its result in "nn", and a key named "n"
30+
# would be filtered on its own values instead of the count). The reserved
31+
# name is extended until it differs from every key column, because
32+
# count(name = <grouping column>) silently REPLACES that grouping column
33+
# with the count.
34+
count_col <- "..datadiff_n"
35+
while (count_col %in% key) {
36+
count_col <- paste0(count_col, "_")
37+
}
38+
duplicated_groups <- data %>%
39+
dplyr::count(dplyr::across(dplyr::all_of(key)), name = count_col) %>%
40+
dplyr::filter(.data[[count_col]] > 1L)
41+
# Aggregate in SQL: only 2 scalars plus at most 3 example groups cross the
42+
# wire, instead of every duplicated group (potentially millions).
43+
agg <- duplicated_groups %>%
44+
dplyr::summarise(
45+
..datadiff_dup_keys = dplyr::n(),
46+
..datadiff_dup_rows = sum(.data[[count_col]], na.rm = TRUE)
47+
) %>%
2848
dplyr::collect()
29-
if (nrow(dups) == 0L) {
49+
n_dup_keys <- as.numeric(agg$..datadiff_dup_keys)
50+
if (n_dup_keys == 0) {
3051
return(NULL)
3152
}
53+
example_groups <- duplicated_groups %>%
54+
utils::head(3L) %>%
55+
dplyr::collect()
56+
examples <- format_key_examples(example_groups[, key, drop = FALSE], key)
57+
if (n_dup_keys > 3) {
58+
examples <- c(examples, "...")
59+
}
3260
return(list(
33-
n_dup_keys = nrow(dups),
34-
n_dup_rows = sum(dups$n),
35-
examples = format_key_examples(dups[, key, drop = FALSE], key)
61+
n_dup_keys = n_dup_keys,
62+
n_dup_rows = as.numeric(agg$..datadiff_dup_rows),
63+
examples = examples
3664
))
3765
}
3866

0 commit comments

Comments
 (0)