Skip to content

Commit a55819e

Browse files
authored
Merge pull request #312 from Maxteabag/fix/issue-255-mssql-routine-completion
Add SQL Server routine autocomplete
2 parents 9616454 + ab8c374 commit a55819e

6 files changed

Lines changed: 796 additions & 17 deletions

File tree

‎sqlit/domains/connections/providers/adapters/base.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,44 @@ class ColumnInfo:
7272
is_primary_key: bool = False
7373

7474

75+
class RoutineInfo(str):
76+
"""String-compatible routine metadata used by autocomplete."""
77+
78+
schema: str
79+
database: str
80+
name: str
81+
routine_type: str
82+
return_type: str
83+
parameters: tuple[str, ...]
84+
85+
def __new__(
86+
cls,
87+
name: str,
88+
*,
89+
schema: str = "",
90+
database: str = "",
91+
routine_type: str = "PROCEDURE",
92+
return_type: str = "",
93+
parameters: tuple[str, ...] = (),
94+
) -> RoutineInfo:
95+
instance = super().__new__(cls, name)
96+
instance.name = name
97+
instance.schema = schema
98+
instance.database = database
99+
instance.routine_type = routine_type.upper()
100+
instance.return_type = return_type.upper()
101+
instance.parameters = parameters
102+
return instance
103+
104+
@property
105+
def qualified_name(self) -> str:
106+
return f"{self.schema}.{self}" if self.schema else str(self)
107+
108+
@property
109+
def is_table_valued(self) -> bool:
110+
return self.routine_type == "FUNCTION" and self.return_type == "TABLE"
111+
112+
75113
@dataclass
76114
class IndexInfo:
77115
"""Information about a database index."""

‎sqlit/domains/connections/providers/mssql/adapter.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,43 @@ def get_procedures(self, conn: Any, database: str | None = None) -> list[str]:
421421
)
422422
return [row[0] for row in cursor.fetchall()]
423423

424+
def get_completion_routines(
425+
self, conn: Any, database: str | None = None
426+
) -> list[Any]:
427+
"""Get procedures/functions with parameter metadata for autocomplete."""
428+
from sqlit.domains.connections.providers.adapters.base import RoutineInfo
429+
430+
cursor = self._get_cursor_for_database(conn, database)
431+
cursor.execute(
432+
"SELECT r.ROUTINE_SCHEMA, r.ROUTINE_NAME, r.ROUTINE_TYPE, r.DATA_TYPE, "
433+
"p.PARAMETER_NAME, p.ORDINAL_POSITION "
434+
"FROM INFORMATION_SCHEMA.ROUTINES r "
435+
"LEFT JOIN INFORMATION_SCHEMA.PARAMETERS p "
436+
"ON p.SPECIFIC_SCHEMA = r.SPECIFIC_SCHEMA "
437+
"AND p.SPECIFIC_NAME = r.SPECIFIC_NAME "
438+
"ORDER BY r.ROUTINE_SCHEMA, r.ROUTINE_NAME, p.ORDINAL_POSITION"
439+
)
440+
441+
grouped: dict[tuple[str, str, str, str], list[str]] = {}
442+
for schema, name, routine_type, return_type, parameter, ordinal in cursor.fetchall():
443+
key = (schema, name, routine_type, return_type or "")
444+
if parameter and (ordinal is None or ordinal > 0):
445+
grouped.setdefault(key, []).append(parameter)
446+
else:
447+
grouped.setdefault(key, [])
448+
449+
return [
450+
RoutineInfo(
451+
name,
452+
schema=schema,
453+
database=database or "",
454+
routine_type=routine_type,
455+
return_type=return_type,
456+
parameters=tuple(parameters),
457+
)
458+
for (schema, name, routine_type, return_type), parameters in grouped.items()
459+
]
460+
424461
def get_indexes(self, conn: Any, database: str | None = None) -> list[IndexInfo]:
425462
"""Get indexes from SQL Server."""
426463
cursor = self._get_cursor_for_database(conn, database)

‎sqlit/domains/query/completion/completion.py‎

Lines changed: 216 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
from __future__ import annotations
77

