diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index f53608e..e07bc1f 100644 --- a/lib/features/workspace/grid_filter_engine.dart +++ b/lib/features/workspace/grid_filter_engine.dart @@ -125,6 +125,7 @@ class _PredicateNode extends _FilterAstNode { this.inValues = const [], this.betweenMin, this.betweenMax, + this.likeRegex, }); final int colIndex; @@ -134,6 +135,10 @@ class _PredicateNode extends _FilterAstNode { final String? betweenMin; final String? betweenMax; + /// Compiled once at parse time for LIKE / ILIKE (and NOT variants), so the + /// per-row scan does not rebuild the same RegExp for every row. + final RegExp? likeRegex; + @override bool evaluate(List row, List lowerColumns) { if (colIndex < 0 || colIndex >= row.length) return false; @@ -172,21 +177,25 @@ class _PredicateNode extends _FilterAstNode { // LIKE / NOT LIKE if (upperOp == 'LIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: true); + final regex = + likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true); return regex.hasMatch(cellValue); } if (upperOp == 'NOT LIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: true); + final regex = + likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true); return !regex.hasMatch(cellValue); } // ILIKE / NOT ILIKE if (upperOp == 'ILIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: false); + final regex = + likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false); return regex.hasMatch(cellValue); } if (upperOp == 'NOT ILIKE') { - final regex = _likeToRegExp(targetValue, caseSensitive: false); + final regex = + likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false); return !regex.hasMatch(cellValue); } @@ -497,6 +506,10 @@ abstract final class _FilterLexer { colIndex: colIdx, op: op, targetValue: patVal.value, + likeRegex: _PredicateNode._likeToRegExp( + patVal.value, + caseSensitive: likeType == 'LIKE', + ), ), consumedChars: (lPos - start) + patVal.consumedChars, ); diff --git a/test/features/workspace/data_grid_engines_test.dart b/test/features/workspace/data_grid_engines_test.dart index 9711cb2..ae733be 100644 --- a/test/features/workspace/data_grid_engines_test.dart +++ b/test/features/workspace/data_grid_engines_test.dart @@ -101,6 +101,66 @@ void main() { expect(resIlike, equals([1])); }); + test('LIKE is case-sensitive while ILIKE ignores case', () { + List run(String f) => GridFilterEngine.filterRowIndices( + filterText: f, + columns: columns, + rows: rows, + ); + expect(run("status LIKE 'act%'"), isEmpty); + expect(run("status ILIKE 'act%'"), equals([0, 2])); + }); + + test('NOT LIKE / NOT ILIKE invert the match', () { + List run(String f) => GridFilterEngine.filterRowIndices( + filterText: f, + columns: columns, + rows: rows, + ); + expect(run("status NOT LIKE 'ACT%'"), equals([1, 3])); + expect(run("status NOT ILIKE '%pend%'"), equals([0, 2, 3])); + expect(run("status NOT LIKE 'act%'"), equals([0, 1, 2, 3])); + }); + + test('LIKE supports _ wildcard and treats regex metacharacters literally', + () { + final specialRows = [ + ['a.c'], + ['abc'], + ['a+c'], + ['(x)'], + ]; + List run(String f) => GridFilterEngine.filterRowIndices( + filterText: f, + columns: ['v'], + rows: specialRows, + ); + expect(run("v LIKE 'a_c'"), equals([0, 1, 2])); + expect(run("v LIKE 'a.c'"), equals([0])); + expect(run("v LIKE 'a+c'"), equals([2])); + expect(run("v LIKE '(x)'"), equals([3])); + }); + + test('LIKE inside AND / OR gives the same result on every row', () { + final many = [ + for (var i = 0; i < 2000; i++) ['$i', i.isEven ? 'ACTIVE' : 'PENDING'], + ]; + final res = GridFilterEngine.filterRowIndices( + filterText: "status LIKE 'ACT%' AND id LIKE '%9' OR status ILIKE 'pen%' AND id LIKE '1%'", + columns: ['id', 'status'], + rows: many, + ); + final expected = [ + for (var i = 0; i < many.length; i++) + if ((many[i][1].startsWith('ACT') && many[i][0].endsWith('9')) || + (many[i][1].toLowerCase().startsWith('pen') && + many[i][0].startsWith('1'))) + i, + ]; + expect(res, equals(expected)); + expect(res, isNotEmpty); + }); + test('filters with IN and NOT IN list of literals', () { final resIn = GridFilterEngine.filterRowIndices( filterText: "status IN ('PENDING', 'CANCELLED')",