Skip to content

Commit 5e62eac

Browse files
authored
Add is not to nullable boolean filter (#11371)
1 parent 6153432 commit 5e62eac

24 files changed

+653
-62
lines changed

crates/viewer/re_dataframe_ui/src/filters/boolean.rs

Lines changed: 143 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
use std::fmt::Formatter;
2+
13
use arrow::datatypes::{DataType, Field};
24
use datafusion::common::Column;
3-
use datafusion::logical_expr::{Expr, col, lit};
5+
use datafusion::logical_expr::{Expr, col, lit, not};
46
use datafusion::prelude::{array_element, array_has, array_sort};
7+
use strum::VariantArray as _;
58

69
use re_ui::UiExt as _;
710
use re_ui::syntax_highlighting::SyntaxHighlightedBuilder;
@@ -11,8 +14,9 @@ use super::{FilterError, FilterUiAction};
1114
/// Filter for non-nullable boolean columns.
1215
///
1316
/// This represents both the filter itself, and the state of the corresponding UI.
14-
#[derive(Debug, Clone, PartialEq, Eq)]
17+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
1518
pub enum NonNullableBooleanFilter {
19+
#[default]
1620
IsTrue,
1721
IsFalse,
1822
}
@@ -52,7 +56,11 @@ impl NonNullableBooleanFilter {
5256
}
5357

5458
pub fn popup_ui(&mut self, ui: &mut egui::Ui, column_name: &str) -> FilterUiAction {
55-
popup_header_ui(ui, column_name);
59+
ui.label(
60+
SyntaxHighlightedBuilder::body_default(column_name)
61+
.with_keyword(" is")
62+
.into_widget_text(ui.style()),
63+
);
5664

5765
let mut clicked = false;
5866

@@ -72,83 +80,192 @@ impl NonNullableBooleanFilter {
7280
}
7381
}
7482

75-
/// Filter for nullable boolean columns.
76-
///
77-
/// This represents both the filter itself, and the state of the corresponding UI.
78-
#[derive(Debug, Clone, PartialEq, Eq)]
83+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::VariantArray)]
7984
#[expect(clippy::enum_variant_names)]
80-
pub enum NullableBooleanFilter {
85+
pub enum NullableBooleanValue {
86+
#[default]
8187
IsTrue,
8288
IsFalse,
8389
IsNull,
8490
}
8591

86-
impl NullableBooleanFilter {
92+
impl NullableBooleanValue {
8793
pub fn as_bool(&self) -> Option<bool> {
8894
match self {
8995
Self::IsTrue => Some(true),
9096
Self::IsFalse => Some(false),
9197
Self::IsNull => None,
9298
}
9399
}
100+
}
101+
102+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::VariantArray)]
103+
pub enum NullableBooleanOperator {
104+
#[default]
105+
Is,
106+
IsNot,
107+
}
108+
109+
impl std::fmt::Display for NullableBooleanOperator {
110+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
111+
match self {
112+
Self::Is => "is".fmt(f),
113+
Self::IsNot => "is not".fmt(f),
114+
}
115+
}
116+
}
117+
118+
/// Filter for nullable boolean columns.
119+
///
120+
/// This represents both the filter itself, and the state of the corresponding UI.
121+
#[derive(Clone, Default, PartialEq, Eq)]
122+
pub struct NullableBooleanFilter {
123+
value: NullableBooleanValue,
124+
operator: NullableBooleanOperator,
125+
}
126+
127+
// to make snapshot more compact
128+
impl std::fmt::Debug for NullableBooleanFilter {
129+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
130+
let op = match self.operator {
131+
NullableBooleanOperator::Is => "",
132+
NullableBooleanOperator::IsNot => "not ",
133+
};
134+
135+
f.write_str(&format!("NullableBooleanFilter({op}{:?})", self.value))
136+
}
137+
}
138+
139+
impl NullableBooleanFilter {
140+
pub fn new_is_true() -> Self {
141+
Self {
142+
value: NullableBooleanValue::IsTrue,
143+
operator: NullableBooleanOperator::Is,
144+
}
145+
}
146+
147+
pub fn new_is_false() -> Self {
148+
Self {
149+
value: NullableBooleanValue::IsFalse,
150+
operator: NullableBooleanOperator::Is,
151+
}
152+
}
153+
154+
pub fn new_is_null() -> Self {
155+
Self {
156+
value: NullableBooleanValue::IsNull,
157+
operator: NullableBooleanOperator::Is,
158+
}
159+
}
160+
161+
pub fn with_is_not(mut self) -> Self {
162+
self.operator = NullableBooleanOperator::IsNot;
163+
self
164+
}
94165

95166
pub fn as_filter_expression(
96167
&self,
97168
column: &Column,
98169
field: &Field,
99170
) -> Result<Expr, FilterError> {
100-
match field.data_type() {
171+
let expr = match field.data_type() {
101172
DataType::Boolean => {
102-
if let Some(value) = self.as_bool() {
103-
Ok(col(column.clone()).eq(lit(value)))
173+
if let Some(value) = self.value.as_bool() {
174+
col(column.clone()).eq(lit(value))
104175
} else {
105-
Ok(col(column.clone()).is_null())
176+
col(column.clone()).is_null()
106177
}
107178
}
108179

109180
DataType::List(field) | DataType::ListView(field)
110181
if field.data_type() == &DataType::Boolean =>
111182
{
112183
// `ANY` semantics
113-
if let Some(value) = self.as_bool() {
114-
Ok(array_has(col(column.clone()), lit(value)))
184+
if let Some(value) = self.value.as_bool() {
185+
array_has(col(column.clone()), lit(value))
115186
} else {
116-
Ok(col(column.clone()).is_null().or(array_element(
187+
col(column.clone()).is_null().or(array_element(
117188
array_sort(col(column.clone()), lit("ASC"), lit("NULLS FIRST")),
118189
lit(1),
119190
)
120-
.is_null()))
191+
.is_null())
121192
}
122193
}
123194

124-
_ => Err(FilterError::InvalidNullableBooleanFilter(
125-
self.clone(),
126-
field.clone().into(),
127-
)),
195+
_ => {
196+
return Err(FilterError::InvalidNullableBooleanFilter(
197+
self.clone(),
198+
field.clone().into(),
199+
));
200+
}
201+
};
202+
203+
match self.operator {
204+
NullableBooleanOperator::Is => Ok(expr),
205+
NullableBooleanOperator::IsNot => Ok(not(expr.clone()).or(expr.is_null())),
128206
}
129207
}
130208

131209
pub fn operand_text(&self) -> String {
132-
if let Some(value) = self.as_bool() {
210+
if let Some(value) = self.value.as_bool() {
133211
value.to_string()
134212
} else {
135213
"null".to_owned()
136214
}
137215
}
138216

217+
pub fn operator(&self) -> NullableBooleanOperator {
218+
self.operator
219+
}
220+
139221
pub fn popup_ui(&mut self, ui: &mut egui::Ui, column_name: &str) -> FilterUiAction {
140-
popup_header_ui(ui, column_name);
222+
ui.horizontal(|ui| {
223+
ui.label(
224+
SyntaxHighlightedBuilder::body_default(column_name).into_widget_text(ui.style()),
225+
);
226+
227+
egui::ComboBox::new("null_bool_op", "")
228+
.selected_text(
229+
SyntaxHighlightedBuilder::keyword(&self.operator.to_string())
230+
.into_widget_text(ui.style()),
231+
)
232+
.show_ui(ui, |ui| {
233+
for possible_op in NullableBooleanOperator::VARIANTS {
234+
if ui
235+
.button(
236+
SyntaxHighlightedBuilder::keyword(&possible_op.to_string())
237+
.into_widget_text(ui.style()),
238+
)
239+
.clicked()
240+
{
241+
self.operator = *possible_op;
242+
}
243+
}
244+
});
245+
});
141246

142247
let mut clicked = false;
143248

144249
clicked |= ui
145-
.re_radio_value(self, Self::IsTrue, primitive_widget_text(ui, "true"))
250+
.re_radio_value(
251+
&mut self.value,
252+
NullableBooleanValue::IsTrue,
253+
primitive_widget_text(ui, "true"),
254+
)
146255
.clicked();
147256
clicked |= ui
148-
.re_radio_value(self, Self::IsFalse, primitive_widget_text(ui, "false"))
257+
.re_radio_value(
258+
&mut self.value,
259+
NullableBooleanValue::IsFalse,
260+
primitive_widget_text(ui, "false"),
261+
)
149262
.clicked();
150263
clicked |= ui
151-
.re_radio_value(self, Self::IsNull, primitive_widget_text(ui, "null"))
264+
.re_radio_value(
265+
&mut self.value,
266+
NullableBooleanValue::IsNull,
267+
primitive_widget_text(ui, "null"),
268+
)
152269
.clicked();
153270

154271
if clicked {
@@ -162,11 +279,3 @@ impl NullableBooleanFilter {
162279
fn primitive_widget_text(ui: &egui::Ui, s: &str) -> egui::WidgetText {
163280
SyntaxHighlightedBuilder::primitive(s).into_widget_text(ui.style())
164281
}
165-
166-
fn popup_header_ui(ui: &mut egui::Ui, column_name: &str) {
167-
ui.label(
168-
SyntaxHighlightedBuilder::body_default(column_name)
169-
.with_keyword(" is")
170-
.into_widget_text(ui.style()),
171-
);
172-
}

crates/viewer/re_dataframe_ui/src/filters/filter.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,9 @@ impl FilterKind {
163163
match data_type {
164164
DataType::Boolean => {
165165
if nullability.is_either() {
166-
Some(Self::NullableBoolean(NullableBooleanFilter::IsTrue))
166+
Some(Self::NullableBoolean(Default::default()))
167167
} else {
168-
Some(Self::NonNullableBoolean(NonNullableBooleanFilter::IsTrue))
168+
Some(Self::NonNullableBoolean(Default::default()))
169169
}
170170
}
171171

crates/viewer/re_dataframe_ui/src/filters/filter_ui.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,10 +317,13 @@ impl SyntaxHighlighting for TimestampFormatted<'_, FilterKind> {
317317
//TODO(ab): these implementation details should be dispatched to the respective sub-structs.
318318
fn syntax_highlight_into(&self, builder: &mut SyntaxHighlightedBuilder) {
319319
let operator_text = match self.inner {
320+
FilterKind::NonNullableBoolean(_) => "is".to_owned(),
321+
FilterKind::NullableBoolean(nullable_boolean_filter) => {
322+
nullable_boolean_filter.operator().to_string()
323+
}
320324
FilterKind::Int(int_filter) => int_filter.comparison_operator().to_string(),
321325
FilterKind::Float(float_filter) => float_filter.comparison_operator().to_string(),
322326
FilterKind::String(string_filter) => string_filter.operator().to_string(),
323-
FilterKind::NonNullableBoolean(_) | FilterKind::NullableBoolean(_) => "is".to_owned(),
324327
FilterKind::Timestamp(timestamp_filter) => timestamp_filter.operator().to_string(),
325328
};
326329

@@ -453,15 +456,19 @@ mod tests {
453456
"boolean_equals_false",
454457
),
455458
(
456-
FilterKind::NullableBoolean(NullableBooleanFilter::IsTrue),
459+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_true()),
457460
"nullable_boolean_equals_true",
458461
),
459462
(
460-
FilterKind::NullableBoolean(NullableBooleanFilter::IsFalse),
463+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_true().with_is_not()),
464+
"nullable_boolean_not_equals_true",
465+
),
466+
(
467+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_false()),
461468
"nullable_boolean_equals_false",
462469
),
463470
(
464-
FilterKind::NullableBoolean(NullableBooleanFilter::IsNull),
471+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_null()),
465472
"nullable_boolean_equals_null",
466473
),
467474
(

crates/viewer/re_dataframe_ui/src/filters/string.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ impl StringFilter {
100100
SyntaxHighlightedBuilder::keyword(&operator_text).into_widget_text(ui.style()),
101101
)
102102
.show_ui(ui, |ui| {
103-
for possible_op in crate::filters::StringOperator::VARIANTS {
103+
for possible_op in StringOperator::VARIANTS {
104104
if ui
105105
.button(
106106
SyntaxHighlightedBuilder::keyword(&possible_op.to_string())

crates/viewer/re_dataframe_ui/tests/filter_tests.rs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -685,22 +685,34 @@ async fn test_non_nullable_boolean_equals() {
685685
#[tokio::test]
686686
async fn test_nullable_boolean_equals() {
687687
filter_snapshot!(
688-
FilterKind::NullableBoolean(NullableBooleanFilter::IsTrue),
688+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_true()),
689689
TestColumn::bools_nulls(),
690690
"nulls_true"
691691
);
692692

693693
filter_snapshot!(
694-
FilterKind::NullableBoolean(NullableBooleanFilter::IsFalse),
694+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_false()),
695695
TestColumn::bools_nulls(),
696696
"nulls_false"
697697
);
698698

699699
filter_snapshot!(
700-
FilterKind::NullableBoolean(NullableBooleanFilter::IsNull),
700+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_null()),
701701
TestColumn::bools_nulls(),
702702
"nulls_null"
703703
);
704+
705+
filter_snapshot!(
706+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_true().with_is_not()),
707+
TestColumn::bools_nulls(),
708+
"nulls_is_not_true"
709+
);
710+
711+
filter_snapshot!(
712+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_null().with_is_not()),
713+
TestColumn::bools_nulls(),
714+
"nulls_is_not_null"
715+
);
704716
}
705717

706718
#[tokio::test]
@@ -716,14 +728,31 @@ async fn test_boolean_equals_list_non_nullable() {
716728

717729
#[tokio::test]
718730
async fn test_boolean_equals_list_nullable() {
731+
let filters = [
732+
(
733+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_true()),
734+
"is_true",
735+
),
736+
(
737+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_true().with_is_not()),
738+
"is_not_true",
739+
),
740+
(
741+
FilterKind::NullableBoolean(NullableBooleanFilter::new_is_null()),
742+
"is_null",
743+
),
744+
];
745+
719746
// Note: NullableBooleanFilter doesn't support Nullability::NONE, but that's ok because
720747
// NonNullableBooleanFilter is used in this case.
721-
for nullability in [Nullability::BOTH, Nullability::INNER, Nullability::OUTER] {
722-
filter_snapshot!(
723-
FilterKind::NullableBoolean(NullableBooleanFilter::IsNull),
724-
TestColumn::bool_lists(nullability),
725-
format!("{nullability:?}")
726-
);
748+
for (filter, filter_str) in filters {
749+
for nullability in [Nullability::BOTH, Nullability::INNER, Nullability::OUTER] {
750+
filter_snapshot!(
751+
filter.clone(),
752+
TestColumn::bool_lists(nullability),
753+
format!("{nullability:?}_{filter_str}")
754+
);
755+
}
727756
}
728757
}
729758

0 commit comments

Comments
 (0)