Skip to content

Commit 40956a8

Browse files
feat(filter): support LIKE, ILIKE, IN, IS NULL, BETWEEN, and escaped quotes in filter engine (#569)
Closes #569
1 parent 4edfaca commit 40956a8

2 files changed

Lines changed: 272 additions & 10 deletions

File tree

‎lib/features/main_screen/grid_filter_engine.dart‎

Lines changed: 197 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/// Client-side filter engine for Data Grid.
22
///
33
/// Evaluates complex multi-clause expressions with AND / OR / NOT, parentheses,
4-
/// column predicates (`col = val`, `col > 10`), and free-text substring search.
4+
/// column predicates (`col = val`, `col > 10`, `col LIKE '%test%'`, `col IN ('a', 'b')`,
5+
/// `col IS NULL`, `col BETWEEN x AND y`), and free-text substring search.
56
abstract final class GridFilterEngine {
67
/// Evaluates [filterText] against [rows] with respect to [columns].
78
/// Returns the list of matching row indices.
@@ -121,16 +122,74 @@ class _PredicateNode extends _FilterAstNode {
121122
required this.colIndex,
122123
required this.op,
123124
required this.targetValue,
125+
this.inValues = const [],
126+
this.betweenMin,
127+
this.betweenMax,
124128
});
125129

126130
final int colIndex;
127131
final String op;
128132
final String targetValue;
133+
final List<String> inValues;
134+
final String? betweenMin;
135+
final String? betweenMax;
129136

130137
@override
131138
bool evaluate(List<String> row, List<String> lowerColumns) {
132139
if (colIndex < 0 || colIndex >= row.length) return false;
133140
final cellValue = row[colIndex];
141+
final isNull = cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty;
142+
final upperOp = op.toUpperCase().trim();
143+
144+
// IS NULL / IS NOT NULL
145+
if (upperOp == 'IS NULL') {
146+
return isNull;
147+
}
148+
if (upperOp == 'IS NOT NULL') {
149+
return !isNull;
150+
}
151+
152+
// IN / NOT IN
153+
if (upperOp == 'IN') {
154+
final lowerCell = cellValue.toLowerCase().trim();
155+
return inValues.any((v) => v.toLowerCase().trim() == lowerCell);
156+
}
157+
if (upperOp == 'NOT IN') {
158+
final lowerCell = cellValue.toLowerCase().trim();
159+
return !inValues.any((v) => v.toLowerCase().trim() == lowerCell);
160+
}
161+
162+
// BETWEEN x AND y
163+
if (upperOp == 'BETWEEN' && betweenMin != null && betweenMax != null) {
164+
final numCell = double.tryParse(cellValue.trim());
165+
final numMin = double.tryParse(betweenMin!.trim());
166+
final numMax = double.tryParse(betweenMax!.trim());
167+
if (numCell != null && numMin != null && numMax != null) {
168+
return numCell >= numMin && numCell <= numMax;
169+
}
170+
return cellValue.compareTo(betweenMin!) >= 0 && cellValue.compareTo(betweenMax!) <= 0;
171+
}
172+
173+
// LIKE / NOT LIKE
174+
if (upperOp == 'LIKE') {
175+
final regex = _likeToRegExp(targetValue, caseSensitive: true);
176+
return regex.hasMatch(cellValue);
177+
}
178+
if (upperOp == 'NOT LIKE') {
179+
final regex = _likeToRegExp(targetValue, caseSensitive: true);
180+
return !regex.hasMatch(cellValue);
181+
}
182+
183+
// ILIKE / NOT ILIKE
184+
if (upperOp == 'ILIKE') {
185+
final regex = _likeToRegExp(targetValue, caseSensitive: false);
186+
return regex.hasMatch(cellValue);
187+
}
188+
if (upperOp == 'NOT ILIKE') {
189+
final regex = _likeToRegExp(targetValue, caseSensitive: false);
190+
return !regex.hasMatch(cellValue);
191+
}
192+
134193
final lowerCell = cellValue.toLowerCase();
135194
final lowerTarget = targetValue.toLowerCase();
136195

@@ -180,6 +239,22 @@ class _PredicateNode extends _FilterAstNode {
180239
return lowerCell.contains(lowerTarget);
181240
}
182241
}
242+
243+
static RegExp _likeToRegExp(String pattern, {required bool caseSensitive}) {
244+
final buffer = StringBuffer('^');
245+
for (var i = 0; i < pattern.length; i++) {
246+
final char = pattern[i];
247+
if (char == '%') {
248+
buffer.write('.*');
249+
} else if (char == '_') {
250+
buffer.write('.');
251+
} else {
252+
buffer.write(RegExp.escape(char));
253+
}
254+
}
255+
buffer.write(r'$');
256+
return RegExp(buffer.toString(), caseSensitive: caseSensitive);
257+
}
183258
}
184259

185260
class _FreeTextNode extends _FilterAstNode {
@@ -231,6 +306,15 @@ abstract final class _FilterLexer {
231306
continue;
232307
}
233308

309+
// Check for extended predicates with keywords (IS NULL, IS NOT NULL, LIKE, ILIKE, IN, BETWEEN)
310+
final remaining = input.substring(i);
311+
final kwPredicate = _tryMatchKeywordPredicate(remaining, lowerColumns);
312+
if (kwPredicate != null) {
313+
tokens.add(_FilterToken(_TokenType.predicate, predicate: kwPredicate.node));
314+
i += kwPredicate.consumedChars;
315+
continue;
316+
}
317+
234318
// Parentheses
235319
if (input[i] == '(') {
236320
tokens.add(const _FilterToken(_TokenType.lparen, value: '('));
@@ -253,14 +337,21 @@ abstract final class _FilterLexer {
253337
if (input[i] == '\'' || input[i] == '"') {
254338
final quote = input[i];
255339
i++;
256-
while (i < input.length && input[i] != quote) {
340+
while (i < input.length) {
257341
if (input[i] == '\\' && i + 1 < input.length) {
258342
i += 2;
343+
} else if (input[i] == quote) {
344+
if (i + 1 < input.length && input[i + 1] == quote) {
345+
// SQL-style doubled quote escape: ''
346+
i += 2;
347+
} else {
348+
i++; // closing quote
349+
break;
350+
}
259351
} else {
260352
i++;
261353
}
262354
}
263-
if (i < input.length) i++; // consume closing quote
264355
} else {
265356
i++;
266357
}
@@ -292,13 +383,11 @@ abstract final class _FilterLexer {
292383
}
293384

294385
// If word is just a column name and the NEXT word is an operator (e.g. "amount", ">", "100")
295-
// or "status", "=", "'ACTIVE'"
296386
final colIdx = lowerColumns.indexOf(word.toLowerCase());
297387
if (colIdx != -1) {
298-
// Peek ahead for operator and value
299-
final remaining = input.substring(i).trimLeft();
388+
final rem = input.substring(i).trimLeft();
300389
final opMatch = RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)\s*([^\s()]+)')
301-
.firstMatch(remaining);
390+
.firstMatch(rem);
302391
if (opMatch != null) {
303392
final op = opMatch.group(1)!;
304393
var val = opMatch.group(2)!;
@@ -319,14 +408,109 @@ abstract final class _FilterLexer {
319408
}
320409
}
321410

322-
// Strip quotes from free text if present
323411
word = _stripQuotes(word);
324412
tokens.add(_FilterToken(_TokenType.text, value: word));
325413
}
326414

327415
return tokens;
328416
}
329417

418+
static ({_PredicateNode node, int consumedChars})? _tryMatchKeywordPredicate(
419+
String remaining,
420+
List<String> lowerColumns,
421+
) {
422+
// 1. IS NULL / IS NOT NULL (e.g. "status IS NULL", "email IS NOT NULL")
423+
final isNullMatch = RegExp(r'^([a-zA-Z_]\w*)\s+IS\s+(NOT\s+)?NULL\b', caseSensitive: false)
424+
.firstMatch(remaining);
425+
if (isNullMatch != null) {
426+
final colName = isNullMatch.group(1)!.toLowerCase();
427+
final colIdx = lowerColumns.indexOf(colName);
428+
if (colIdx != -1) {
429+
final isNot = isNullMatch.group(2) != null;
430+
return (
431+
node: _PredicateNode(
432+
colIndex: colIdx,
433+
op: isNot ? 'IS NOT NULL' : 'IS NULL',
434+
targetValue: '',
435+
),
436+
consumedChars: isNullMatch.group(0)!.length,
437+
);
438+
}
439+
}
440+
441+
// 2. IN / NOT IN (e.g. "status IN ('ACTIVE', 'PENDING')", "id NOT IN (1, 2, 3)")
442+
final inMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?IN\s*\(([^)]+)\)', caseSensitive: false)
443+
.firstMatch(remaining);
444+
if (inMatch != null) {
445+
final colName = inMatch.group(1)!.toLowerCase();
446+
final colIdx = lowerColumns.indexOf(colName);
447+
if (colIdx != -1) {
448+
final isNot = inMatch.group(2) != null;
449+
final listStr = inMatch.group(3)!;
450+
final items = listStr
451+
.split(',')
452+
.map((s) => _stripQuotes(s.trim()))
453+
.where((s) => s.isNotEmpty)
454+
.toList();
455+
return (
456+
node: _PredicateNode(
457+
colIndex: colIdx,
458+
op: isNot ? 'NOT IN' : 'IN',
459+
targetValue: '',
460+
inValues: items,
461+
),
462+
consumedChars: inMatch.group(0)!.length,
463+
);
464+
}
465+
}
466+
467+
// 3. BETWEEN x AND y (e.g. "amount BETWEEN 10 AND 100")
468+
final betweenMatch = RegExp(r'^([a-zA-Z_]\w*)\s+BETWEEN\s+([^\s]+)\s+AND\s+([^\s()]+)', caseSensitive: false)
469+
.firstMatch(remaining);
470+
if (betweenMatch != null) {
471+
final colName = betweenMatch.group(1)!.toLowerCase();
472+
final colIdx = lowerColumns.indexOf(colName);
473+
if (colIdx != -1) {
474+
final minVal = _stripQuotes(betweenMatch.group(2)!.trim());
475+
final maxVal = _stripQuotes(betweenMatch.group(3)!.trim());
476+
return (
477+
node: _PredicateNode(
478+
colIndex: colIdx,
479+
op: 'BETWEEN',
480+
targetValue: '',
481+
betweenMin: minVal,
482+
betweenMax: maxVal,
483+
),
484+
consumedChars: betweenMatch.group(0)!.length,
485+
);
486+
}
487+
}
488+
489+
// 4. LIKE / ILIKE / NOT LIKE / NOT ILIKE (e.g. "name LIKE '%John%'", "email ILIKE '%.org'")
490+
final likeMatch = RegExp(r'^([a-zA-Z_]\w*)\s+(NOT\s+)?(ILIKE|LIKE)\s+([^\s()]+)', caseSensitive: false)
491+
.firstMatch(remaining);
492+
if (likeMatch != null) {
493+
final colName = likeMatch.group(1)!.toLowerCase();
494+
final colIdx = lowerColumns.indexOf(colName);
495+
if (colIdx != -1) {
496+
final isNot = likeMatch.group(2) != null;
497+
final likeType = likeMatch.group(3)!.toUpperCase();
498+
final pattern = _stripQuotes(likeMatch.group(4)!.trim());
499+
final op = isNot ? 'NOT $likeType' : likeType;
500+
return (
501+
node: _PredicateNode(
502+
colIndex: colIdx,
503+
op: op,
504+
targetValue: pattern,
505+
),
506+
consumedChars: likeMatch.group(0)!.length,
507+
);
508+
}
509+
}
510+
511+
return null;
512+
}
513+
330514
static _PredicateNode? _tryExtractPredicate(
331515
String token,
332516
List<String> lowerColumns,
@@ -354,7 +538,11 @@ abstract final class _FilterLexer {
354538
if ((s.startsWith("'") && s.endsWith("'")) ||
355539
(s.startsWith('"') && s.endsWith('"'))) {
356540
if (s.length >= 2) {
357-
return s.substring(1, s.length - 1).replaceAll(r"\'", "'").replaceAll(r'\"', '"');
541+
return s
542+
.substring(1, s.length - 1)
543+
.replaceAll("''", "'")
544+
.replaceAll(r"\'", "'")
545+
.replaceAll(r'\"', '"');
358546
}
359547
}
360548
return s;
@@ -396,7 +584,6 @@ class _FilterParser {
396584
bool _isImplicitAnd() {
397585
if (_pos >= tokens.length) return false;
398586
final type = tokens[_pos].type;
399-
// Two terms or predicates in a row without explicit OR are treated as implicit AND
400587
return type == _TokenType.predicate ||
401588
type == _TokenType.text ||
402589
type == _TokenType.lparen ||

‎test/features/main_screen/data_grid_engines_test.dart‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,81 @@ void main() {
8484
);
8585
expect(res, equals([0, 1]));
8686
});
87+
88+
test('filters with LIKE and ILIKE wildcards', () {
89+
final resLike = GridFilterEngine.filterRowIndices(
90+
filterText: "status LIKE 'ACT%'",
91+
columns: columns,
92+
rows: rows,
93+
);
94+
expect(resLike, equals([0, 2]));
95+
96+
final resIlike = GridFilterEngine.filterRowIndices(
97+
filterText: "status ILIKE '%pend%'",
98+
columns: columns,
99+
rows: rows,
100+
);
101+
expect(resIlike, equals([1]));
102+
});
103+
104+
test('filters with IN and NOT IN list of literals', () {
105+
final resIn = GridFilterEngine.filterRowIndices(
106+
filterText: "status IN ('PENDING', 'CANCELLED')",
107+
columns: columns,
108+
rows: rows,
109+
);
110+
expect(resIn, equals([1, 3]));
111+
112+
final resNotIn = GridFilterEngine.filterRowIndices(
113+
filterText: "status NOT IN ('ACTIVE')",
114+
columns: columns,
115+
rows: rows,
116+
);
117+
expect(resNotIn, equals([1, 3]));
118+
});
119+
120+
test('filters with IS NULL and IS NOT NULL', () {
121+
final rowsWithNull = [
122+
['1', 'ACTIVE', '100'],
123+
['2', 'NULL', '200'],
124+
['3', '', '300'],
125+
];
126+
final resNull = GridFilterEngine.filterRowIndices(
127+
filterText: 'status IS NULL',
128+
columns: columns,
129+
rows: rowsWithNull,
130+
);
131+
expect(resNull, equals([1, 2]));
132+
133+
final resNotNull = GridFilterEngine.filterRowIndices(
134+
filterText: 'status IS NOT NULL',
135+
columns: columns,
136+
rows: rowsWithNull,
137+
);
138+
expect(resNotNull, equals([0]));
139+
});
140+
141+
test('filters with BETWEEN range', () {
142+
final res = GridFilterEngine.filterRowIndices(
143+
filterText: 'amount BETWEEN 50 AND 200',
144+
columns: columns,
145+
rows: rows,
146+
);
147+
expect(res, equals([0, 1]));
148+
});
149+
150+
test('handles escaped quotes inside string literals', () {
151+
final rowsWithQuotes = [
152+
['1', "O'Connor", '100'],
153+
['2', 'Smith', '200'],
154+
];
155+
final res = GridFilterEngine.filterRowIndices(
156+
filterText: "status = 'O''Connor'",
157+
columns: columns,
158+
rows: rowsWithQuotes,
159+
);
160+
expect(res, equals([0]));
161+
});
87162
});
88163

89164
group('GridSelectionCalcEngine', () {

0 commit comments

Comments
 (0)