88
import re
9+
from collections.abc import Sequence
10+
11+
from sqlit.domains.query.app.multi_statement import split_statements
912

1013
from .alter_table import get_alter_table_completions
1114
from .core import (
@@ -27,6 +30,7 @@
2730
is_inside_string,
2831
remove_comments,
2932
remove_string_literals,
33+
split_identifier_parts,
3034
)
3135
from .create_index import get_create_index_completions
3236
from .create_table import get_create_table_completions
@@ -63,11 +67,60 @@ def get_context(sql: str, cursor_pos: int) -> list[Suggestion]:
6367
if before_cursor.rstrip().endswith(";"):
6468
return []
6569

70+
statements = split_statements(before_cursor)
71+
current_statement = statements[-1] if statements else before_cursor
72+
routine_context = remove_comments(remove_string_literals(current_statement))
73+
routine_context = re.split(
74+
r"\n(?=\s*(?:SELECT|INSERT|UPDATE|DELETE|MERGE|WITH|EXEC(?:UTE)?|"
75+
r"CREATE|ALTER|DROP)\b)",
76+
routine_context,
77+
flags=re.IGNORECASE,
78+
)[-1]
79+
routine_invocation = re.search(
80+
r"\b(?:EXEC|EXECUTE)\s+"
81+
r"(?:@\w+\s*=\s*)?"
82+
r"([^\s,=;]+)"
83+
r"(?P<suffix>.*)$",
84+
routine_context,
85+
re.IGNORECASE,
86+
)
87+
if routine_invocation and routine_invocation.group("suffix").startswith((" ", "\t", "\n")):
88+
return [
89+
Suggestion(
90+
type=SuggestionType.PARAMETER,
91+
table_scope=routine_invocation.group(1),
92+
)
93+
]
94+
if routine_invocation and not routine_invocation.group("suffix"):
95+
target = routine_invocation.group(1)
96+
parts = split_identifier_parts(target)
97+
if target.endswith("."):
98+
qualifiers = parts
99+
else:
100+
qualifiers = parts[:-1]
101+
scope = ".".join(qualifiers) if qualifiers else None
102+
return [Suggestion(type=SuggestionType.PROCEDURE, table_scope=scope)]
103+
104+
qualified_from = re.search(
105+
r"\b(?:FROM|JOIN|APPLY)\s+"
106+
r"(?:(?:\[[^\]]+\]|\w+)\.){1,2}\[?\w*$",
107+
before_cursor,
108+
re.IGNORECASE,
109+
)
110+
if qualified_from:
111+
return [Suggestion(type=SuggestionType.TABLE)]
112+
66113
# Check for table.column pattern (alias or table prefix)
67-
dot_match = re.search(r"(\w+)\.\w*$", before_cursor)
114+
dot_match = re.search(
115+
r"((?:\[[^\]]+\]|\w+)(?:\.(?:\[[^\]]+\]|\w+))*)\.\[?\w*$",
116+
before_cursor,
117+
)
68118
if dot_match:
69119
prefix = dot_match.group(1)
70-
return [Suggestion(type=SuggestionType.ALIAS_COLUMN, table_scope=prefix)]
120+
return [
121+
Suggestion(type=SuggestionType.ALIAS_COLUMN, table_scope=prefix),
122+
Suggestion(type=SuggestionType.FUNCTION, table_scope=prefix),
123+
]
71124

72125
# Try statement-specific handlers
73126
for handler in [get_insert_context, get_update_context, get_delete_context]:
@@ -162,6 +215,77 @@ def get_completions(
162215
"""
163216
before_cursor = sql[:cursor_pos]
164217
current_word = get_current_word(sql, cursor_pos)
218+
routines = procedures or []
219+
220+
def routine_display_name(routine: object) -> str:
221+
name = str(routine)
222+
identities = {
223+
(
224+
str(getattr(candidate, "database", "")).lower(),
225+
str(getattr(candidate, "schema", "")).lower(),
226+
)
227+
for candidate in routines
228+
if str(candidate).lower() == name.lower()
229+
}
230+
if len(identities) <= 1:
231+
return name
232+
parts = [
233+
str(getattr(routine, "database", "")),
234+
str(getattr(routine, "schema", "")),
235+
name,
236+
]
237+
return ".".join(part for part in parts if part)
238+
239+
def scoped_routine_names(
240+
candidates: Sequence[object],
241+
*,
242+
schema: str,
243+
database: str = "",
244+
) -> list[str]:
245+
filtered = [
246+
routine
247+
for routine in candidates
248+
if str(getattr(routine, "schema", "")).lower() == schema.lower()
249+
and (
250+
not database
251+
or str(getattr(routine, "database", "")).lower()
252+
== database.lower()
253+
)
254+
]
255+
counts: dict[str, int] = {}
256+
for routine in filtered:
257+
key = str(routine).lower()
258+
counts[key] = counts.get(key, 0) + 1
259+
return [
260+
str(routine)
261+
for routine in filtered
262+
if counts[str(routine).lower()] == 1
263+
]
264+
265+
procedure_names = [
266+
routine_display_name(routine)
267+
for routine in routines
268+
if getattr(routine, "routine_type", "PROCEDURE") == "PROCEDURE"
269+
]
270+
function_names = [
271+
routine_display_name(routine)
272+
for routine in routines
273+
if getattr(routine, "routine_type", "") == "FUNCTION"
274+
]
275+
legacy_routine_names = [
276+
str(routine) for routine in routines if not hasattr(routine, "routine_type")
277+
]
278+
function_routines = [
279+
routine
280+
for routine in routines
281+
if getattr(routine, "routine_type", "") == "FUNCTION"
282+
]
283+
table_function_routines = [
284+
routine
285+
for routine in function_routines
286+
if bool(getattr(routine, "is_table_valued", False))
287+
]
288+
table_function_names = [routine_display_name(routine) for routine in table_function_routines]
165289

166290
# Don't suggest if inside string literal
167291
if is_inside_string(before_cursor):
@@ -183,7 +307,12 @@ def get_completions(
183307
return fuzzy_match(current_word, result)
184308

185309
# Try DROP handler (doesn't need columns)
186-
drop_result = get_drop_completions(before_cursor, tables, procedures)
310+
drop_routines = (
311+
(function_names if include_functions else []) + legacy_routine_names
312+
if re.search(r"\bDROP\s+FUNCTION\b", before_cursor, re.IGNORECASE)
313+
else procedure_names
314+
)
315+
drop_result = get_drop_completions(before_cursor, tables, drop_routines)
187316
if drop_result is not None:
188317
return fuzzy_match(current_word, drop_result)
189318

@@ -258,11 +387,27 @@ def get_completions(
258387

259388
# Schema.table prefix → suggest tables after schema name
260389
# Pattern: FROM/JOIN schema. or schema.partial
261-
namespace_match = re.search(r"\b(?:FROM|JOIN)\s+(\w+)\.\w*$", clean_before, re.IGNORECASE)
390+
namespace_match = re.search(
391+
r"\b(?:FROM|JOIN|APPLY)\s+"
392+
r"(?:(?:\[([^\]]+)\]|(\w+))\.)?"
393+
r"(?:\[([^\]]+)\]|(\w+))\.\[?\w*$",
394+
clean_before,
395+
re.IGNORECASE,
396+
)
262397
if namespace_match:
263-
namespace = namespace_match.group(1)
398+
database = namespace_match.group(1) or namespace_match.group(2) or ""
399+
namespace = namespace_match.group(3) or namespace_match.group(4)
264400
names = get_names_for_namespace(namespace, tables)
265-
return fuzzy_match(current_word, names if names else tables)
401+
candidates = list(names if names else tables)
402+
if include_functions:
403+
candidates.extend(
404+
scoped_routine_names(
405+
table_function_routines,
406+
schema=namespace,
407+
database=database,
408+
)
409+
)
410+
return fuzzy_match(current_word, candidates)
266411

267412
# ANY/ALL/SOME ( → suggest SELECT for subquery
268413
if re.search(r"\b(ANY|ALL|SOME)\s*\(\s*\w*$", clean_before, re.IGNORECASE):
@@ -333,6 +478,8 @@ def get_completions(
333478
if suggestion.type == SuggestionType.TABLE:
334479
results.extend(get_identifier_namespaces(tables))
335480
results.extend(tables)
481+
if include_functions:
482+
results.extend(table_function_names)
336483
results.extend(cte_names)
337484

338485
elif suggestion.type == SuggestionType.COLUMN:
@@ -358,6 +505,7 @@ def get_completions(
358505

359506
if include_functions:
360507
results.extend(get_all_functions())
508+
results.extend(function_names)
361509

362510
elif suggestion.type == SuggestionType.ALIAS_COLUMN:
363511
scope = suggestion.table_scope
@@ -372,14 +520,74 @@ def get_completions(
372520
results.extend(columns[scope_lower])
373521

374522
elif suggestion.type == SuggestionType.PROCEDURE:
375-
if procedures:
376-
results.extend(procedures)
523+
scope_parts = split_identifier_parts(suggestion.table_scope or "")
524+
schema_scope = scope_parts[-1].lower() if scope_parts else ""
525+
database_scope = scope_parts[-2].lower() if len(scope_parts) > 1 else ""
526+
if not schema_scope:
527+
results.extend(procedure_names)
528+
else:
529+
results.extend(
530+
scoped_routine_names(
531+
[
532+
routine
533+
for routine in routines
534+
if getattr(routine, "routine_type", "PROCEDURE")
535+
== "PROCEDURE"
536+
],
537+
schema=schema_scope,
538+
database=database_scope,
539+
)
540+
)
541+
542+
elif suggestion.type == SuggestionType.FUNCTION:
543+
scope_parts = split_identifier_parts(suggestion.table_scope or "")
544+
schema_scope = scope_parts[-1].lower() if scope_parts else ""
545+
database_scope = scope_parts[-2].lower() if len(scope_parts) > 1 else ""
546+
is_alias = len(scope_parts) == 1 and schema_scope in alias_map
547+
if include_functions and not is_alias:
548+
if schema_scope:
549+
results.extend(
550+
scoped_routine_names(
551+
function_routines,
552+
schema=schema_scope,
553+
database=database_scope,
554+
)
555+
)
556+
else:
557+
results.extend(
558+
routine_display_name(routine)
559+
for routine in function_routines
560+
)
561+
562+
elif suggestion.type == SuggestionType.PARAMETER:
563+
parts = split_identifier_parts(suggestion.table_scope or "")
564+
name_part = parts[-1].lower() if parts else ""
565+
schema_part = parts[-2].lower() if len(parts) > 1 else ""
566+
database_part = parts[-3].lower() if len(parts) > 2 else ""
567+
matches = []
568+
for routine in routines:
569+
name = str(routine).lower()
570+
schema = str(getattr(routine, "schema", "")).lower()
571+
database = str(getattr(routine, "database", "")).lower()
572+
if (
573+
name == name_part
574+
and (not schema_part or schema == schema_part)
575+
and (not database_part or database == database_part)
576+
):
577+
matches.append(routine)
578+
if not database_part and len(matches) > 1:
579+
# The user's default schema is not available here, so choosing
580+
# any same-named routine would risk suggesting invalid params.
581+
matches = []
582+
for routine in matches:
583+
results.extend(getattr(routine, "parameters", ()))
377584

378585
elif suggestion.type == SuggestionType.KEYWORD:
379586
if include_keywords:
380587
results.extend(get_all_keywords())
381588
if include_functions:
382589
results.extend(get_all_functions())
590+
results.extend(function_names)
383591

384592
elif suggestion.type == SuggestionType.OPERATOR:
385593
results.extend(SQL_OPERATORS)

‎sqlit/domains/query/completion/core.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class SuggestionType(Enum):
2323
SCHEMA = auto()
2424
DATABASE = auto()
2525
PROCEDURE = auto()
26+
PARAMETER = auto()
2627
ALIAS_COLUMN = auto() # Column for a specific table/alias
2728
OPERATOR = auto() # Comparison operators (=, <, >, etc.)
2829

0 commit comments

Comments
 (0)