Skip to content

Commit 676313a

Browse files
committed
Fix autocomplete alias resolution for qualified tables
1 parent 71f98c5 commit 676313a

File tree

4 files changed

+224
-1
lines changed

4 files changed

+224
-1
lines changed

sqlit/domains/query/completion/core.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,23 @@ def build_alias_map(refs: list[TableRef], known_tables: list[str]) -> dict[str,
426426
Only includes aliases for tables that exist in known_tables.
427427
"""
428428
known_lower = {t.lower() for t in known_tables}
429+
430+
def strip_identifier(value: str) -> str:
431+
value = value.strip()
432+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"`", '"'}:
433+
return value[1:-1]
434+
if value.startswith("[") and value.endswith("]") and len(value) >= 2:
435+
return value[1:-1]
436+
return value
437+
438+
# Add unqualified table names for qualified entries (db.schema.table, schema.table, etc.).
439+
for table in known_tables:
440+
parts = [strip_identifier(part) for part in table.split(".")]
441+
# Take the last non-empty component as the unqualified table name.
442+
for part in reversed(parts):
443+
if part:
444+
known_lower.add(part.lower())
445+
break
429446
alias_map: dict[str, str] = {}
430447

431448
for ref in refs:

sqlit/domains/query/ui/mixins/autocomplete_suggestions.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,27 @@ def _build_alias_map(self: AutocompleteMixinHost, text: str) -> dict[str, str]:
3737
table_refs = extract_table_refs(text)
3838
known_tables = set(t.lower() for t in self._schema_cache.get("tables", []))
3939
known_tables.update(t.lower() for t in self._schema_cache.get("views", []))
40+
table_metadata = getattr(self, "_table_metadata", {}) or {}
41+
known_metadata = {key.lower() for key in table_metadata.keys()}
4042

4143
alias_map: dict[str, str] = {}
44+
45+
def is_known(name: str) -> bool:
46+
lowered = name.lower()
47+
return lowered in known_tables or lowered in known_metadata
48+
4249
for ref in table_refs:
43-
if ref.alias and ref.name.lower() in known_tables:
50+
if not ref.alias:
51+
continue
52+
53+
# Prefer schema-qualified name when present (db.table or schema.table).
54+
if ref.schema:
55+
qualified = f"{ref.schema}.{ref.name}"
56+
if is_known(qualified):
57+
alias_map[ref.alias.lower()] = qualified
58+
continue
59+
60+
if is_known(ref.name):
4461
alias_map[ref.alias.lower()] = ref.name
4562
return alias_map
4663

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Integration tests for MySQL autocomplete column suggestions."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import tempfile
7+
8+
import pytest
9+
10+
from sqlit.domains.shell.app.main import SSMSTUI
11+
from tests.fixtures.mysql import MYSQL_HOST, MYSQL_PASSWORD, MYSQL_PORT, MYSQL_USER
12+
from tests.helpers import ConnectionConfig
13+
from tests.integration.browsing_base import wait_for_condition
14+
15+
16+
EXTRA_DB_NAME = os.environ.get("MYSQL_AUTOCOMPLETE_EXTRA_DB", "test_sqlit_autocomplete_other")
17+
18+
19+
@pytest.fixture
20+
def temp_config_dir():
21+
"""Create a temporary config directory for tests."""
22+
with tempfile.TemporaryDirectory(prefix="sqlit-test-") as tmpdir:
23+
original = os.environ.get("SQLIT_CONFIG_DIR")
24+
os.environ["SQLIT_CONFIG_DIR"] = tmpdir
25+
yield tmpdir
26+
if original:
27+
os.environ["SQLIT_CONFIG_DIR"] = original
28+
else:
29+
os.environ.pop("SQLIT_CONFIG_DIR", None)
30+
31+
32+
@pytest.fixture
33+
def mysql_extra_db(mysql_server_ready: bool):
34+
"""Create a second user database to force multi-db autocomplete mode."""
35+
if not mysql_server_ready:
36+
pytest.skip("MySQL is not available")
37+
38+
try:
39+
import pymysql
40+
except ImportError:
41+
pytest.skip("PyMySQL is not installed")
42+
43+
conn = pymysql.connect(
44+
host=MYSQL_HOST,
45+
port=MYSQL_PORT,
46+
user=MYSQL_USER,
47+
password=MYSQL_PASSWORD,
48+
database="mysql",
49+
connect_timeout=10,
50+
)
51+
cursor = conn.cursor()
52+
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{EXTRA_DB_NAME}`")
53+
conn.commit()
54+
conn.close()
55+
56+
yield EXTRA_DB_NAME
57+
58+
conn = pymysql.connect(
59+
host=MYSQL_HOST,
60+
port=MYSQL_PORT,
61+
user=MYSQL_USER,
62+
password=MYSQL_PASSWORD,
63+
database="mysql",
64+
connect_timeout=10,
65+
)
66+
cursor = conn.cursor()
67+
cursor.execute(f"DROP DATABASE IF EXISTS `{EXTRA_DB_NAME}`")
68+
conn.commit()
69+
conn.close()
70+
71+
72+
def _set_cursor_to_end(app: SSMSTUI) -> None:
73+
text = app.query_input.text
74+
lines = text.split("\n")
75+
app.query_input.cursor_location = (len(lines) - 1, len(lines[-1]) if lines else 0)
76+
77+
78+
@pytest.mark.asyncio
79+
async def test_mysql_alias_autocomplete_shows_columns_multi_db(
80+
mysql_server_ready: bool,
81+
mysql_db: str,
82+
mysql_extra_db: str, # noqa: ARG001 - fixture used for setup/teardown
83+
temp_config_dir: str, # noqa: ARG001 - fixture used for isolation
84+
) -> None:
85+
"""Autocomplete should resolve alias columns even with multi-db schema cache."""
86+
if not mysql_server_ready:
87+
pytest.skip("MySQL is not available")
88+
89+
config = ConnectionConfig(
90+
name="test-mysql-autocomplete",
91+
db_type="mysql",
92+
server=MYSQL_HOST,
93+
port=str(MYSQL_PORT),
94+
database="",
95+
username=MYSQL_USER,
96+
password=MYSQL_PASSWORD,
97+
)
98+
99+
app = SSMSTUI()
100+
async with app.run_test(size=(120, 40)) as pilot:
101+
await pilot.pause(0.1)
102+
103+
app.connections = [config]
104+
app.refresh_tree()
105+
await pilot.pause(0.1)
106+
107+
await wait_for_condition(
108+
pilot,
109+
lambda: len(app.object_tree.root.children) > 0,
110+
timeout_seconds=5.0,
111+
description="tree to be populated with connections",
112+
)
113+
114+
app.connect_to_server(config)
115+
await wait_for_condition(
116+
pilot,
117+
lambda: app.current_connection is not None,
118+
timeout_seconds=15.0,
119+
description="connection to be established",
120+
)
121+
122+
# Ensure schema cache is available for autocomplete in headless tests.
123+
load_schema_async = getattr(app, "_load_schema_cache_async", None)
124+
if callable(load_schema_async):
125+
await load_schema_async()
126+
127+
await wait_for_condition(
128+
pilot,
129+
lambda: (
130+
"test_users" in getattr(app, "_table_metadata", {})
131+
or f"{mysql_db}.test_users" in getattr(app, "_table_metadata", {})
132+
),
133+
timeout_seconds=20.0,
134+
description="table metadata to be loaded",
135+
)
136+
137+
app.query_input.text = f"SELECT * FROM {mysql_db}.test_users u WHERE u."
138+
_set_cursor_to_end(app)
139+
app._trigger_autocomplete(app.query_input)
140+
141+
expected = {"id", "name", "email"}
142+
143+
await wait_for_condition(
144+
pilot,
145+
lambda: bool(app._schema_cache.get("columns", {}).get("test_users")),
146+
timeout_seconds=10.0,
147+
description="autocomplete columns to load",
148+
)
149+
150+
text = app.query_input.text
151+
cursor_loc = app.query_input.cursor_location
152+
cursor_pos = app._location_to_offset(text, cursor_loc)
153+
suggestions = app._get_autocomplete_suggestions(text, cursor_pos)
154+
assert expected.issubset({item.lower() for item in suggestions})
155+
assert "Loading..." not in suggestions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Tests for autocomplete alias handling in multi-database schemas."""
2+
3+
from __future__ import annotations
4+
5+
from sqlit.domains.query.ui.mixins.autocomplete_suggestions import AutocompleteSuggestionsMixin
6+
7+
8+
class _DummyAutocompleteHost(AutocompleteSuggestionsMixin):
9+
def __init__(self, schema_cache: dict) -> None:
10+
self._schema_cache = schema_cache
11+
self._columns_loading = set()
12+
self.loaded: list[str] = []
13+
14+
def _load_columns_for_table(self, table_name: str, *, allow_retry: bool = True) -> None: # noqa: ARG002
15+
self.loaded.append(table_name)
16+
17+
18+
def test_alias_column_loads_table_when_tables_are_qualified() -> None:
19+
"""Alias-based column lookup should resolve to the table name, not the alias."""
20+
host = _DummyAutocompleteHost(
21+
{
22+
"tables": ["db.test_users"],
23+
"views": [],
24+
"columns": {},
25+
"procedures": [],
26+
}
27+
)
28+
host._table_metadata = {"test_users": ("", "test_users", "db")}
29+
30+
sql = "SELECT * FROM test_users u WHERE u."
31+
suggestions = host._get_autocomplete_suggestions(sql, len(sql))
32+
33+
assert suggestions == ["Loading..."]
34+
assert host.loaded == ["test_users"]

0 commit comments

Comments
 (0)