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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Features
--------
* Preserve the query as metadata when saving to Parquet with `.>`.
* Make completion candidate match order configurable.
* Make approximate-matching thresholds configurable.


2.23.0 (2026/09/09)
Expand Down
5 changes: 5 additions & 0 deletions mycli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ def __init__(
indexed_column_suffix=indexed_column_suffix,
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,
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')
else 0.67,
)
for error in self.completer.completion_config_errors:
self.echo(error, err=True, fg='red')
Expand Down
3 changes: 3 additions & 0 deletions mycli/client_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def refresh_completions(self, reset: bool = False) -> list[SQLResult]:
"config_property_names": self.completer.config_property_names,
'frecency_provider': self.completer.frecency_provider,
'completion_match_order': self.completer.completion_match_order,
'rapidfuzz_min_length': self.completer.rapidfuzz_min_length,
'rapidfuzz_length_coverage': self.completer.rapidfuzz_length_coverage,
'rapidfuzz_score_cutoff': self.completer.rapidfuzz_score_cutoff,
},
)

Expand Down
13 changes: 13 additions & 0 deletions mycli/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ smart_completion = True
# * rapidfuzz - true approximate matching, _ie_ autcorrect
completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz

# 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

# Minimum rapidfuzz candidate length as a fraction of the input length.
# Empty uses 0.67. Lower is more liberal. Set to 0 or less to suppress
# the length filter.
rapidfuzz_length_coverage = 0.67

# Minimum rapidfuzz similarity score (0-100). Lower is more liberal.
# Empty uses the default of 75.
rapidfuzz_score_cutoff = 75

# Text appended to indexed column names in the completion menu. This text is
# not inserted into the query. Leave empty to disable the marker. Quote values
# containing spaces, commas, or comment characters.
Expand Down
12 changes: 9 additions & 3 deletions mycli/sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -956,12 +956,18 @@ def __init__(
config_property_names: Collection[str] = (),
frecency_provider: Callable[[], Mapping[str, float]] | None = None,
completion_match_order: Collection[str] = (),
rapidfuzz_min_length: int = 4,
rapidfuzz_length_coverage: float = 0.67,
rapidfuzz_score_cutoff: float = 75.0,
) -> None:
super(self.__class__, self).__init__()
self.smart_completion = smart_completion
self.indexed_column_suffix = indexed_column_suffix
self.config_property_names = tuple(sorted(config_property_names))
self.frecency_provider = frecency_provider
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.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())
Expand Down Expand Up @@ -1355,7 +1361,7 @@ def find_fuzzy_matches(
if fuzziness is not None:
completions.append((item, fuzziness))

if len(text) >= 4:
if len(text) >= self.rapidfuzz_min_length:
rapidfuzz_matches = rapidfuzz.process.extract(
text,
collection,
Expand All @@ -1364,11 +1370,11 @@ def find_fuzzy_matches(
# because underscores are valuable info
processor=rapidfuzz.utils.default_process,
limit=20,
score_cutoff=75,
score_cutoff=self.rapidfuzz_score_cutoff,
)
existing = {c[0]: index for index, c in enumerate(completions)}
for item, _score, _type in rapidfuzz_matches:
if len(item) < len(text) / 1.5:
if len(item) < len(text) * self.rapidfuzz_length_coverage:
continue
if item in existing:
index = existing[item]
Expand Down
13 changes: 13 additions & 0 deletions test/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ smart_completion = True
# * rapidfuzz - true approximate matching, _ie_ autcorrect
completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz

# 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

# Minimum rapidfuzz candidate length as a fraction of the input length.
# Empty uses 0.67. Lower is more liberal. Set to 0 or less to suppress
# the length filter.
rapidfuzz_length_coverage = 0.67

# Minimum rapidfuzz similarity score (0-100). Lower is more liberal.
# Empty uses the default of 75.
rapidfuzz_score_cutoff = 75

# Text appended to indexed column names in the completion menu. This text is
# not inserted into the query. Leave empty to disable the marker. Quote values
# containing spaces, commas, or comment characters.
Expand Down
37 changes: 37 additions & 0 deletions test/pytests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,43 @@ 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, 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)
setting = f'rapidfuzz_min_length = {value}\n' if value is not None else ''
myclirc = write_myclirc(tmp_path, f'[main]\n{setting}')

cli = MyCli(myclirc=myclirc)

assert cli.completer.rapidfuzz_min_length == expected


@pytest.mark.parametrize(('value', 'expected'), [(None, 0.67), ('', 0.67), ('0.5', 0.5), ('0', 0.0), ('-1', 0.0)])
def test_init_configures_rapidfuzz_length_coverage(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str | None, expected: float
) -> None:
patch_constructor_side_effects(monkeypatch)
setting = f'rapidfuzz_length_coverage = {value}\n' if value is not None else ''
myclirc = write_myclirc(tmp_path, f'[main]\n{setting}')

cli = MyCli(myclirc=myclirc)

assert cli.completer.rapidfuzz_length_coverage == expected


@pytest.mark.parametrize(('value', 'expected'), [(None, 75.0), ('', 75.0), ('82.5', 82.5), ('0', 0.0), ('-1', 0.0), ('101', 100.0)])
def test_init_configures_rapidfuzz_score_cutoff(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str | None, expected: float
) -> None:
patch_constructor_side_effects(monkeypatch)
setting = f'rapidfuzz_score_cutoff = {value}\n' if value is not None else ''
myclirc = write_myclirc(tmp_path, f'[main]\n{setting}')

cli = MyCli(myclirc=myclirc)

assert cli.completer.rapidfuzz_score_cutoff == expected


@pytest.mark.parametrize('value', ['', 'rapidfuzz'])
def test_init_reads_empty_or_single_match_order(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str) -> None:
patch_constructor_side_effects(monkeypatch)
Expand Down
12 changes: 12 additions & 0 deletions test/pytests/test_client_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ def make_refresh_cli() -> tuple[Any, dict[str, Any]]:
config_property_names=('main.show_warnings',),
frecency_provider=lambda: {'select': 1.0},
completion_match_order=('under_words', 'regex'),
rapidfuzz_min_length=2,
rapidfuzz_length_coverage=0.5,
rapidfuzz_score_cutoff=82.5,
keyword_casing='upper',
indexed_column_suffix=' [indexed]',
set_dbname=lambda dbname: state['set_dbname_calls'].append(dbname),
Expand Down Expand Up @@ -76,6 +79,9 @@ def test_refresh_completions_passes_options_to_refresher() -> None:
'config_property_names': ('main.show_warnings',),
'frecency_provider': cli.completer.frecency_provider,
'completion_match_order': ('under_words', 'regex'),
'rapidfuzz_min_length': 2,
'rapidfuzz_length_coverage': 0.5,
'rapidfuzz_score_cutoff': 82.5,
},
)
]
Expand Down Expand Up @@ -107,6 +113,9 @@ def test_refresh_completions_updates_dbname_when_reset() -> None:
config_property_names=(),
frecency_provider=None,
completion_match_order=(),
rapidfuzz_min_length=4,
rapidfuzz_length_coverage=0.67,
rapidfuzz_score_cutoff=75.0,
keyword_casing='lower',
indexed_column_suffix='*',
set_dbname=lambda dbname: set_dbname_calls.append(dbname),
Expand All @@ -129,6 +138,9 @@ def test_refresh_completions_uses_lock_when_reset() -> None:
config_property_names=(),
frecency_provider=None,
completion_match_order=(),
rapidfuzz_min_length=4,
rapidfuzz_length_coverage=0.67,
rapidfuzz_score_cutoff=75.0,
keyword_casing='lower',
indexed_column_suffix='*',
set_dbname=lambda dbname: None,
Expand Down
40 changes: 40 additions & 0 deletions test/pytests/test_sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,46 @@ def fail_extract(*args, **kwargs):
assert matches == []


