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.
56abstract 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
185260class _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 ||
0 commit comments