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
64 changes: 50 additions & 14 deletions lib/core/actions/querya_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _haystacks = Expando<String>('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<String> queryParts(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
final idTail = id.split('.').last.toLowerCase();
final fields = <String>[
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 <String>[] : 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<String> parts) {
if (parts.isEmpty) return true;
final haystack = _haystack;
for (final part in parts) {
if (!haystack.contains(part)) return false;
}
return true;
}
}
6 changes: 5 additions & 1 deletion lib/core/actions/querya_command_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ class QueryaCommandRegistry extends ChangeNotifier {
if (query.trim().isEmpty) {
return List<QueryaCommand>.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
Expand Down
10 changes: 9 additions & 1 deletion lib/core/actions/querya_schema_object.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ class QueryaSchemaObject {
String get searchHaystack =>
'$name $qualifiedName ${kind.name} $kindLabel ${database ?? ''} ${schema ?? ''}';

static final Expando<String> _lowerHaystacks =
Expando<String>('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;
Expand All @@ -149,7 +157,7 @@ List<QueryaSchemaObject> filterSchemaObjects(
final parts = q.split(RegExp(r'\s+'));
final hits = <QueryaSchemaObject>[];
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;
Expand Down
71 changes: 71 additions & 0 deletions test/core/actions/querya_command_matches_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading