Skip to content

Commit c83a981

Browse files
feat: add DataFrame fill_nan (#22702)
## Which issue does this PR close? <!-- We generally require a GitHub issue to be filed for all bug fixes and enhancements and this helps us generate change logs for our releases. You can link an issue to this PR using the GitHub syntax. For example `Closes #123` indicates that this PR will close issue #123. --> - Closes #14770 . ## What changes are included in this PR? <!-- There is no need to duplicate the description in the issue here but it is sometimes worth providing a summary of the individual changes in this PR. --> Add `fill_nan` and test by referencing the `fill_null` mirror. ## Are these changes tested? <!-- We typically require tests for all PRs in order to: 1. Prevent the code from being accidentally broken by subsequent changes 2. Serve as another way to document the expected behavior of the code If tests are not included in your PR, please explain why (for example, are they covered by existing tests)? --> Yes ## Are there any user-facing changes? <!-- If there are user-facing changes then we may require documentation to be updated before approving the PR. --> Add a new function. --------- Co-authored-by: Jeffrey Vo <jeffrey.vo.australia@gmail.com>
1 parent 710e929 commit c83a981

2 files changed

Lines changed: 233 additions & 31 deletions

File tree

datafusion/core/src/dataframe/mod.rs

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,11 @@ use datafusion_common::{
5858
};
5959
use datafusion_expr::select_expr::SelectExpr;
6060
use datafusion_expr::{
61-
ExplainOption, SortExpr, TableProviderFilterPushDown, UNNAMED_TABLE, case,
62-
dml::InsertOp,
63-
expr::{Alias, ScalarFunction},
64-
is_null, lit,
65-
utils::COUNT_STAR_EXPANSION,
61+
ExplainOption, ScalarUDF, SortExpr, TableProviderFilterPushDown, UNNAMED_TABLE, case,
62+
dml::InsertOp, is_null, lit, utils::COUNT_STAR_EXPANSION,
6663
};
6764
use datafusion_functions::core::coalesce;
65+
use datafusion_functions::math::nanvl;
6866
use datafusion_functions_aggregate::expr_fn::{
6967
avg, count, max, median, min, stddev, sum,
7068
};
@@ -2471,6 +2469,64 @@ impl DataFrame {
24712469
&self,
24722470
value: ScalarValue,
24732471
columns: Vec<String>,
2472+
) -> Result<DataFrame> {
2473+
self.fill_columns(&value, &columns, &coalesce(), |_| true)
2474+
}
2475+
2476+
// Helper to find columns from names
2477+
fn find_columns(&self, names: &[impl AsRef<str>]) -> Result<Vec<FieldRef>> {
2478+
let schema = self.logical_plan().schema();
2479+
names
2480+
.iter()
2481+
.map(|name| {
2482+
let name = name.as_ref();
2483+
schema
2484+
.field_with_name(None, name)
2485+
.cloned()
2486+
.map_err(|_| plan_datafusion_err!("Column '{}' not found", name))
2487+
})
2488+
.collect()
2489+
}
2490+
2491+
/// Fill NaN values in specified floating-point columns with a given value
2492+
/// If no columns are specified (empty slice), applies to all columns
2493+
/// Only floating-point columns are affected; other columns are left unchanged
2494+
/// Only fills if the value can be cast to the column's type
2495+
///
2496+
/// # Arguments
2497+
/// * `value` - Value to fill NaNs with
2498+
/// * `columns` - List of column names to fill. If empty, fills all columns.
2499+
///
2500+
/// # Example
2501+
/// ```
2502+
/// # use datafusion::prelude::*;
2503+
/// # use datafusion::error::Result;
2504+
/// # use datafusion_common::ScalarValue;
2505+
/// # #[tokio::main]
2506+
/// # async fn main() -> Result<()> {
2507+
/// let ctx = SessionContext::new();
2508+
/// let df = ctx
2509+
/// .read_csv("tests/data/example.csv", CsvReadOptions::new())
2510+
/// .await?;
2511+
/// // Fill NaN in only columns "a" and "c":
2512+
/// let df = df.fill_nan(&ScalarValue::from(0.0), &["a", "c"])?;
2513+
/// // Fill NaN across all columns:
2514+
/// let df = df.fill_nan(&ScalarValue::from(0.0), &[])?;
2515+
/// # Ok(())
2516+
/// # }
2517+
/// ```
2518+
pub fn fill_nan(&self, value: &ScalarValue, columns: &[&str]) -> Result<DataFrame> {
2519+
self.fill_columns(value, columns, &nanvl(), |field| {
2520+
field.data_type().is_floating()
2521+
})
2522+
}
2523+
2524+
fn fill_columns(
2525+
&self,
2526+
value: &ScalarValue,
2527+
columns: &[impl AsRef<str>],
2528+
func: &Arc<ScalarUDF>,
2529+
applies: impl Fn(&FieldRef) -> bool,
24742530
) -> Result<DataFrame> {
24752531
let cols = if columns.is_empty() {
24762532
self.logical_plan()
@@ -2480,28 +2536,21 @@ impl DataFrame {
24802536
.map(Arc::clone)
24812537
.collect()
24822538
} else {
2483-
self.find_columns(&columns)?
2539+
self.find_columns(columns)?
24842540
};
24852541

2486-
// Create projections for each column
24872542
let projections = self
24882543
.logical_plan()
24892544
.schema()
24902545
.fields()
24912546
.iter()
24922547
.map(|field| {
2493-
if cols.contains(field) {
2548+
if cols.contains(field) && applies(field) {
24942549
// Try to cast fill value to column type. If the cast fails, fallback to the original column.
24952550
match value.clone().cast_to(field.data_type()) {
2496-
Ok(fill_value) => Expr::Alias(Alias {
2497-
expr: Box::new(Expr::ScalarFunction(ScalarFunction {
2498-
func: coalesce(),
2499-
args: vec![col(field.name()), lit(fill_value)],
2500-
})),
2501-
relation: None,
2502-
name: field.name().to_string(),
2503-
metadata: None,
2504-
}),
2551+
Ok(fill_value) => func
2552+
.call(vec![col(field.name()), lit(fill_value)])
2553+
.alias(field.name()),
25052554
Err(_) => col(field.name()),
25062555
}
25072556
} else {
@@ -2513,20 +2562,6 @@ impl DataFrame {
25132562
self.clone().select(projections)
25142563
}
25152564

2516-
// Helper to find columns from names
2517-
fn find_columns(&self, names: &[String]) -> Result<Vec<FieldRef>> {
2518-
let schema = self.logical_plan().schema();
2519-
names
2520-
.iter()
2521-
.map(|name| {
2522-
schema
2523-
.field_with_name(None, name)
2524-
.cloned()
2525-
.map_err(|_| plan_datafusion_err!("Column '{}' not found", name))
2526-
})
2527-
.collect()
2528-
}
2529-
25302565
/// Find qualified columns for this dataframe from names
25312566
///
25322567
/// # Arguments

datafusion/core/tests/dataframe/mod.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6539,6 +6539,173 @@ async fn test_fill_null_all_columns() -> Result<()> {
65396539
Ok(())
65406540
}
65416541

6542+
async fn create_nan_table() -> Result<DataFrame> {
6543+
// create a DataFrame with a NaN value in a float column "a" and a
6544+
// non-float column "b" that must stay untouched by fill_nan.
6545+
// "+-----+---+",
6546+
// "| a | b |",
6547+
// "+-----+---+",
6548+
// "| 1.0 | 1 |",
6549+
// "| NaN | 2 |",
6550+
// "| 3.0 | 3 |",
6551+
// "+-----+---+",
6552+
let schema = Arc::new(Schema::new(vec![
6553+
Field::new("a", DataType::Float64, true),
6554+
Field::new("b", DataType::Int32, true),
6555+
]));
6556+
let a_values = Float64Array::from(vec![Some(1.0), Some(f64::NAN), Some(3.0)]);
6557+
let b_values = Int32Array::from(vec![Some(1), Some(2), Some(3)]);
6558+
let batch = RecordBatch::try_new(
6559+
schema.clone(),
6560+
vec![Arc::new(a_values), Arc::new(b_values)],
6561+
)?;
6562+
6563+
let ctx = SessionContext::new();
6564+
let table = MemTable::try_new(schema.clone(), vec![vec![batch]])?;
6565+
ctx.register_table("t_nan", Arc::new(table))?;
6566+
let df = ctx.table("t_nan").await?;
6567+
Ok(df)
6568+
}
6569+
6570+
#[tokio::test]
6571+
async fn test_fill_nan() -> Result<()> {
6572+
let df = create_nan_table().await?;
6573+
6574+
// Fill NaNs in the float column "a" with 0.0.
6575+
let df_filled = df.fill_nan(&ScalarValue::Float64(Some(0.0)), &["a"])?;
6576+
6577+
let results = df_filled.collect().await?;
6578+
assert_snapshot!(
6579+
batches_to_sort_string(&results),
6580+
@r"
6581+
+-----+---+
6582+
| a | b |
6583+
+-----+---+
6584+
| 0.0 | 2 |
6585+
| 1.0 | 1 |
6586+
| 3.0 | 3 |
6587+
+-----+---+
6588+
"
6589+
);
6590+
6591+
Ok(())
6592+
}
6593+
6594+
#[tokio::test]
6595+
async fn test_fill_nan_all_columns() -> Result<()> {
6596+
let df = create_nan_table().await?;
6597+
6598+
// Fill NaNs across all columns. Only the float column "a" is affected;
6599+
// the non-float column "b" is left unchanged since NaN only exists for
6600+
// floating-point types.
6601+
let df_filled = df.fill_nan(&ScalarValue::Float64(Some(0.0)), &[])?;
6602+
6603+
let results = df_filled.collect().await?;
6604+
assert_snapshot!(
6605+
batches_to_sort_string(&results),
6606+
@r"
6607+
+-----+---+
6608+
| a | b |
6609+
+-----+---+
6610+
| 0.0 | 2 |
6611+
| 1.0 | 1 |
6612+
| 3.0 | 3 |
6613+
+-----+---+
6614+
"
6615+
);
6616+
Ok(())
6617+
}
6618+
6619+
#[tokio::test]
6620+
async fn test_fill_nan_non_float_column() -> Result<()> {
6621+
let df = create_nan_table().await?;
6622+
6623+
// Explicitly naming a non-float column is a no-op, not an error: NaN does
6624+
// not exist for Int32, so column "b" (and the un-targeted "a") are unchanged.
6625+
let df_filled = df.fill_nan(&ScalarValue::Float64(Some(0.0)), &["b"])?;
6626+
6627+
let results = df_filled.collect().await?;
6628+
assert_snapshot!(
6629+
batches_to_sort_string(&results),
6630+
@r"
6631+
+-----+---+
6632+
| a | b |
6633+
+-----+---+
6634+
| 1.0 | 1 |
6635+
| 3.0 | 3 |
6636+
| NaN | 2 |
6637+
+-----+---+
6638+
"
6639+
);
6640+
6641+
Ok(())
6642+
}
6643+
6644+
#[tokio::test]
6645+
async fn test_fill_nan_unknown_column() -> Result<()> {
6646+
let df = create_nan_table().await?;
6647+
6648+
// A column name that is not in the schema is propagated as an error.
6649+
let err = df
6650+
.fill_nan(&ScalarValue::Float64(Some(0.0)), &["does_not_exist"])
6651+
.unwrap_err();
6652+
6653+
assert_snapshot!(err.strip_backtrace(), @"Error during planning: Column 'does_not_exist' not found");
6654+
6655+
Ok(())
6656+
}
6657+
6658+
#[tokio::test]
6659+
async fn test_fill_nan_casts_fill_value() -> Result<()> {
6660+
let df = create_nan_table().await?;
6661+
6662+
// Int32(0) is not the column's type (Float64) but can be cast to it, so the
6663+
// NaN is replaced with 0.0. Exercises the cross-type cast path — the other
6664+
// positive tests pass a Float64 value, which skips the actual cast.
6665+
let df_filled = df.fill_nan(&ScalarValue::Int32(Some(0)), &["a"])?;
6666+
6667+
let results = df_filled.collect().await?;
6668+
assert_snapshot!(
6669+
batches_to_sort_string(&results),
6670+
@r"
6671+
+-----+---+
6672+
| a | b |
6673+
+-----+---+
6674+
| 0.0 | 2 |
6675+
| 1.0 | 1 |
6676+
| 3.0 | 3 |
6677+
+-----+---+
6678+
"
6679+
);
6680+
6681+
Ok(())
6682+
}
6683+
6684+
#[tokio::test]
6685+
async fn test_fill_nan_uncastable_value() -> Result<()> {
6686+
let df = create_nan_table().await?;
6687+
6688+
// The float column "a" is targeted, but "abc" cannot be cast to Float64, so
6689+
// the fill is skipped and column "a" keeps its original NaN value.
6690+
let df_filled = df.fill_nan(&ScalarValue::Utf8(Some("abc".to_string())), &["a"])?;
6691+
6692+
let results = df_filled.collect().await?;
6693+
assert_snapshot!(
6694+
batches_to_sort_string(&results),
6695+
@r"
6696+
+-----+---+
6697+
| a | b |
6698+
+-----+---+
6699+
| 1.0 | 1 |
6700+
| 3.0 | 3 |
6701+
| NaN | 2 |
6702+
+-----+---+
6703+
"
6704+
);
6705+
6706+
Ok(())
6707+
}
6708+
65426709
#[tokio::test]
65436710
async fn test_insert_into_casting_support() -> Result<()> {
65446711
// Testing case1:

0 commit comments

Comments
 (0)