@pytest.mark.parametrize(
('minimum', 'text', 'should_run'),
[(2, 's', False), (2, 'se', True), (6, 'selec', False), (6, 'select', True), (0, '', True)],
)
def test_find_fuzzy_matches_uses_configured_minimum(monkeypatch: pytest.MonkeyPatch, minimum: int, text: str, should_run: bool) -> None:
calls: list[str] = []

def extract(query: str, *args: object, **kwargs: object) -> list[tuple[str, int, int]]:
calls.append(query)
return [('SELECT', 100, 0)]

monkeypatch.setattr(mycli.sqlcompleter.rapidfuzz.process, 'extract', extract)
completer = SQLCompleter(rapidfuzz_min_length=minimum, completion_match_order=('rapidfuzz',))

matches = completer.find_fuzzy_matches(text, text, ['SELECT'])

assert calls == ([text] if should_run else [])
assert (('SELECT', Fuzziness.RAPIDFUZZ) in matches) == should_run


@pytest.mark.parametrize(('coverage', 'accepted'), [(0.0, True), (0.5, True), (0.75, True), (0.76, False), (1.0, False)])
def test_find_fuzzy_matches_filters_candidate_length(monkeypatch: pytest.MonkeyPatch, coverage: float, accepted: bool) -> None:
monkeypatch.setattr(SQLCompleter, 'find_fuzzy_match', lambda *args: None)
monkeypatch.setattr(mycli.sqlcompleter.rapidfuzz.process, 'extract', lambda *args, **kwargs: [('abc', 90, 0)])
completer = SQLCompleter(rapidfuzz_length_coverage=coverage)

matches = completer.find_fuzzy_matches('abcd', 'abcd', ['abc'])

assert matches == ([('abc', Fuzziness.RAPIDFUZZ)] if accepted else [])


@pytest.mark.parametrize(('cutoff', 'accepted'), [(0.0, True), (75.0, True), (75.1, False), (100.0, False)])
def test_find_fuzzy_matches_applies_score_cutoff(cutoff: float, accepted: bool) -> None:
completer = SQLCompleter(rapidfuzz_score_cutoff=cutoff)

matches = completer.find_fuzzy_matches('abcd', 'abcd', ['abce'])

assert matches == ([('abce', Fuzziness.RAPIDFUZZ)] if accepted else [])


def test_find_fuzzy_matches_appends_rapidfuzz_results_and_skips_duplicates(monkeypatch) -> None:
monkeypatch.setattr(
SQLCompleter,
Expand Down
Loading