diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index 785ff915..ccbd94d6 100644 --- a/lib/features/workspace/grid_filter_engine.dart +++ b/lib/features/workspace/grid_filter_engine.dart @@ -176,7 +176,10 @@ class _NotNode extends _FilterAstNode { } class _PredicateNode extends _FilterAstNode { - const _PredicateNode({ + /// Everything that depends only on the query text is worked out here, once: + /// the operator, the lower-cased target, its number, the normalised `IN` set + /// and the `BETWEEN` bounds. [evaluate] runs once per row (#1550). + _PredicateNode({ required this.colIndex, required this.op, required this.targetValue, @@ -184,7 +187,12 @@ class _PredicateNode extends _FilterAstNode { this.betweenMin, this.betweenMax, this.likeRegex, - }); + }) : _upperOp = op.toUpperCase().trim(), + _lowerTarget = targetValue.toLowerCase(), + _numTarget = double.tryParse(targetValue.trim()), + _inSet = {for (final v in inValues) v.toLowerCase().trim()}, + _numMin = betweenMin == null ? null : double.tryParse(betweenMin.trim()), + _numMax = betweenMax == null ? null : double.tryParse(betweenMax.trim()); final int colIndex; final String op; @@ -197,72 +205,56 @@ class _PredicateNode extends _FilterAstNode { /// per-row scan does not rebuild the same RegExp for every row. final RegExp? likeRegex; + final String _upperOp; + final String _lowerTarget; + final double? _numTarget; + final Set _inSet; + final double? _numMin; + final double? _numMax; + @override bool evaluate(List row, List lowerColumns) { if (colIndex < 0 || colIndex >= row.length) return false; final cellValue = row[colIndex]; - final isNull = cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; - final upperOp = op.toUpperCase().trim(); - - // IS NULL / IS NOT NULL - if (upperOp == 'IS NULL') { - return isNull; - } - if (upperOp == 'IS NOT NULL') { - return !isNull; - } - - // IN / NOT IN - if (upperOp == 'IN') { - final lowerCell = cellValue.toLowerCase().trim(); - return inValues.any((v) => v.toLowerCase().trim() == lowerCell); - } - if (upperOp == 'NOT IN') { - final lowerCell = cellValue.toLowerCase().trim(); - return !inValues.any((v) => v.toLowerCase().trim() == lowerCell); - } - - // BETWEEN x AND y - if (upperOp == 'BETWEEN' && betweenMin != null && betweenMax != null) { - final numCell = double.tryParse(cellValue.trim()); - final numMin = double.tryParse(betweenMin!.trim()); - final numMax = double.tryParse(betweenMax!.trim()); - if (numCell != null && numMin != null && numMax != null) { - return numCell >= numMin && numCell <= numMax; - } - return cellValue.compareTo(betweenMin!) >= 0 && cellValue.compareTo(betweenMax!) <= 0; - } - // LIKE / NOT LIKE - if (upperOp == 'LIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true); - return regex.hasMatch(cellValue); - } - if (upperOp == 'NOT LIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true); - return !regex.hasMatch(cellValue); - } - - // ILIKE / NOT ILIKE - if (upperOp == 'ILIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false); - return regex.hasMatch(cellValue); - } - if (upperOp == 'NOT ILIKE') { - final regex = - likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false); - return !regex.hasMatch(cellValue); + switch (_upperOp) { + case 'IS NULL': + return _isNull(cellValue); + case 'IS NOT NULL': + return !_isNull(cellValue); + case 'IN': + return _inSet.contains(cellValue.toLowerCase().trim()); + case 'NOT IN': + return !_inSet.contains(cellValue.toLowerCase().trim()); + case 'BETWEEN': + final min = betweenMin; + final max = betweenMax; + if (min != null && max != null) { + final numCell = double.tryParse(cellValue.trim()); + final numMin = _numMin; + final numMax = _numMax; + if (numCell != null && numMin != null && numMax != null) { + return numCell >= numMin && numCell <= numMax; + } + return cellValue.compareTo(min) >= 0 && cellValue.compareTo(max) <= 0; + } + case 'LIKE': + return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) + .hasMatch(cellValue); + case 'NOT LIKE': + return !(likeRegex ?? _likeToRegExp(targetValue, caseSensitive: true)) + .hasMatch(cellValue); + case 'ILIKE': + return (likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false)) + .hasMatch(cellValue); + case 'NOT ILIKE': + return !(likeRegex ?? _likeToRegExp(targetValue, caseSensitive: false)) + .hasMatch(cellValue); } - final lowerCell = cellValue.toLowerCase(); - final lowerTarget = targetValue.toLowerCase(); - // Numeric comparison if both values can be parsed as numbers final numCell = double.tryParse(cellValue.trim()); - final numTarget = double.tryParse(targetValue.trim()); + final numTarget = _numTarget; if (numCell != null && numTarget != null) { switch (op) { @@ -285,6 +277,8 @@ class _PredicateNode extends _FilterAstNode { } // String / Lexicographic comparison + final lowerCell = cellValue.toLowerCase(); + final lowerTarget = _lowerTarget; switch (op) { case '=': case '==': @@ -307,6 +301,9 @@ class _PredicateNode extends _FilterAstNode { } } + static bool _isNull(String cellValue) => + cellValue == 'NULL' || cellValue == 'null' || cellValue.isEmpty; + static RegExp _likeToRegExp(String pattern, {required bool caseSensitive}) { final buffer = StringBuffer('^'); for (var i = 0; i < pattern.length; i++) { diff --git a/test/features/workspace/grid_filter_predicate_test.dart b/test/features/workspace/grid_filter_predicate_test.dart new file mode 100644 index 00000000..f8af9ac6 --- /dev/null +++ b/test/features/workspace/grid_filter_predicate_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/grid_filter_engine.dart'; + +void main() { + // A predicate resolves its operator, target, IN set and BETWEEN bounds once + // when it is built (#1550); these pin the per-row results. + group('GridFilterEngine predicates', () { + const columns = ['id', 'name', 'price', 'note']; + const rows = [ + ['1', 'Anna', '10', 'x'], + ['2', 'Andy', '20.0', 'NULL'], + ['3', 'Bob', '30', ''], + ['4', 'Notebook', '40', 'y'], + ['5', 'Android', '50', 'z'], + ]; + + List run(String filter) => GridFilterEngine.filterRowIndices( + filterText: filter, + columns: columns, + rows: rows, + ); + + test('IN compares trimmed, case-insensitive values', () { + expect(run("name IN (' ANNA ', bob)"), [0, 2]); + expect(run('name IN (anDY, "NOTEBOOK")'), [1, 3]); + expect(run("name NOT IN (' anna ', BOB)"), [1, 3, 4]); + expect(run('price IN (10, 30)'), [0, 2]); // "20.0" is not in the list + }); + + test('BETWEEN is numeric when all three sides are numbers', () { + expect(run('price BETWEEN 15 AND 40'), [1, 2, 3]); + expect(run('price BETWEEN 20 AND 20'), [1]); + }); + + test('BETWEEN falls back to a case-sensitive string range', () { + expect(run("name BETWEEN 'Anna' AND 'Bob'"), [0, 2]); + }); + + test('numeric comparison needs both sides to parse', () { + expect(run('price = 20'), [1]); // "20.0" equals 20 + expect(run('price > 25'), [2, 3, 4]); + expect(run('price <= 20'), [0, 1]); + expect(run('price = abc'), isEmpty); + }); + + test('string comparison and contains ignore case', () { + expect(run('name = ANNA'), [0]); + expect(run('name : ND'), [1, 4]); + expect(run('name != bob'), [0, 1, 3, 4]); + expect(run('name > Bob'), [3]); // "notebook" > "bob" + }); + + test('IS NULL treats NULL, null and empty as null', () { + expect(run('note IS NULL'), [1, 2]); + expect(run('note IS NOT NULL'), [0, 3, 4]); + }); + }); +}