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
51 changes: 26 additions & 25 deletions lib/features/workspace/grid_filter_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,21 @@ class _FilterToken {
}

abstract final class _FilterLexer {
// Compiled once. They have no `^`: they are tried with `matchAsPrefix` at an
// offset, so the rest of the input is never copied (#1527, #1512).
static final _andRe = RegExp(r'AND\b|&&', caseSensitive: false);
static final _orRe = RegExp(r'OR\b|\|\|', caseSensitive: false);
static final _notRe = RegExp(r'NOT\b|!', caseSensitive: false);
static final _isNullRe =
RegExp(r'IS\s+(NOT\s+)?NULL\b', caseSensitive: false);
static final _inRe = RegExp(r'(NOT\s+)?IN\s*\(', caseSensitive: false);
static final _betweenRe = RegExp(r'BETWEEN\s+', caseSensitive: false);
static final _andKeywordRe = RegExp(r'AND\s+', caseSensitive: false);
static final _likeRe =
RegExp(r'(NOT\s+)?(ILIKE|LIKE)\s+', caseSensitive: false);
static final _comparisonRe = RegExp(r'>=|<=|!=|<>|==|=|>|<|:');
static final _identifierRe = RegExp(r'[a-zA-Z_]\w*');

static List<_FilterToken> tokenize(String input, List<String> lowerColumns) {
final tokens = <_FilterToken>[];
var i = 0;
Expand All @@ -386,25 +401,21 @@ abstract final class _FilterLexer {
}

// Check logical operators: AND, &&, OR, ||, NOT, !
final rem = input.substring(i);
final andMatch =
RegExp(r'^(AND\b|&&)', caseSensitive: false).firstMatch(rem);
final andMatch = _andRe.matchAsPrefix(input, i);
if (andMatch != null) {
tokens.add(const _FilterToken(_TokenType.and, value: 'AND'));
i += andMatch.group(0)!.length;
continue;
}

final orMatch =
RegExp(r'^(OR\b|\|\|)', caseSensitive: false).firstMatch(rem);
final orMatch = _orRe.matchAsPrefix(input, i);
if (orMatch != null) {
tokens.add(const _FilterToken(_TokenType.or, value: 'OR'));
i += orMatch.group(0)!.length;
continue;
}

final notMatch =
RegExp(r'^(NOT\b|!)', caseSensitive: false).firstMatch(rem);
final notMatch = _notRe.matchAsPrefix(input, i);
if (notMatch != null) {
tokens.add(const _FilterToken(_TokenType.not, value: 'NOT'));
i += notMatch.group(0)!.length;
Expand Down Expand Up @@ -455,12 +466,10 @@ abstract final class _FilterLexer {

final hadSpace = p > spaceStart;
final canMatchKeyword = hadSpace || isQuotedCol;
final rem = input.substring(p);

// 1. IS NULL / IS NOT NULL
if (canMatchKeyword) {
final isNullMatch =
RegExp(r'^IS\s+(NOT\s+)?NULL\b', caseSensitive: false).firstMatch(rem);
final isNullMatch = _isNullRe.matchAsPrefix(input, p);
if (isNullMatch != null) {
final isNot = isNullMatch.group(1) != null;
return (
Expand All @@ -476,8 +485,7 @@ abstract final class _FilterLexer {

// 2. IN / NOT IN
if (canMatchKeyword) {
final inMatch =
RegExp(r'^(NOT\s+)?IN\s*\(', caseSensitive: false).firstMatch(rem);
final inMatch = _inRe.matchAsPrefix(input, p);
if (inMatch != null) {
final isNot = inMatch.group(1) != null;
var inPos = p + inMatch.group(0)!.length;
Expand Down Expand Up @@ -512,8 +520,7 @@ abstract final class _FilterLexer {

// 3. BETWEEN x AND y
if (canMatchKeyword) {
final betweenMatch =
RegExp(r'^BETWEEN\s+', caseSensitive: false).firstMatch(rem);
final betweenMatch = _betweenRe.matchAsPrefix(input, p);
if (betweenMatch != null) {
var bPos = p + betweenMatch.group(0)!.length;
final minVal = _parseValue(input, bPos);
Expand All @@ -523,9 +530,7 @@ abstract final class _FilterLexer {
while (andPos < input.length && input[andPos].trim().isEmpty) {
andPos++;
}
final andRem = input.substring(andPos);
final andMatch =
RegExp(r'^AND\s+', caseSensitive: false).firstMatch(andRem);
final andMatch = _andKeywordRe.matchAsPrefix(input, andPos);
if (andMatch != null) {
var maxPos = andPos + andMatch.group(0)!.length;
final maxVal = _parseValue(input, maxPos);
Expand All @@ -549,9 +554,7 @@ abstract final class _FilterLexer {

// 4. LIKE / ILIKE / NOT LIKE / NOT ILIKE
if (canMatchKeyword) {
final likeMatch =
RegExp(r'^(NOT\s+)?(ILIKE|LIKE)\s+', caseSensitive: false)
.firstMatch(rem);
final likeMatch = _likeRe.matchAsPrefix(input, p);
if (likeMatch != null) {
final isNot = likeMatch.group(1) != null;
final likeType = likeMatch.group(2)!.toUpperCase();
Expand All @@ -576,10 +579,9 @@ abstract final class _FilterLexer {
}

// 5. Comparison operators: >=, <=, !=, <>, ==, =, >, <, :
final opMatch =
RegExp(r'^(>=|<=|!=|<>|==|=|>|<|:)').firstMatch(rem);
final opMatch = _comparisonRe.matchAsPrefix(input, p);
if (opMatch != null) {
final op = opMatch.group(1)!;
final op = opMatch.group(0)!;
final oPos = p + opMatch.group(0)!.length;
final valParsed = _parseValue(input, oPos);
if (valParsed != null) {
Expand Down Expand Up @@ -638,8 +640,7 @@ abstract final class _FilterLexer {
}

// Unquoted column: [a-zA-Z_]\w*
final rem = input.substring(i);
final match = RegExp(r'^[a-zA-Z_]\w*').firstMatch(rem);
final match = _identifierRe.matchAsPrefix(input, i);
if (match != null) {
final name = match.group(0)!;
final colIdx = lowerColumns.indexOf(name.toLowerCase());
Expand Down
70 changes: 70 additions & 0 deletions test/features/workspace/grid_filter_tokenizer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:querya_desktop/features/workspace/grid_filter_engine.dart';

void main() {
// The lexer reads the input by offset with precompiled patterns (#1527,
// #1512); these pin the keyword boundaries and operators it has to keep.
group('GridFilterEngine tokenizer', () {
const columns = ['id', 'name', 'price', 'note'];
const rows = [
['1', 'Anna', '10', 'x'],
['2', 'Andy', '20', '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('AND / OR / NOT are keywords only on a word boundary', () {
expect(run('android'), [4]); // not AND + roid
expect(run('note'), [3]); // not NOT + e
expect(run('name = Bob AND price > 10'), [2]);
expect(run('Bob and price > 10'), [2]);
expect(run('price < 20 or name = Bob'), [0, 2]);
});

test('symbolic operators && || ! work without spaces around names', () {
expect(run('price>=30 && name:o'), [2, 3, 4]);
expect(run('price<20 || name = Bob'), [0, 2]);
expect(run('!name = Bob'), [0, 1, 3, 4]);
expect(run('NOT name = Bob'), [0, 1, 3, 4]);
});

test('comparison operators without spaces', () {
expect(run('price>=30'), [2, 3, 4]);
expect(run('price<>30'), [0, 1, 3, 4]);
expect(run('price!=30'), [0, 1, 3, 4]);
expect(run('name:an'), [0, 1, 4]);
});

test('IS NULL / IS NOT NULL in any case', () {
expect(run('note IS NULL'), [1, 2]);
expect(run('note is not null'), [0, 3, 4]);
expect(run('name IS NULL'), isEmpty);
});

test('IN, NOT IN and BETWEEN', () {
expect(run("name IN ('anna', \"BOB\")"), [0, 2]);
expect(run('name NOT IN (Anna, Bob)'), [1, 3, 4]);
expect(run('name IN(Anna)'), [0]);
expect(run('price BETWEEN 15 AND 40'), [1, 2, 3]);
expect(run('price between 15 and 40'), [1, 2, 3]);
});

test('LIKE, ILIKE and NOT LIKE', () {
expect(run("name LIKE 'An%'"), [0, 1, 4]);
expect(run("name ILIKE 'an%'"), [0, 1, 4]);
expect(run("name LIKE 'an%'"), isEmpty);
expect(run("name NOT LIKE 'An%'"), [2, 3]);
});

test('parentheses group predicates', () {
expect(run('(price<20 OR price>40) AND note != y'), [0, 4]);
});
});
}
Loading