--- title: "Data Checks" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Data Checks} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} # Evaluate chunks only where the ducklake DuckDB extension is already # installed. The probe never downloads anything, so building this vignette # needs no network access. jsonlite writes the check results onto a commit. ducklake_available <- ducklake::ducklake_extension_available() && requireNamespace("jsonlite", quietly = TRUE) knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = ducklake_available ) # Use a unique temp directory for this vignette to avoid conflicts during R CMD check vignette_temp_dir <- file.path(tempdir(), "checks_vignette") dir.create(vignette_temp_dir, showWarnings = FALSE, recursive = TRUE) knitr::opts_knit$set(root.dir = vignette_temp_dir) ``` ```{r setup, message=FALSE} library(ducklake) library(dplyr) attach_ducklake("checks_lake", lake_path = vignette_temp_dir) df_cars <- data.frame(model = rownames(mtcars), mtcars, row.names = NULL) with_transaction( create_table(df_cars, "cars"), author = "Data Engineer", commit_message = "Add the Motor Trend car data" ) ``` DuckLake enforces one rule about the values in a table: a column can be `NOT NULL`. It has no check constraints, no primary or unique keys, and no foreign keys, and its catalog accepts no custom tags from SQL, so a rule like "`cyl` is 4, 6, or 8" has no slot of its own. The catalog does hold views. A view that returns the rows breaking a rule is a check: the rule's logic sits in the catalog, it is versioned with the data it describes, and any client of the lake runs it by reading the view. This article writes checks that way with `create_check()` and runs them with `run_checks()`. Then it uses what a lake adds: a load that rolls back when a check fails, the outcome of the checks recorded on a commit, and the rules and the data read together as of an earlier snapshot. It closes with how this sits next to affirm and data-dict. Both functions are experimental while the convention settles. ## The rule the lake enforces `set_column_not_null()` sets the one constraint DuckLake has. From then on the lake itself refuses a row without the value, whichever client writes it. Plain SQL stands in for another client here: ```{r not_null} with_transaction( set_column_not_null("cars", "mpg"), author = "Data Manager", commit_message = "Require mpg" ) try( DBI::dbExecute( get_ducklake_connection(), "INSERT INTO cars (model, cyl) VALUES ('Unknown', 4)" ) ) ``` ## Rules as views Every other rule is a view. The checks get a schema of their own (a schema is the lake's namespace for tables and views), which keeps them apart from the data and tells `run_checks()` where to look. `create_check()` takes a rule the way you would say it, as the condition every row should meet, and stores the view of the rows where it is false, with the rule's label as the view's comment: ```{r write_checks} with_transaction({ get_ducklake_table("main.cars") |> create_check( "cyl_known", cyl %in% c(4, 6, 8), label = "cyl is 4, 6, or 8", listing = c(model, cyl) ) get_ducklake_table("main.cars") |> create_check( "hp_plausible", hp > 0 & hp < 500, label = "hp is between 0 and 500", listing = c(model, hp) ) }, author = "Data Manager", commit_message = "Add the first checks on cars") ``` There is nothing more to a check than that view. The first call above stores what this pipeline would, creating the `checks` schema on the way: ```{r by_hand, eval = FALSE} get_ducklake_table("main.cars") |> filter(!(cyl %in% c(4, 6, 8))) |> select(model, cyl) |> create_view("checks.cyl_known") set_table_comment("checks.cyl_known", "cyl is 4, 6, or 8") ``` Some rules are easier to state as their failure, and those are written just like that. No model should appear twice, and the rows that break the rule are the models counted more than once: ```{r failure_check} with_transaction({ get_ducklake_table("main.cars") |> count(model) |> filter(n > 1) |> create_view("checks.model_unique") set_table_comment("checks.model_unique", "model appears once") }, author = "Data Manager", commit_message = "Check that models are unique") ``` A rule that spans tables is written the same way, as a join: an `anti_join()` of visits against subjects returns the visits with no subject. A few habits keep a check dependable: - State a rule the way its label reads and let `create_check()` find the rows where it is false. A rule that forbids something stays readable (`hp != 0`, where the pipeline by hand would negate a negation), and a compound rule is not negated by hand, where `&` and `|` are easy to swap. When the failure is the natural thing to describe, write it as a pipeline into `create_view()`. The two forms select the same rows, because three-valued logic treats `NOT (hp != 0)` and `hp = 0` alike. - A row where the rule is `NA` passes, as it does under a SQL `CHECK` constraint, so a missing `cyl` passes `cyl_known`. When a missing value matters, say so in the rule, give it a check of its own, or make the column `NOT NULL`. - List the columns a reviewer needs. The view is the listing of what failed, so its columns are the ones someone will act on. - Read the table by its schema-qualified name, `"main.cars"`. DuckDB resolves an unqualified name from the view's schema and the session's current database, so `"cars"` can fail to bind for a client where another database is current. - Keep only checks in the schema. `run_checks()` counts the rows of every view it finds there, and a view that summarizes (a row of totals) would report a failure every time. ## Running the checks `run_checks()` counts the rows each check returns: ```{r run_checks} run_checks() ``` The label comes from the view's comment and `n_fail` is the number of rows breaking the rule. The car data passes all three. ## Gating a load A new batch arrives, and one of its rows has a typing error: ```{r batch} df_batch <- data.frame( model = c("Saab 99", "Audi 100 LS"), mpg = c(24.5, 23.0), cyl = c(4, 40), hp = c(87, 91) ) ``` Inside a transaction the checks see the rows that are waiting to be committed. So a load can run the checks on itself and stop, and `with_transaction()` rolls everything back when it does: ```{r gate} try( with_transaction({ rows_insert(get_ducklake_table("cars"), df_batch, by = "model") df_checks <- run_checks() if (any(df_checks$n_fail > 0)) { stop("failed ", toString(df_checks$check[df_checks$n_fail > 0])) } }, author = "Data Manager", commit_message = "Load the September batch") ) ``` Nothing of the batch reached the lake. The table has its 32 rows and the history has no new snapshot: ```{r gate_result} get_ducklake_table("cars") |> count() |> collect() list_table_snapshots("cars") |> select(snapshot_id, author, commit_message) ``` This is how a rule becomes a constraint in a lake. DuckLake would accept the row; a transaction that checks itself never commits it. The gate covers the writers that use it, and a client that writes without running the checks is not stopped. ## Recording the outcome on the commit Rejecting the batch is not always right. Raw data often has to land as it arrived, with the problems flagged for follow-up. A manual transaction can run the checks after the write and put the outcome on the commit, in `commit_extra_info`: ```{r soft_load} begin_transaction() rows_insert(get_ducklake_table("cars"), df_batch, by = "model") df_failed <- run_checks() |> filter(n_fail > 0) |> select(check, n_fail) commit_transaction( author = "Data Manager", commit_message = "Load the September batch", commit_extra_info = as.character( jsonlite::toJSON(list(checks_failed = df_failed)) ) ) v_loaded <- max(list_table_snapshots("cars")$snapshot_id) ``` The snapshot now says what state the data was in when it was committed: ```{r soft_result} list_table_snapshots("cars") |> select(snapshot_id, commit_message, commit_extra_info) |> tail(1) ``` ## Which rows failed The check is a view, so the failing rows are one read away, with the columns the check selected: ```{r listing} run_checks() get_ducklake_table("checks.cyl_known") |> collect() ``` The correction is an ordinary update, and its commit message can name the check it answers. Afterwards the lake's history holds the load, the finding, and the fix: ```{r fix} with_transaction( rows_update( get_ducklake_table("cars"), data.frame(model = "Audi 100 LS", cyl = 4), by = "model" ), author = "Data Manager", commit_message = "cyl_known: Audi 100 LS cyl corrected from 40 to 4" ) run_checks() ``` ## Rules have history A check is a catalog object, so changing a rule is a snapshot with an author and a message, like a change to the data. Calling `create_check()` again under the same name replaces the rule and its label together: ```{r revise_check} with_transaction( get_ducklake_table("main.cars") |> create_check( "hp_plausible", hp >= 40 & hp <= 400, label = "hp is between 40 and 400", listing = c(model, hp) ), author = "Data Manager", commit_message = "Narrow the plausible hp range" ) list_table_snapshots("checks.hp_plausible") |> select(snapshot_id, author, commit_message) ``` A check written with `create_view()` keeps its label when the view is replaced, so only a change of wording needs `set_table_comment()` again. ## Rules and data as of a snapshot A lake attached at a snapshot shows its views as they were then, along with its tables. Re-attaching at the snapshot of the September load runs the rules of that day against the data of that day. The uncorrected row fails again, and the `hp` rule reads as it did then: ```{r pinned, message=FALSE} detach_ducklake("checks_lake") attach_ducklake( "checks_lake", lake_path = vignette_temp_dir, snapshot_version = v_loaded ) run_checks() ``` That answers a question an audit asks: which rules were in force when this data was committed, and did the data pass them? ## Next to affirm and data-dict Two other tools take a rule in the same form. [affirm](https://pcctc.github.io/affirm/), from PCCTC, runs checks in R on data frames and turns the failures into a report. [data-dict](https://data-dict.tidyverse.org), from the tidyverse team, keeps the dictionary of a dataset and its rules in a YAML file and validates the data against it. The rule from the top of this article, in each: ```{r affirm_form, eval = FALSE} # affirm df_cars |> affirm_true(label = "cyl is 4, 6, or 8", condition = cyl %in% c(4, 6, 8)) ``` ```yaml # data-dict.yaml columns: - name: cyl constraints: - assert: cyl IN (4, 6, 8) description: cyl is 4, 6, or 8 ``` ```{r ducklake_form, eval = FALSE} # ducklake get_ducklake_table("main.cars") |> create_check("cyl_known", cyl %in% c(4, 6, 8), label = "cyl is 4, 6, or 8") ``` All three ask for the rule as it is said and work out the failing rows themselves. A missing value passes in data-dict and in a lake check, the way it does under a SQL `CHECK` constraint. They differ in where the rule lives and in what runs it: | | affirm | data-dict | ducklake checks | |---|---|---|---| | The rule lives | in an R script | in a YAML file beside the data, easy to diff in git | in the lake's catalog, in the same snapshots as the data | | What runs it | R, on a data frame in memory | its own engine, on Parquet files | DuckDB, on the lake's tables, from any client | | What a rule can say | any R expression | an expression about one table's rows, plus declared keys, required columns, allowed values, and ranges | anything a query can return, joins and aggregates included | | What comes back | a gt or Excel report of the failing rows | an HTML report, and the dictionary rendered as a site | counts from `run_checks()`, and the failing rows as a view | The data-dict column describes its 0.0.3 preview of August 2026, and the project is moving quickly. The three answer different questions, so they combine. data-dict describes a whole dataset (types, units, what the values of an enum mean, a glossary) in a file that people and agents can read without a database, and its `draft` command writes most of that file from the data. A lake's checks are narrower and sit closer to the data. They run where the data is, inside the transaction that loads it if need be, and a change to a rule is a snapshot with an author, like a change to the data. For now data-dict reads Parquet files, and a lake table is more than its Parquet data files, since deletes are recorded separately and small inserts live in the catalog, so a table has to be exported before data-dict can validate it. What a lake holds of a dictionary today is the comments and labels of `vignette("views-comments-labels")`. `run_checks()` counts and nothing more, and a report is a job for the tools built for one. The rows collected from a check view are a data frame, ready for gt or for a report from affirm. pointblank interrogates lazy tables, so one of its agents can also run on `get_ducklake_table("main.cars")` inside DuckDB. ```{r cleanup} detach_ducklake("checks_lake") ```