Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 56 additions & 59 deletions lib/features/workspace/grid_filter_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,23 @@ 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,
this.inValues = const [],
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;
Expand All @@ -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<String> _inSet;
final double? _numMin;
final double? _numMax;

@override
bool evaluate(List<String> row, List<String> 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) {
Expand All @@ -285,6 +277,8 @@ class _PredicateNode extends _FilterAstNode {
}

// String / Lexicographic comparison
final lowerCell = cellValue.toLowerCase();
final lowerTarget = _lowerTarget;
switch (op) {
case '=':
case '==':
Expand All @@ -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++) {
Expand Down
58 changes: 58 additions & 0 deletions test/features/workspace/grid_filter_predicate_test.dart
Original file line number Diff line number Diff line change
@@ -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<int> 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]);
});
});
}
Loading