diff --git a/changelog.md b/changelog.md index 0f2be987..e523a61c 100644 --- a/changelog.md +++ b/changelog.md @@ -5,7 +5,8 @@ Features -------- * Preserve the query as metadata when saving to Parquet with `.>`. * Make completion candidate match order configurable. -* Make approximate-matching thresholds configurable. +* Make approximate-matching completion thresholds configurable. +* Make regex matching completion thresholds configurable. 2.23.0 (2026/09/09) diff --git a/mycli/client.py b/mycli/client.py index 8ea067a0..5b65aa28 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -214,6 +214,7 @@ def __init__( config_property_names=get_config_property_names(self.config), completion_match_order=c['main'].as_list('completion_match_order') if 'completion_match_order' in c['main'] else (), rapidfuzz_min_length=c['main'].as_int('rapidfuzz_min_length') if c['main'].get('rapidfuzz_min_length') else 4, + regex_match_distance=c['main'].as_int('regex_match_distance') if c['main'].get('regex_match_distance') else 3, rapidfuzz_score_cutoff=c['main'].as_float('rapidfuzz_score_cutoff') if c['main'].get('rapidfuzz_score_cutoff') else 75.0, rapidfuzz_length_coverage=c['main'].as_float('rapidfuzz_length_coverage') if c['main'].get('rapidfuzz_length_coverage') diff --git a/mycli/client_query.py b/mycli/client_query.py index 0a12281a..fdfce5ba 100644 --- a/mycli/client_query.py +++ b/mycli/client_query.py @@ -64,6 +64,7 @@ def refresh_completions(self, reset: bool = False) -> list[SQLResult]: 'rapidfuzz_min_length': self.completer.rapidfuzz_min_length, 'rapidfuzz_length_coverage': self.completer.rapidfuzz_length_coverage, 'rapidfuzz_score_cutoff': self.completer.rapidfuzz_score_cutoff, + 'regex_match_distance': self.completer.regex_match_distance, }, ) diff --git a/mycli/myclirc b/mycli/myclirc index 211c5862..22acb567 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -20,6 +20,11 @@ smart_completion = True # * rapidfuzz - true approximate matching, _ie_ autcorrect completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz +# Maximum intervening span length between input characters for regex completion +# candidate matches. Empty uses the default of 3. Higher is more liberal, but +# carries a perforance penalty. +regex_match_distance = 3 + # Minimum input characters before using rapidfuzz approximate matching. # Empty uses the default of 4. Set to 0 or less to remove the minimum. rapidfuzz_min_length = 4 diff --git a/mycli/sqlcompleter.py b/mycli/sqlcompleter.py index d9f72eab..752211b8 100644 --- a/mycli/sqlcompleter.py +++ b/mycli/sqlcompleter.py @@ -959,6 +959,7 @@ def __init__( rapidfuzz_min_length: int = 4, rapidfuzz_length_coverage: float = 0.67, rapidfuzz_score_cutoff: float = 75.0, + regex_match_distance: int = 3, ) -> None: super(self.__class__, self).__init__() self.smart_completion = smart_completion @@ -968,6 +969,7 @@ def __init__( self.rapidfuzz_min_length = max(0, rapidfuzz_min_length) self.rapidfuzz_length_coverage = max(0.0, rapidfuzz_length_coverage) self.rapidfuzz_score_cutoff = max(0.0, min(100.0, rapidfuzz_score_cutoff)) + self.regex_match_distance = max(0, regex_match_distance) self.completion_config_errors: list[str] = [] default_order = tuple(category.name.lower() for category in Fuzziness) order = tuple(name.strip().lower() for name in completion_match_order if name.strip()) @@ -1351,7 +1353,7 @@ def find_fuzzy_matches( collection: Collection[Any], ) -> list[tuple[str, int]]: completions: list[tuple[str, int]] = [] - regex = '.{0,3}?'.join(map(re.escape, text)) + regex = f'.{{0,{self.regex_match_distance}}}?'.join(map(re.escape, text)) pattern = re.compile(f'({regex})') under_words_text = [x for x in text.split('_') if x] case_words_text = re.split(_CASE_CHANGE_PAT, last) diff --git a/test/myclirc b/test/myclirc index a9b4dff1..cb66041f 100644 --- a/test/myclirc +++ b/test/myclirc @@ -20,6 +20,11 @@ smart_completion = True # * rapidfuzz - true approximate matching, _ie_ autcorrect completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz +# Maximum intervening span length between input characters for regex completion +# candidate matches. Empty uses the default of 3. Higher is more liberal, but +# carries a perforance penalty. +regex_match_distance = 3 + # Minimum input characters before using rapidfuzz approximate matching. # Empty uses the default of 4. Set to 0 or less to remove the minimum. rapidfuzz_min_length = 4 diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index c2955987..f8dc38a8 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -36,6 +36,17 @@ def test_init_configures_completion_ranking(monkeypatch: pytest.MonkeyPatch, tmp assert cli.completer.completion_match_order == ('camel_case', 'under_words', 'perfect', 'regex', 'slash_words', 'rapidfuzz') +@pytest.mark.parametrize(('value', 'expected'), [(None, 3), ('', 3), ('5', 5), ('0', 0), ('-1', 0)]) +def test_init_configures_regex_match_distance(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str | None, expected: int) -> None: + patch_constructor_side_effects(monkeypatch) + setting = f'regex_match_distance = {value}\n' if value is not None else '' + myclirc = write_myclirc(tmp_path, f'[main]\n{setting}') + + cli = MyCli(myclirc=myclirc) + + assert cli.completer.regex_match_distance == expected + + @pytest.mark.parametrize(('value', 'expected'), [(None, 4), ('', 4), ('2', 2), ('0', 0), ('-1', 0)]) def test_init_configures_rapidfuzz_min_length(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str | None, expected: int) -> None: patch_constructor_side_effects(monkeypatch) diff --git a/test/pytests/test_client_query.py b/test/pytests/test_client_query.py index f442bf22..1fe3cb57 100644 --- a/test/pytests/test_client_query.py +++ b/test/pytests/test_client_query.py @@ -32,6 +32,7 @@ def make_refresh_cli() -> tuple[Any, dict[str, Any]]: rapidfuzz_min_length=2, rapidfuzz_length_coverage=0.5, rapidfuzz_score_cutoff=82.5, + regex_match_distance=5, keyword_casing='upper', indexed_column_suffix=' [indexed]', set_dbname=lambda dbname: state['set_dbname_calls'].append(dbname), @@ -82,6 +83,7 @@ def test_refresh_completions_passes_options_to_refresher() -> None: 'rapidfuzz_min_length': 2, 'rapidfuzz_length_coverage': 0.5, 'rapidfuzz_score_cutoff': 82.5, + 'regex_match_distance': 5, }, ) ] @@ -116,6 +118,7 @@ def test_refresh_completions_updates_dbname_when_reset() -> None: rapidfuzz_min_length=4, rapidfuzz_length_coverage=0.67, rapidfuzz_score_cutoff=75.0, + regex_match_distance=3, keyword_casing='lower', indexed_column_suffix='*', set_dbname=lambda dbname: set_dbname_calls.append(dbname), @@ -141,6 +144,7 @@ def test_refresh_completions_uses_lock_when_reset() -> None: rapidfuzz_min_length=4, rapidfuzz_length_coverage=0.67, rapidfuzz_score_cutoff=75.0, + regex_match_distance=3, keyword_casing='lower', indexed_column_suffix='*', set_dbname=lambda dbname: None, diff --git a/test/pytests/test_sqlcompleter.py b/test/pytests/test_sqlcompleter.py index 54c96494..d30b93f8 100644 --- a/test/pytests/test_sqlcompleter.py +++ b/test/pytests/test_sqlcompleter.py @@ -134,6 +134,18 @@ def test_find_fuzzy_matches_collects_item_level_matches(monkeypatch) -> None: ] +@pytest.mark.parametrize( + ('distance', 'candidate', 'accepted'), + [(0, 'zab', True), (0, 'axb', False), (1, 'axb', True), (1, 'axxb', False), (5, 'axxxxxb', True), (5, 'axxxxxxb', False)], +) +def test_find_fuzzy_matches_uses_regex_match_distance(distance: int, candidate: str, accepted: bool) -> None: + completer = SQLCompleter(regex_match_distance=distance) + + matches = completer.find_fuzzy_matches('ab', 'ab', [candidate]) + + assert matches == ([(candidate, Fuzziness.REGEX)] if accepted else []) + + def test_find_fuzzy_matches_skips_rapidfuzz_for_short_text(monkeypatch) -> None: monkeypatch.setattr(SQLCompleter, 'find_fuzzy_match', lambda *args, **kwargs: None)