Skip to content

Commit e65e30c

Browse files
committed
#868: added toggle for missing/populated filter
1 parent 64d7aa4 commit e65e30c

14 files changed

+178
-85
lines changed

dtale/column_filters.py

Lines changed: 80 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -79,30 +79,48 @@ def build_filter(self):
7979
return self.cfg
8080

8181

82-
class MissingFilter(object):
82+
class MissingOrPopulatedFilter(object):
8383
def __init__(self, column, classification, cfg):
8484
self.column = column
8585
self.classification = classification
8686
self.cfg = cfg
8787

88-
def handle_missing(self, fltr):
89-
if self.cfg is None or not self.cfg.get("missing", False):
88+
def handle_missing_or_populated(self, fltr):
89+
if self.cfg is None or (
90+
not self.cfg.get("missing", False) and not self.cfg.get("populated", False)
91+
):
9092
return fltr
91-
return {
92-
"missing": True,
93-
"meta": {
94-
"column": self.column,
95-
"classification": self.classification,
96-
"type": self.cfg["type"],
97-
},
98-
"query": "{col}.isnull()".format(col=build_col_key(self.column)),
99-
}
100-
101-
def update_missing_query_builder(self, query_builder, fltr=None):
102-
if self.cfg is None or not self.cfg.get("missing", False):
93+
if self.cfg.get("missing", False):
94+
return {
95+
"missing": True,
96+
"meta": {
97+
"column": self.column,
98+
"classification": self.classification,
99+
"type": self.cfg["type"],
100+
},
101+
"query": "{col}.isnull()".format(col=build_col_key(self.column)),
102+
}
103+
if self.cfg.get("populated", False):
104+
return {
105+
"populated": True,
106+
"meta": {
107+
"column": self.column,
108+
"classification": self.classification,
109+
"type": self.cfg["type"],
110+
},
111+
"query": "~{col}.isnull()".format(col=build_col_key(self.column)),
112+
}
113+
114+
def update_missing_or_populated_query_builder(self, query_builder, fltr=None):
115+
if self.cfg is None or (
116+
not self.cfg.get("missing", False) and not self.cfg.get("populated", False)
117+
):
103118
return fltr
104119
# TODO: how to handle scenarios where QueryBuilder doesn't support functionality so do it manually
105-
return query_builder[self.column] != query_builder[self.column]
120+
if self.cfg.get("missing", False):
121+
return query_builder[self.column] != query_builder[self.column]
122+
if self.cfg.get("populated", False):
123+
return query_builder[self.column] == query_builder[self.column]
106124

107125

108126
def handle_ne(query, operand):
@@ -117,19 +135,19 @@ def handle_query_builder_ne(query, operand):
117135
return query
118136

119137

120-
class StringFilter(MissingFilter):
138+
class StringFilter(MissingOrPopulatedFilter):
121139
def __init__(self, column, classification, cfg):
122140
super(StringFilter, self).__init__(column, classification, cfg)
123141

124142
def build_filter(self):
125143
if self.cfg is None:
126-
return super(StringFilter, self).handle_missing(None)
144+
return super(StringFilter, self).handle_missing_or_populated(None)
127145

128146
action = self.cfg.get("action", "equals")
129147
if action == "equals" and not len(self.cfg.get("value", [])):
130-
return super(StringFilter, self).handle_missing(None)
148+
return super(StringFilter, self).handle_missing_or_populated(None)
131149
elif action != "equals" and not self.cfg.get("raw"):
132-
return super(StringFilter, self).handle_missing(None)
150+
return super(StringFilter, self).handle_missing_or_populated(None)
133151

134152
state = self.cfg.get("value", [])
135153
case_sensitive = self.cfg.get("caseSensitive", False)
@@ -191,17 +209,23 @@ def build_filter(self):
191209
build_col_key(self.column), raw
192210
)
193211
fltr["query"] = handle_ne(fltr["query"], operand)
194-
return super(StringFilter, self).handle_missing(fltr)
212+
return super(StringFilter, self).handle_missing_or_populated(fltr)
195213

196214
def update_query_builder(self, query_builder):
197215
if self.cfg is None:
198-
return super(StringFilter, self).update_missing_query_builder(query_builder)
216+
return super(StringFilter, self).update_missing_or_populated_query_builder(
217+
query_builder
218+
)
199219

200220
action = self.cfg.get("action", "equals")
201221
if action == "equals" and not len(self.cfg.get("value", [])):
202-
return super(StringFilter, self).update_missing_query_builder(query_builder)
222+
return super(StringFilter, self).update_missing_or_populated_query_builder(
223+
query_builder
224+
)
203225
elif action != "equals" and not self.cfg.get("raw"):
204-
return super(StringFilter, self).update_missing_query_builder(query_builder)
226+
return super(StringFilter, self).update_missing_or_populated_query_builder(
227+
query_builder
228+
)
205229

206230
state = self.cfg.get("value", [])
207231
case_sensitive = self.cfg.get("caseSensitive", False)
@@ -232,18 +256,18 @@ def update_query_builder(self, query_builder):
232256
elif action == "length":
233257
# Not supported by QueryBuilder
234258
pass
235-
return super(StringFilter, self).update_missing_query_builder(
259+
return super(StringFilter, self).update_missing_or_populated_query_builder(
236260
query_builder, fltr.get("query")
237261
)
238262

239263

240-
class NumericFilter(MissingFilter):
264+
class NumericFilter(MissingOrPopulatedFilter):
241265
def __init__(self, column, classification, cfg):
242266
super(NumericFilter, self).__init__(column, classification, cfg)
243267

244268
def build_filter(self):
245269
if self.cfg is None:
246-
return super(NumericFilter, self).handle_missing(None)
270+
return super(NumericFilter, self).handle_missing_or_populated(None)
247271
cfg_val, cfg_operand, cfg_min, cfg_max = (
248272
self.cfg.get(p) for p in ["value", "operand", "min", "max"]
249273
)
@@ -259,7 +283,7 @@ def build_filter(self):
259283
if cfg_operand in ["=", "ne"]:
260284
state = make_list(cfg_val or [])
261285
if not len(state):
262-
return super(NumericFilter, self).handle_missing(None)
286+
return super(NumericFilter, self).handle_missing_or_populated(None)
263287
fltr = dict(value=cfg_val, **base_fltr)
264288
if len(state) == 1:
265289
fltr["query"] = "{} {} {}".format(
@@ -273,18 +297,18 @@ def build_filter(self):
273297
"in" if cfg_operand == "=" else "not in",
274298
", ".join(map(str, state)),
275299
)
276-
return super(NumericFilter, self).handle_missing(fltr)
300+
return super(NumericFilter, self).handle_missing_or_populated(fltr)
277301
if cfg_operand in ["<", ">", "<=", ">="]:
278302
if cfg_val is None:
279-
return super(NumericFilter, self).handle_missing(None)
303+
return super(NumericFilter, self).handle_missing_or_populated(None)
280304
fltr = dict(
281305
value=cfg_val,
282306
query="{} {} {}".format(
283307
build_col_key(self.column), cfg_operand, cfg_val
284308
),
285309
**base_fltr
286310
)
287-
return super(NumericFilter, self).handle_missing(fltr)
311+
return super(NumericFilter, self).handle_missing_or_populated(fltr)
288312
if cfg_operand in ["[]", "()"]:
289313
fltr = dict(**base_fltr)
290314
queries = []
@@ -309,14 +333,14 @@ def build_filter(self):
309333
if len(queries) == 2 and cfg_max == cfg_min:
310334
queries = ["{} == {}".format(build_col_key(self.column), cfg_max)]
311335
if not len(queries):
312-
return super(NumericFilter, self).handle_missing(None)
336+
return super(NumericFilter, self).handle_missing_or_populated(None)
313337
fltr["query"] = " and ".join(queries)
314-
return super(NumericFilter, self).handle_missing(fltr)
315-
return super(NumericFilter, self).handle_missing(None)
338+
return super(NumericFilter, self).handle_missing_or_populated(fltr)
339+
return super(NumericFilter, self).handle_missing_or_populated(None)
316340

317341
def update_query_builder(self, query_builder):
318342
if self.cfg is None:
319-
return super(NumericFilter, self).update_missing_query_builder(
343+
return super(NumericFilter, self).update_missing_or_populated_query_builder(
320344
query_builder
321345
)
322346
cfg_val, cfg_operand, cfg_min, cfg_max = (
@@ -328,9 +352,9 @@ def update_query_builder(self, query_builder):
328352
if self.cfg.get("meta", {}).get("type") == "float":
329353
state = [np.float64(val) for val in state]
330354
if not len(state):
331-
return super(NumericFilter, self).update_missing_query_builder(
332-
query_builder
333-
)
355+
return super(
356+
NumericFilter, self
357+
).update_missing_or_populated_query_builder(query_builder)
334358
fltr = dict(value=cfg_val, operand=cfg_operand)
335359
if len(state) == 1:
336360
fltr["query"] = handle_query_builder_ne(
@@ -340,14 +364,14 @@ def update_query_builder(self, query_builder):
340364
fltr["query"] = handle_query_builder_ne(
341365
query_builder[self.column].isin(state), cfg_operand
342366
)
343-
return super(NumericFilter, self).update_missing_query_builder(
367+
return super(NumericFilter, self).update_missing_or_populated_query_builder(
344368
query_builder, fltr.get("query")
345369
)
346370
if cfg_operand in ["<", ">", "<=", ">="]:
347371
if cfg_val is None:
348-
return super(NumericFilter, self).update_missing_query_builder(
349-
query_builder
350-
)
372+
return super(
373+
NumericFilter, self
374+
).update_missing_or_populated_query_builder(query_builder)
351375
fltr = dict(value=cfg_val, operand=cfg_operand)
352376
if cfg_operand == "<":
353377
fltr["query"] = query_builder[self.column] < cfg_val
@@ -357,24 +381,26 @@ def update_query_builder(self, query_builder):
357381
fltr["query"] = query_builder[self.column] <= cfg_val
358382
elif cfg_operand == ">=":
359383
fltr["query"] = query_builder[self.column] >= cfg_val
360-
return super(NumericFilter, self).update_missing_query_builder(
384+
return super(NumericFilter, self).update_missing_or_populated_query_builder(
361385
query_builder, fltr.get("query")
362386
)
363387
if cfg_operand in ["[]", "()"]:
364388
# Not supported by QueryBuilder
365-
return super(NumericFilter, self).update_missing_query_builder(
389+
return super(NumericFilter, self).update_missing_or_populated_query_builder(
366390
query_builder
367391
)
368-
return super(NumericFilter, self).update_missing_query_builder(query_builder)
392+
return super(NumericFilter, self).update_missing_or_populated_query_builder(
393+
query_builder
394+
)
369395

370396

371-
class DateFilter(MissingFilter):
397+
class DateFilter(MissingOrPopulatedFilter):
372398
def __init__(self, column, classification, cfg):
373399
super(DateFilter, self).__init__(column, classification, cfg)
374400

375401
def build_filter(self):
376402
if self.cfg is None:
377-
return super(DateFilter, self).handle_missing(None)
403+
return super(DateFilter, self).handle_missing_or_populated(None)
378404

379405
start, end = (self.cfg.get(p) for p in ["start", "end"])
380406
fltr = dict(
@@ -394,15 +420,17 @@ def build_filter(self):
394420
if len(queries) == 2 and start == end:
395421
queries = ["{} == '{}'".format(build_col_key(self.column), start)]
396422
if not len(queries):
397-
return super(DateFilter, self).handle_missing(None)
423+
return super(DateFilter, self).handle_missing_or_populated(None)
398424
fltr["query"] = " and ".join(queries)
399-
return super(DateFilter, self).handle_missing(fltr)
425+
return super(DateFilter, self).handle_missing_or_populated(fltr)
400426

401427
def update_query_builder(self, query_builder):
402428
# TODO: need to use datetime.datetime and then for equivalence you need to do (col > input - 1) & (col <= input)
403429
# pd.Timestamp('2023-01-04').to_pydatetime() -> to get datetime.datetime
404430
if self.cfg is None:
405-
return super(DateFilter, self).update_missing_query_builder(query_builder)
431+
return super(DateFilter, self).update_missing_or_populated_query_builder(
432+
query_builder
433+
)
406434

407435
start, end = (self.cfg.get(p) for p in ["start", "end"])
408436
fltr = dict(start=start, end=end)
@@ -426,11 +454,11 @@ def update_query_builder(self, query_builder):
426454
& (query_builder[self.column] <= start_end)
427455
]
428456
if not len(queries):
429-
return super(DateFilter, self).handle_missing(None)
457+
return super(DateFilter, self).handle_missing_or_populated(None)
430458
if len(queries) == 2:
431459
fltr["query"] = queries[0] & queries[1]
432460
else:
433461
fltr["query"] = queries[0]
434-
return super(DateFilter, self).update_missing_query_builder(
462+
return super(DateFilter, self).update_missing_or_populated_query_builder(
435463
query_builder, fltr.get("query")
436464
)

frontend/static/__tests__/filters/ColumnFilter-date-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, fireEvent } from '@testing-library/react';
1+
import { act, fireEvent, screen } from '@testing-library/react';
22

33
import * as TestSupport from './ColumnFilter.test.support';
44

@@ -30,11 +30,11 @@ describe('ColumnFilter date tests', () => {
3030
it('ColumnFilter date rendering', async () => {
3131
expect(result.getElementsByClassName('bp5-input').length).toBeGreaterThan(0);
3232
await act(async () => {
33-
await fireEvent.click(result.getElementsByClassName('ico-check-box-outline-blank')[0]);
33+
await fireEvent.click(screen.getByText('Missing'));
3434
});
3535
expect(result.getElementsByClassName('bp5-disabled')).toHaveLength(2);
3636
await act(async () => {
37-
await fireEvent.click(result.getElementsByClassName('ico-check-box')[0]);
37+
await fireEvent.click(screen.getByText('Missing'));
3838
});
3939
expect(result.getElementsByClassName('bp5-disabled')).toHaveLength(0);
4040
await act(async () => {

frontend/static/__tests__/filters/ColumnFilter-invalid-type-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, fireEvent } from '@testing-library/react';
1+
import { act, fireEvent, screen } from '@testing-library/react';
22
import selectEvent from 'react-select-event';
33

44
import * as ColumnFilterRepository from '../../repository/ColumnFilterRepository';
@@ -44,11 +44,11 @@ describe('ColumnFilter string tests', () => {
4444
it('ColumnFilter invalid type rendering', async () => {
4545
expect(result.getElementsByClassName('string-filter-inputs').length).toBe(1);
4646
await act(async () => {
47-
await fireEvent.click(result.getElementsByClassName('ico-check-box-outline-blank')[0]);
47+
await fireEvent.click(screen.getByText('Missing'));
4848
});
4949
expect(result.getElementsByClassName('Select__control--is-disabled').length).toBeGreaterThan(0);
5050
await act(async () => {
51-
await fireEvent.click(result.getElementsByClassName('ico-check-box')[0]);
51+
await fireEvent.click(screen.getByText('Missing'));
5252
});
5353
await act(async () => {
5454
await fireEvent.click(result.getElementsByClassName('ico-check-box')[0]);

frontend/static/__tests__/filters/ColumnFilter-numeric-async-test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ describe('ColumnFilter numeric tests', () => {
5252
it('ColumnFilter int rendering', async () => {
5353
expect(result.getElementsByClassName('numeric-filter-inputs').length).toBe(1);
5454
await act(async () => {
55-
await fireEvent.click(result.getElementsByClassName('ico-check-box-outline-blank')[0]);
55+
await fireEvent.click(screen.getByText('Missing'));
5656
});
57-
expect(spies.saveSpy).toHaveBeenLastCalledWith('1', 'col5', { type: 'int', missing: true });
57+
expect(spies.saveSpy).toHaveBeenLastCalledWith('1', 'col5', { type: 'int', missing: true, populated: false });
5858
await act(async () => {
59-
await fireEvent.click(result.getElementsByClassName('ico-check-box')[0]);
59+
await fireEvent.click(screen.getByText('Missing'));
6060
});
6161
expect(result.getElementsByClassName('bp5-disabled')).toHaveLength(0);
6262
const asyncSelect = result.getElementsByClassName('Select')[0] as HTMLElement;

frontend/static/__tests__/filters/ColumnFilter-numeric-test.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,19 @@ describe('ColumnFilter numeric tests', () => {
3939
});
4040
expect(result.getElementsByClassName('numeric-filter-inputs').length).toBe(1);
4141
await act(async () => {
42-
await fireEvent.click(result.getElementsByClassName('ico-check-box-outline-blank')[0]);
42+
await fireEvent.click(screen.getByText('Missing'));
4343
});
44-
expect(spies.saveSpy).toHaveBeenLastCalledWith('1', 'col1', { type: 'int', missing: true });
44+
expect(spies.saveSpy).toHaveBeenLastCalledWith('1', 'col1', { type: 'int', missing: true, populated: false });
4545
expect(result.getElementsByClassName('Select__control--is-disabled').length).toBeGreaterThan(0);
4646
await act(async () => {
47-
await fireEvent.click(result.getElementsByClassName('ico-check-box')[0]);
47+
await fireEvent.click(screen.getByText('Missing'));
4848
});
49+
expect(spies.saveSpy).toHaveBeenLastCalledWith('1', 'col1', { type: 'int', missing: false, populated: false });
4950
expect(result.getElementsByClassName('Select__control--is-disabled').length).toBe(0);
51+
await act(async () => {
52+
await fireEvent.click(screen.getByText('Populated'));
53+
});
54+
expect(spies.saveSpy).toHaveBeenLastCalledWith('1', 'col1', { type: 'int', missing: false, populated: true });
5055
await selectOption(result.getElementsByClassName('Select')[0] as HTMLElement, 1);
5156
await act(async () => {
5257
await tick(300);
@@ -81,13 +86,13 @@ describe('ColumnFilter numeric tests', () => {
8186
});
8287
expect(result.getElementsByClassName('numeric-filter-inputs').length).toBe(1);
8388
await act(async () => {
84-
await fireEvent.click(result.getElementsByClassName('ico-check-box-outline-blank')[0]);
89+
await fireEvent.click(screen.getByText('Missing'));
8590
});
8691
const numericInput = (idx = 0): HTMLInputElement =>
8792
result.getElementsByClassName('numeric-filter')[idx] as HTMLInputElement;
8893
expect(numericInput().disabled).toBe(true);
8994
await act(async () => {
90-
await fireEvent.click(result.getElementsByClassName('ico-check-box')[0]);
95+
await fireEvent.click(screen.getByText('Missing'));
9196
});
9297
expect(numericInput().disabled).toBe(false);
9398
await act(async () => {

frontend/static/__tests__/filters/ColumnFilter-string-async-test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ describe('ColumnFilter string tests', () => {
4545
it('ColumnFilter string rendering', async () => {
4646
expect(result.getElementsByClassName('string-filter-inputs').length).toBe(1);
4747
await act(async () => {
48-
await fireEvent.click(result.getElementsByClassName('ico-check-box-outline-blank')[0]);
48+
await fireEvent.click(screen.getByText('Missing'));
4949
});
5050
await act(async () => {
51-
await fireEvent.click(result.getElementsByClassName('ico-check-box')[0]);
51+
await fireEvent.click(screen.getByText('Missing'));
5252
});
5353
expect(result.getElementsByClassName('bp5-disabled')).toHaveLength(0);
5454
const asyncSelect = result.getElementsByClassName('Select')[1] as HTMLElement;

0 commit comments

Comments
 (0)