diff --git a/lib/core/actions/querya_command.dart b/lib/core/actions/querya_command.dart index 2bc1c074..cbf120a4 100644 --- a/lib/core/actions/querya_command.dart +++ b/lib/core/actions/querya_command.dart @@ -38,20 +38,56 @@ class QueryaCommand { bool enabledIn(BuildContext context) => isEnabled?.call(context) ?? true; - /// Case-insensitive match on title, category, aliases, and the last id segment. - bool matches(String query) { + static final RegExp _whitespace = RegExp(r'\s+'); + + /// Lower-cased search text per command, built on first use. An [Expando] + /// because the class is `const` (no late fields) and commands are immutable. + static final Expando _haystacks = Expando('QueryaCommand'); + + /// [query] trimmed, lower-cased and split on whitespace; empty when the query + /// is blank. Split once per search, then pass to [matchesParts] for each + /// command (#1528). + static List queryParts(String query) { final q = query.trim().toLowerCase(); - if (q.isEmpty) return true; - final idTail = id.split('.').last.toLowerCase(); - final fields = [ - title.toLowerCase(), - id.toLowerCase(), - idTail, - if (category != null) category!.toLowerCase(), - ...aliases.map((a) => a.toLowerCase()), - ]; - return q.split(RegExp(r'\s+')).every( - (part) => fields.any((field) => field.contains(part)), - ); + return q.isEmpty ? const [] : q.split(_whitespace); + } + + String get _haystack => _haystacks[this] ??= _buildHaystack(); + + String _buildHaystack() { + final tail = id.substring(id.lastIndexOf('.') + 1); + final buffer = StringBuffer() + ..write(title.toLowerCase()) + ..write('\n') + ..write(id.toLowerCase()) + ..write('\n') + ..write(tail.toLowerCase()); + final category = this.category; + if (category != null) { + buffer + ..write('\n') + ..write(category.toLowerCase()); + } + for (final alias in aliases) { + buffer + ..write('\n') + ..write(alias.toLowerCase()); + } + return buffer.toString(); + } + + /// Case-insensitive match on title, category, aliases, and the last id segment. + bool matches(String query) => matchesParts(queryParts(query)); + + /// Every part of [parts] (from [queryParts]) occurs in the title, id, id + /// tail, category or an alias. A part has no whitespace, so it can never + /// match across the `\n` that separates those fields in the haystack. + bool matchesParts(List parts) { + if (parts.isEmpty) return true; + final haystack = _haystack; + for (final part in parts) { + if (!haystack.contains(part)) return false; + } + return true; } } diff --git a/lib/core/actions/querya_command_registry.dart b/lib/core/actions/querya_command_registry.dart index 902562d5..8ba9ac65 100644 --- a/lib/core/actions/querya_command_registry.dart +++ b/lib/core/actions/querya_command_registry.dart @@ -90,7 +90,11 @@ class QueryaCommandRegistry extends ChangeNotifier { if (query.trim().isEmpty) { return List.from(pool); } - return [for (final command in pool) if (command.matches(query)) command]; + final parts = QueryaCommand.queryParts(query); + return [ + for (final command in pool) + if (command.matchesParts(parts)) command, + ]; } @visibleForTesting diff --git a/lib/core/actions/querya_schema_object.dart b/lib/core/actions/querya_schema_object.dart index 26158a79..9d5407eb 100644 --- a/lib/core/actions/querya_schema_object.dart +++ b/lib/core/actions/querya_schema_object.dart @@ -124,6 +124,14 @@ class QueryaSchemaObject { String get searchHaystack => '$name $qualifiedName ${kind.name} $kindLabel ${database ?? ''} ${schema ?? ''}'; + static final Expando _lowerHaystacks = + Expando('QueryaSchemaObject'); + + /// [searchHaystack] lower-cased, built once per object: the switcher filters + /// thousands of objects on every keystroke (#1528). + String get searchHaystackLower => + _lowerHaystacks[this] ??= searchHaystack.toLowerCase(); + @override bool operator ==(Object other) => identical(this, other) || other is QueryaSchemaObject && other.id == id; @@ -149,7 +157,7 @@ List filterSchemaObjects( final parts = q.split(RegExp(r'\s+')); final hits = []; for (final object in objects) { - final hay = object.searchHaystack.toLowerCase(); + final hay = object.searchHaystackLower; if (parts.every(hay.contains)) { hits.add(object); if (hits.length >= limit) break; diff --git a/test/core/actions/querya_command_matches_test.dart b/test/core/actions/querya_command_matches_test.dart new file mode 100644 index 00000000..6f2de5ad --- /dev/null +++ b/test/core/actions/querya_command_matches_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/querya_command.dart'; +import 'package:querya_desktop/core/actions/querya_schema_object.dart'; + +void main() { + final command = QueryaCommand( + id: 'querya.sql.formatDocument', + title: 'Format SQL', + category: 'Editor', + aliases: const ['Beautify', 'pretty print'], + execute: (_) {}, + ); + + group('QueryaCommand.matches (#1528)', () { + test('blank query matches', () { + expect(command.matches(''), isTrue); + expect(command.matches(' '), isTrue); + }); + + test('title, id, id tail, category and aliases match, any case', () { + expect(command.matches('FORMAT'), isTrue); + expect(command.matches('querya.sql'), isTrue); + expect(command.matches('formatdocument'), isTrue); + expect(command.matches('editor'), isTrue); + expect(command.matches('beautify'), isTrue); + expect(command.matches('pretty'), isTrue); + }); + + test('every whitespace-separated part must match, in any field', () { + expect(command.matches('format editor'), isTrue); + expect(command.matches(' beautify sql '), isTrue); + expect(command.matches('format nothing'), isFalse); + }); + + test('a part does not match across two fields', () { + // "Format SQL" ends in "sql", the id tail starts with "format". + expect(command.matches('sqlformat'), isFalse); + expect(command.matches('sqlquerya'), isFalse); + }); + + test('matchesParts agrees with matches for pre-split queries', () { + for (final q in ['', 'format', 'Format EDITOR', 'zzz', 'pretty print']) { + expect( + command.matchesParts(QueryaCommand.queryParts(q)), + command.matches(q), + reason: q, + ); + } + }); + + test('an id without a dot uses the whole id as its tail', () { + final c = QueryaCommand(id: 'plain', title: 'T', execute: (_) {}); + expect(c.matches('plain'), isTrue); + }); + }); + + group('filterSchemaObjects haystack cache (#1528)', () { + test('the lower-cased haystack is stable and case-insensitive', () { + final o = QueryaSchemaObject.postgres( + database: 'Sales', + schema: 'Public', + name: 'Users', + kind: QueryaSchemaObjectKind.table, + ); + expect(identical(o.searchHaystackLower, o.searchHaystackLower), isTrue); + expect(o.searchHaystackLower, o.searchHaystack.toLowerCase()); + expect(filterSchemaObjects([o], 'PUBLIC.users'), [o]); + expect(filterSchemaObjects([o], 'orders'), isEmpty); + }); + }); +}