From f19b07a88aa8a0f48c0eddeb2b9b5fd94e519d48 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:35:58 +0300 Subject: [PATCH] perf(grid): read the filter input by offset with precompiled patterns GridFilterEngine's lexer ran on every keystroke in the filter bar and, per token, copied the rest of the input (input.substring(i) / substring(p)) and compiled about ten RegExp objects (AND, OR, NOT, IS NULL, IN, BETWEEN, LIKE, comparison operators, identifiers). The patterns are static final fields without a ^ anchor and are tried with matchAsPrefix(input, offset), so nothing is copied or compiled while typing. Semantics are unchanged; tests pin keyword boundaries (android is not AND, note is not NOT), operators without spaces, IS NULL, IN, BETWEEN, LIKE and parentheses. Closes #1527 Closes #1512 --- .../workspace/grid_filter_engine.dart | 51 +++++++------- .../workspace/grid_filter_tokenizer_test.dart | 70 +++++++++++++++++++ 2 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 test/features/workspace/grid_filter_tokenizer_test.dart diff --git a/lib/features/workspace/grid_filter_engine.dart b/lib/features/workspace/grid_filter_engine.dart index 7f2794e7..785ff915 100644 --- a/lib/features/workspace/grid_filter_engine.dart +++ b/lib/features/workspace/grid_filter_engine.dart @@ -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 lowerColumns) { final tokens = <_FilterToken>[]; var i = 0; @@ -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; @@ -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 ( @@ -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; @@ -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); @@ -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); @@ -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(); @@ -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) { @@ -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()); diff --git a/test/features/workspace/grid_filter_tokenizer_test.dart b/test/features/workspace/grid_filter_tokenizer_test.dart new file mode 100644 index 00000000..ae4d903c --- /dev/null +++ b/test/features/workspace/grid_filter_tokenizer_test.dart @@ -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 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]); + }); + }); +}