Skip to content

feat: Add n_distinct(..., na.rm = TRUE) support for multiple passed columns - #1588

Merged
krlmlr merged 3 commits into
duckdb:mainfrom
lschneiderbauer:n_distinct_na_rm
Oct 3, 2025
Merged

krlmlr merged 3 commits into
duckdb:mainfrom
lschneiderbauer:n_distinct_na_rm

Conversation

@lschneiderbauer

@lschneiderbauer lschneiderbauer commented Sep 28, 2025 •

Copy link
Copy Markdown
Contributor

My previous contribution only supported n_distinct(..., na.rm = TRUE) with for a single column, because duckdb's FILTER clause was not supported in a windowed context. This changed with duckdb v1.2.

This PR adds na.rm = TRUE support for multiple columns by using duckdb's struct and using FILTER to filter for non-null values before counting distinct elements.

I also adjusted the tests to simply compare against the dplyr result of n_distinct() instead of testing against explicit values to make sure the semantics is identical.

@codecov-commenter

codecov-commenter commented Sep 28, 2025 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 53.41%. Comparing base (5c88c6f) to head (88a8d1c).
⚠️ Report is 2631 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #1588       +/-   ##
===========================================
- Coverage   63.61%   53.41%   -10.21%     
===========================================
  Files         123      151       +28     
  Lines        5266    10325     +5059     
===========================================
+ Hits         3350     5515     +2165     
- Misses       1916     4810     +2894     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@krlmlr krlmlr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, great!

expect_equal(translate(n_distinct(x, na.rm = TRUE), window = FALSE), sql(r"{COUNT(DISTINCT x)}"))
expect_equal(translate(n_distinct(x, na.rm = TRUE), window = TRUE), sql(r"{COUNT(DISTINCT x) OVER ()}"))
expect_equal(translate(n_distinct(x, na.rm = TRUE), window = TRUE, vars_group = "y"), sql(r"{COUNT(DISTINCT x) OVER (PARTITION BY y)}"))
expect_equal(translate(n_distinct(x, na.rm = TRUE), window = FALSE), sql(r"{COUNT(DISTINCT row(x)) FILTER (x IS NOT NULL)}"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can keep this SQL unchanged in the very common case of just one column.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed it, so that in the single-colum case we fall back to the unchanged SQL.

Comment thread R/backend-dbplyr__duckdb_connection.R Outdated
if (!identical(na.rm, FALSE)) {
cols <- list(...)
# https://duckdb.org/docs/sql/data_types/struct.html#creating-structs-with-the-row-function
str_struct <- paste0("row(", paste0(list(...), collapse = ", "), ")")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is paste0() the correct idiom here? The other SQL generators use sql_expr() (for simple expressions) or build_sql() (for nonstandard constructs).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't make build_sql() work with column lists (unless I would manually paste those together again). I noticed that build_sql() is superseeded by glue_sql2() (tidyverse/dbplyr#1249) which I now mostly adapted.

There is an exception though:

      str_null_check <-
        sql(paste0(paste0(list(...), " IS NOT NULL"), collapse = " AND "))

I couldn't figure out how to use glue_sql2() or the other helpers to construct this SQL statement without using paste0() explicitely, and I suspect it's not possible.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the heads up. My concern is mainly about ... containing complex expressions, how is that handled?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's currently handled the same way n_distinct() is handled in (base) dbplyr:
translate(n_distinct(inject(something), y, na.rm = TRUE), window = FALSE) in itself will translate anything:

<SQL> COUNT(DISTINCT row(inject(something), y)) FILTER (inject(something) IS NOT NULL AND y IS NOT NULL)

(This is true despite the usage of glue_sql2().)

However, mutate() itself checks if the expressions given map to existing columns, and only then passes them on to the SQL translator:

df |> 
  mutate(
    z = n_distinct(inject(something), na.rm = TRUE)
  )
Error in `mutate()`:
ℹ In argument: `z = n_distinct(inject(something), na.rm = TRUE)`
Caused by error:
! Object `something` not found.

I cannot comment on whether it was a design-decision to not let glue_sql2() perform more checks or whether this is an oversight.

@krlmlr krlmlr changed the title add n_distinct(..., na.rm = TRUE) support for multiple passed columns. feat: Add n_distinct(..., na.rm = TRUE) support for multiple passed columns Oct 3, 2025
@krlmlr
krlmlr merged commit 03c7aba into duckdb:main Oct 3, 2025
18 checks passed
@krlmlr

krlmlr commented Oct 3, 2025

Copy link
Copy Markdown
Collaborator

Thanks!

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Oct 4, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants