diff --git a/sqlit/domains/connections/providers/adapters/base.py b/sqlit/domains/connections/providers/adapters/base.py index 87770b1f..d77259eb 100644 --- a/sqlit/domains/connections/providers/adapters/base.py +++ b/sqlit/domains/connections/providers/adapters/base.py @@ -412,6 +412,26 @@ def get_sequence_definition( "cycle": None, } + def get_procedure_definition( + self, conn: Any, procedure_name: str, database: str | None = None + ) -> dict[str, Any]: + """Get detailed information about a stored procedure. + + Returns a dict with keys like: + - name: Procedure name + - schema: Schema/database the procedure belongs to + - language: Implementation language (e.g. SQL) + - definition: Procedure source code/DDL + + Default implementation returns minimal info. Override in subclasses. + """ + return { + "name": procedure_name, + "schema": database or "", + "language": None, + "definition": None, + } + @abstractmethod def quote_identifier(self, name: str) -> str: """Quote an identifier (table name, column name, etc.).""" diff --git a/sqlit/domains/connections/providers/model.py b/sqlit/domains/connections/providers/model.py index b104db3c..65529fc8 100644 --- a/sqlit/domains/connections/providers/model.py +++ b/sqlit/domains/connections/providers/model.py @@ -120,6 +120,10 @@ def get_sequence_definition(self, conn: Any, sequence_name: str, database: str | class ProcedureInspector(Protocol): def get_procedures(self, conn: Any, database: str | None = None) -> list[Any]: ... + def get_procedure_definition( + self, conn: Any, procedure_name: str, database: str | None = None + ) -> dict[str, Any]: ... + class ConfigValidator(Protocol): def normalize(self, config: ConnectionConfig) -> ConnectionConfig: ... diff --git a/sqlit/domains/connections/providers/mysql/base.py b/sqlit/domains/connections/providers/mysql/base.py index d65fcb90..7a085016 100644 --- a/sqlit/domains/connections/providers/mysql/base.py +++ b/sqlit/domains/connections/providers/mysql/base.py @@ -265,3 +265,29 @@ def get_trigger_definition( "event": None, "definition": None, } + + def get_procedure_definition( + self, conn: Any, procedure_name: str, database: str | None = None + ) -> dict[str, Any]: + """Get detailed information about a MySQL/MariaDB stored procedure.""" + cursor = conn.cursor() + if database: + cursor.execute( + f"SHOW CREATE PROCEDURE {self.quote_identifier(database)}.{self.quote_identifier(procedure_name)}" + ) + else: + cursor.execute(f"SHOW CREATE PROCEDURE {self.quote_identifier(procedure_name)}") + row = cursor.fetchone() + if row: + return { + "name": procedure_name, + "schema": database or "", + "language": "SQL", + "definition": row[2] if len(row) > 2 else None, + } + return { + "name": procedure_name, + "schema": database or "", + "language": None, + "definition": None, + } diff --git a/sqlit/domains/explorer/app/schema_service.py b/sqlit/domains/explorer/app/schema_service.py index 1252b774..650d7aa9 100644 --- a/sqlit/domains/explorer/app/schema_service.py +++ b/sqlit/domains/explorer/app/schema_service.py @@ -206,3 +206,13 @@ def get_sequence_definition(self, database: str | None, name: str) -> dict[str, lambda: inspector.get_sequence_definition(self.session.connection, name, db_arg), database, ) + + def get_procedure_definition(self, database: str | None, name: str) -> dict[str, Any] | None: + inspector = self.session.provider.schema_inspector + if not isinstance(inspector, ProcedureInspector): + return None + db_arg = self._resolve_db_arg(database) + return self._run_with_retry( + lambda: inspector.get_procedure_definition(self.session.connection, name, db_arg), + database, + ) diff --git a/sqlit/domains/explorer/state/tree_on_object.py b/sqlit/domains/explorer/state/tree_on_object.py index ba95d9b7..36613654 100644 --- a/sqlit/domains/explorer/state/tree_on_object.py +++ b/sqlit/domains/explorer/state/tree_on_object.py @@ -7,7 +7,7 @@ class TreeOnObjectState(State): - """Tree focused on index, trigger, or sequence node.""" + """Tree focused on index, trigger, sequence, or procedure node.""" help_category = "Explorer" @@ -45,4 +45,4 @@ def get_display_bindings(self, app: InputContext) -> tuple[list[DisplayBinding], return left, [] def is_active(self, app: InputContext) -> bool: - return app.focus == "explorer" and app.tree_node_kind in ("index", "trigger", "sequence") + return app.focus == "explorer" and app.tree_node_kind in ("index", "trigger", "sequence", "procedure") diff --git a/sqlit/domains/explorer/ui/mixins/tree.py b/sqlit/domains/explorer/ui/mixins/tree.py index 26cf34b3..7b22318a 100644 --- a/sqlit/domains/explorer/ui/mixins/tree.py +++ b/sqlit/domains/explorer/ui/mixins/tree.py @@ -357,6 +357,10 @@ def action_select_table(self: TreeMixinHost) -> None: tree_object_info.show_sequence_info(self, data) return + if self._get_node_kind(node) == "procedure": + tree_object_info.show_procedure_info(self, data) + return + def action_use_database(self: TreeMixinHost) -> None: """Toggle the selected database as the default for the current connection.""" node = self.object_tree.cursor_node diff --git a/sqlit/domains/explorer/ui/tree/object_info.py b/sqlit/domains/explorer/ui/tree/object_info.py index d049d58c..a6b505fd 100644 --- a/sqlit/domains/explorer/ui/tree/object_info.py +++ b/sqlit/domains/explorer/ui/tree/object_info.py @@ -4,7 +4,7 @@ from typing import Any -from sqlit.domains.explorer.domain.tree_nodes import IndexNode, SequenceNode, TriggerNode +from sqlit.domains.explorer.domain.tree_nodes import IndexNode, ProcedureNode, SequenceNode, TriggerNode from sqlit.shared.ui.protocols import TreeMixinHost @@ -56,7 +56,49 @@ def show_sequence_info(host: TreeMixinHost, data: SequenceNode) -> None: host.notify(f"Error getting sequence info: {error}", severity="error") -def display_object_info(host: TreeMixinHost, object_type: str, info: dict[str, Any]) -> None: +def show_procedure_info(host: TreeMixinHost, data: ProcedureNode) -> None: + """Show stored procedure definition in the results panel and load it into the query editor.""" + schema_service = host._get_schema_service() + if not schema_service: + return + + try: + info = schema_service.get_procedure_definition(data.database, data.name) + if info is None: + host.notify("Stored procedures not supported for this database.", severity="warning") + return + _prepare_procedure_for_edit(host, data, info) + display_object_info(host, "Procedure", info, editable_definition=True) + except Exception as error: + host.notify(f"Error getting procedure info: {error}", severity="error") + + +def _prepare_procedure_for_edit(host: TreeMixinHost, data: ProcedureNode, info: dict[str, Any]) -> None: + """Prepend DROP statement and wrap MySQL/MariaDB DDL in DELIMITER blocks.""" + definition = info.get("definition") + if not definition: + return + + provider = getattr(host, "current_provider", None) + db_type = provider.metadata.db_type if provider else "" + if db_type in ("mysql", "mariadb"): + quoted_name = f"`{data.name.replace('`', '``')}`" + info["definition"] = ( + f"DROP PROCEDURE IF EXISTS {quoted_name};\n" + "DELIMITER $$\n" + f"{definition}\n" + "$$\n" + "DELIMITER ;" + ) + + +def display_object_info( + host: TreeMixinHost, + object_type: str, + info: dict[str, Any], + *, + editable_definition: bool = False, +) -> None: """Display object info in the results table as a Property/Value view.""" rows: list[tuple[str, str]] = [] for key, value in info.items(): @@ -81,4 +123,7 @@ def display_object_info(host: TreeMixinHost, object_type: str, info: dict[str, A definition = info.get("definition") if definition: - host.query_input.text = f"/*\n{definition}\n*/" + if editable_definition: + host.query_input.text = str(definition) + else: + host.query_input.text = f"/*\n{definition}\n*/" diff --git a/sqlit/domains/query/app/multi_statement.py b/sqlit/domains/query/app/multi_statement.py index 381a97f6..10e233e1 100644 --- a/sqlit/domains/query/app/multi_statement.py +++ b/sqlit/domains/query/app/multi_statement.py @@ -9,19 +9,29 @@ from __future__ import annotations import re +from collections.abc import Iterator from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any + +# Regex for client-side DELIMITER commands (MySQL/MariaDB style). +# Matches "DELIMITER " at the start of a line, case-insensitive. +_DELIMITER_RE = re.compile(r"(?im)^\s*DELIMITER\s+(\S+)") if TYPE_CHECKING: from .query_service import NonQueryResult, QueryResult -def _iter_sql_chars(sql: str) -> Iterator[tuple[int, str, bool]]: +def _iter_sql_chars(sql: str, delimiter: str | None = None) -> Iterator[tuple[int, str, bool]]: """Iterate through SQL characters, tracking string literal context. Handles escape sequences (backslash), SQL-style doubled quotes, and PostgreSQL dollar-quoted strings ($$ or $tag$). + Args: + sql: SQL string to iterate. + delimiter: Optional statement delimiter. If it starts with '$', + it takes precedence over PostgreSQL dollar-quoted strings. + Yields: (index, char, outside_string) tuples where outside_string is True when the character is not inside a string literal. @@ -67,16 +77,24 @@ def _iter_sql_chars(sql: str) -> Iterator[tuple[int, str, bool]]: i += 2 continue + # If a delimiter is provided and matches here outside of any string, + # treat it as code, not as a dollar-quoted string. This allows MySQL DELIMITER $$. + if delimiter and not in_single_quote and not in_double_quote and sql.startswith(delimiter, i): + for offset in range(len(delimiter)): + yield (i + offset, sql[i + offset], True) + i += len(delimiter) + continue + # Check for PostgreSQL dollar-quoted string start if char == "$" and not in_single_quote and not in_double_quote: # Match $[a-zA-Z_][a-zA-Z0-9_]*$ or $$ match = re.match(r"^\$([a-zA-Z_][a-zA-Z0-9_]*)?\$", sql[i:]) if match: - delimiter = match.group(0) - in_dollar_tag = delimiter - for offset in range(len(delimiter)): + dollar_tag = match.group(0) + in_dollar_tag = dollar_tag + for offset in range(len(dollar_tag)): yield (i + offset, sql[i + offset], False) - i += len(delimiter) + i += len(dollar_tag) continue # Toggle quote state and yield @@ -94,27 +112,44 @@ def _iter_sql_chars(sql: str) -> Iterator[tuple[int, str, bool]]: def _has_semicolon_outside_strings(sql: str) -> bool: """Check if SQL has semicolons outside of string literals.""" - for _, char, outside in _iter_sql_chars(sql): - if char == ";" and outside: - return True - return False + return _has_delimiter_outside_strings(sql, ";") + + +def _has_delimiter_outside_strings(sql: str, delimiter: str) -> bool: + """Check if SQL has the given delimiter outside of string literals.""" + return any( + outside and sql.startswith(delimiter, idx) + for idx, _char, outside in _iter_sql_chars(sql, delimiter) + ) def _split_by_semicolons(sql: str) -> list[str]: """Split SQL by semicolons, respecting string literals.""" + return _split_with_delimiter(sql, ";") + + +def _split_with_delimiter(sql: str, delimiter: str) -> list[str]: + """Split SQL by a delimiter, respecting string literals. + + The delimiter can be any non-empty string (e.g. ";", "$$", "//"). + """ statements = [] current: list[str] = [] + skip_until = -1 - for _, char, outside in _iter_sql_chars(sql): - if char == ";" and outside: + for idx, char, outside in _iter_sql_chars(sql, delimiter): + if idx < skip_until: + continue + if outside and sql.startswith(delimiter, idx): stmt = "".join(current).strip() if stmt: statements.append(stmt) current = [] + skip_until = idx + len(delimiter) else: current.append(char) - # Don't forget the last statement (may not end with semicolon) + # Don't forget the last statement (may not end with delimiter) stmt = "".join(current).strip() if stmt: statements.append(stmt) @@ -177,6 +212,24 @@ def _append_statement_range( ranges.append((stmt_text, actual_start, actual_start + len(stmt_text))) +def _append_ranges_for_block( + ranges: list[tuple[str, int, int]], + sql: str, + block_start: int, + block_end: int, + delimiter: str, +) -> None: + """Split a SQL block by delimiter and append statement ranges.""" + stmt_start = block_start + for idx, _char, outside in _iter_sql_chars(sql[block_start:block_end], delimiter): + absolute_idx = block_start + idx + if outside and sql.startswith(delimiter, absolute_idx): + _append_statement_range(ranges, sql, stmt_start, absolute_idx) + stmt_start = absolute_idx + len(delimiter) + + _append_statement_range(ranges, sql, stmt_start, block_end) + + def _get_statement_ranges(sql: str) -> list[tuple[str, int, int]]: """Get statements with their character ranges in the original SQL. @@ -194,16 +247,21 @@ def _get_statement_ranges(sql: str) -> list[tuple[str, int, int]]: ranges: list[tuple[str, int, int]] = [] - # Strategy 1: If semicolons exist, use semicolon splitting with tracking - if _has_semicolon_outside_strings(sql): - stmt_start = 0 + delimiter_changes = _get_delimiter_changes(sql) - for idx, char, outside in _iter_sql_chars(sql): - if char == ";" and outside: - _append_statement_range(ranges, sql, stmt_start, idx) - stmt_start = idx + 1 + if delimiter_changes: + current_delimiter = ";" + last_end = 0 + for start, end, new_delimiter in delimiter_changes: + _append_ranges_for_block(ranges, sql, last_end, start, current_delimiter) + current_delimiter = new_delimiter + last_end = end + _append_ranges_for_block(ranges, sql, last_end, len(sql), current_delimiter) + return ranges - _append_statement_range(ranges, sql, stmt_start, len(sql)) + # Strategy 1: If semicolons exist, use semicolon splitting with tracking + if _has_semicolon_outside_strings(sql): + _append_ranges_for_block(ranges, sql, 0, len(sql), ";") return ranges # Strategy 2: If blank lines exist, use blank line splitting with tracking @@ -307,20 +365,32 @@ def get_executable_sql(sql: str) -> str: return "; ".join(executable) +def _get_delimiter_changes(sql: str) -> list[tuple[int, int, str]]: + """Find client-side DELIMITER changes in SQL. + + Returns: + List of (start_index, end_index, new_delimiter) tuples. + The [start, end) range covers the full DELIMITER command line. + """ + return [(match.start(), match.end(), match.group(1)) for match in _DELIMITER_RE.finditer(sql)] + + def split_statements(sql: str) -> list[str]: """Split SQL into individual statements. Splitting strategy: - 1. If query contains semicolons (outside strings) → split by semicolons - 2. If no semicolons but has blank lines → split by blank lines - 3. Otherwise → return as single statement + 1. Respect MySQL/MariaDB-style DELIMITER commands + 2. Split by the active delimiter (default ";") + 3. If no delimiter but has blank lines → split by blank lines + 4. Otherwise → return as single statement Handles: - - Multiple statements separated by semicolons - - Multiple statements separated by blank lines (when no semicolons) - - Semicolons/blank lines inside string literals (preserved) + - Multiple statements separated by the active delimiter + - DELIMITER $$ ... $$ DELIMITER ; style procedure bodies + - Multiple statements separated by blank lines (when no delimiter) + - Delimiters/blank lines inside string literals (preserved) - Empty statements (filtered out) - - Trailing semicolons + - Trailing delimiters Args: sql: SQL containing one or more statements. @@ -331,17 +401,32 @@ def split_statements(sql: str) -> list[str]: if not sql or not sql.strip(): return [] - # Strategy 1: If semicolons exist, use semicolon splitting - if _has_semicolon_outside_strings(sql): - return _split_by_semicolons(sql) - - # Strategy 2: If blank lines exist, use blank line splitting - # A blank line is two consecutive newlines (possibly with whitespace between) - if re.search(r"\n\s*\n", sql): - return _split_by_blank_lines(sql) + delimiter_changes = _get_delimiter_changes(sql) + if not delimiter_changes: + # No DELIMITER commands: use the default ";" logic. + if _has_delimiter_outside_strings(sql, ";"): + return _split_with_delimiter(sql, ";") + if re.search(r"\n\s*\n", sql): + return _split_by_blank_lines(sql) + return [sql.strip()] + + statements: list[str] = [] + current_delimiter = ";" + last_end = 0 + + for start, end, new_delimiter in delimiter_changes: + # Process SQL before the DELIMITER command using the current delimiter. + block = sql[last_end:start] + statements.extend(_split_with_delimiter(block, current_delimiter)) + # Switch delimiter and skip the DELIMITER command itself. + current_delimiter = new_delimiter + last_end = end + + # Process remaining SQL after the last DELIMITER command. + block = sql[last_end:] + statements.extend(_split_with_delimiter(block, current_delimiter)) - # Strategy 3: Single statement - return [sql.strip()] + return statements def normalize_for_execution(sql: str) -> str: @@ -350,6 +435,9 @@ def normalize_for_execution(sql: str) -> str: Converts blank-line-separated statements to semicolon-separated, since databases expect semicolons between statements. + If the SQL contains DELIMITER commands, it is returned as-is so that + the server receives the procedure body with the correct terminator. + Args: sql: SQL that may use blank lines or semicolons as separators. @@ -359,6 +447,10 @@ def normalize_for_execution(sql: str) -> str: if not sql or not sql.strip(): return sql + # DELIMITER commands are client-side directives; keep them intact. + if _get_delimiter_changes(sql): + return sql + # If already has semicolons, return as-is if _has_semicolon_outside_strings(sql): return sql diff --git a/sqlit/domains/query/ui/mixins/query_execution.py b/sqlit/domains/query/ui/mixins/query_execution.py index 2370053d..e1862247 100644 --- a/sqlit/domains/query/ui/mixins/query_execution.py +++ b/sqlit/domains/query/ui/mixins/query_execution.py @@ -27,6 +27,10 @@ r"\b(create|alter|drop|truncate|rename|comment|grant|revoke)\b", re.IGNORECASE, ) +_PROCEDURE_DDL_RE = re.compile( + r"\b(create|alter|drop)(\s+or\s+replace)?(\s+definer\s*=\s*[^;]+?)?\s+(procedure|function|trigger|event)\b", + re.IGNORECASE, +) _SQL_COMMENT_RE = re.compile(r"(--[^\n]*|/\*.*?\*/)", re.DOTALL) _SQL_LITERAL_RE = re.compile(r"('([^']|'')*'|\"([^\"]|\"\")*\"|`[^`]*`|\[[^\]]*\])", re.DOTALL) @@ -36,6 +40,20 @@ def _strip_sql_comments_and_literals(sql: str) -> str: return _SQL_LITERAL_RE.sub(" ", sql) +def _sql_changes_schema(sql: str) -> bool: + """Return True if SQL changes table/database structure. + + DDL for procedures, functions, triggers, and events is excluded because + it does not change table structure and should not trigger an explorer refresh. + """ + cleaned = _strip_sql_comments_and_literals(sql) + if not _SCHEMA_CHANGE_RE.search(cleaned): + return False + + remaining = _PROCEDURE_DDL_RE.sub("", cleaned) + return bool(_SCHEMA_CHANGE_RE.search(remaining)) + + class QueryExecutionMixin(ProcessWorkerLifecycleMixin): """Mixin providing query execution actions.""" @@ -258,8 +276,7 @@ def _on_result(confirmed: bool | None) -> None: ) def _query_changes_schema(self: QueryMixinHost, query: str) -> bool: - cleaned = _strip_sql_comments_and_literals(query) - return bool(_SCHEMA_CHANGE_RE.search(cleaned)) + return _sql_changes_schema(query) def _maybe_refresh_explorer_after_query(self: QueryMixinHost, query: str) -> None: if not self._query_changes_schema(query): @@ -522,7 +539,8 @@ async def _run_query_async(self: QueryMixinHost, query: str, keep_insert_mode: b use_process_worker = False if use_process_worker: - outcome = await asyncio.to_thread(client.execute, query, config, max_rows) + single_statement = executable_statements[0] if executable_statements else query + outcome = await asyncio.to_thread(client.execute, single_statement, config, max_rows) if outcome.cancelled: return if outcome.error: @@ -569,9 +587,10 @@ async def _run_query_async(self: QueryMixinHost, query: str, keep_insert_mode: b self._maybe_refresh_explorer_after_query(query) else: # Single statement - existing behavior + single_statement = executable_statements[0] if executable_statements else query result = await asyncio.to_thread( executor.execute, - query, + single_statement, max_rows, ) elapsed_ms = (time.perf_counter() - start_time) * 1000 diff --git a/sqlit/domains/shell/state/machine.py b/sqlit/domains/shell/state/machine.py index dc146c35..57127990 100644 --- a/sqlit/domains/shell/state/machine.py +++ b/sqlit/domains/shell/state/machine.py @@ -121,7 +121,7 @@ def __init__(self) -> None: self.tree_on_database, # For database nodes (multi-database servers) self.tree_on_table, self.tree_on_folder, - self.tree_on_object, # For index/trigger/sequence nodes + self.tree_on_object, # For index/trigger/sequence/procedure nodes self.tree_focused, self.autocomplete_active, # Before query_insert (more specific) self.query_visual, # Before query_normal (more specific) diff --git a/tests/unit/test_explorer_states.py b/tests/unit/test_explorer_states.py new file mode 100644 index 00000000..cb6bb374 --- /dev/null +++ b/tests/unit/test_explorer_states.py @@ -0,0 +1,54 @@ +"""Unit tests for explorer tree state detection.""" + +from __future__ import annotations + +from sqlit.core.input_context import InputContext +from sqlit.core.vim import VimMode +from sqlit.domains.explorer.state.tree_on_object import TreeOnObjectState + + +def _context(tree_node_kind: str | None) -> InputContext: + return InputContext( + focus="explorer", + vim_mode=VimMode.NORMAL, + leader_pending=False, + leader_menu="", + tree_filter_active=False, + tree_multi_select_active=False, + tree_visual_mode_active=False, + autocomplete_visible=False, + results_filter_active=False, + value_view_active=False, + value_view_tree_mode=False, + value_view_is_json=False, + query_executing=False, + modal_open=False, + has_connection=True, + current_connection_name="test", + tree_node_kind=tree_node_kind, + tree_node_connection_name=None, + tree_node_connection_selected=False, + last_result_is_error=False, + has_results=False, + ) + + +def test_tree_on_object_state_is_active_for_procedure() -> None: + """Procedure nodes are treated as object nodes for key bindings.""" + state = TreeOnObjectState() + assert state.is_active(_context("procedure")) is True + + +def test_tree_on_object_state_is_active_for_index_trigger_sequence() -> None: + """Index, trigger, and sequence nodes are still object nodes.""" + state = TreeOnObjectState() + assert state.is_active(_context("index")) is True + assert state.is_active(_context("trigger")) is True + assert state.is_active(_context("sequence")) is True + + +def test_tree_on_object_state_is_inactive_for_table_view() -> None: + """Table and view nodes use a different state.""" + state = TreeOnObjectState() + assert state.is_active(_context("table")) is False + assert state.is_active(_context("view")) is False diff --git a/tests/unit/test_multi_statement.py b/tests/unit/test_multi_statement.py index 617abb2c..957fe608 100644 --- a/tests/unit/test_multi_statement.py +++ b/tests/unit/test_multi_statement.py @@ -6,8 +6,6 @@ from __future__ import annotations -import pytest - class TestStatementSplitting: """Tests for splitting multi-statement queries.""" @@ -469,3 +467,109 @@ def test_handles_multiline_single_statement(self): assert normalized == query assert ";" not in normalized + + def test_preserves_delimiter_commands(self): + """SQL with DELIMITER commands should not be normalized.""" + from sqlit.domains.query.app.multi_statement import normalize_for_execution + + query = """DELIMITER $$ +CREATE PROCEDURE p() BEGIN SELECT 1; END $$ +DELIMITER ;""" + normalized = normalize_for_execution(query) + + assert normalized == query + + +class TestDelimiterSplitting: + """Tests for MySQL/MariaDB-style DELIMITER splitting.""" + + def test_split_procedure_body_with_delimiter(self): + """Procedure body containing semicolons should stay as one statement.""" + from sqlit.domains.query.app.multi_statement import split_statements + + query = """DROP PROCEDURE IF EXISTS p; +DELIMITER $$ +CREATE PROCEDURE p() +BEGIN + SELECT 1; + SELECT 2; +END $$ +DELIMITER ;""" + statements = split_statements(query) + + assert len(statements) == 2 + assert statements[0] == "DROP PROCEDURE IF EXISTS p" + assert "CREATE PROCEDURE p()" in statements[1] + assert "SELECT 1;" in statements[1] + assert "SELECT 2;" in statements[1] + assert "END" in statements[1] + + def test_split_with_custom_delimiter_and_restore(self): + """Delimiter should switch back to semicolon after DELIMITER ;.""" + from sqlit.domains.query.app.multi_statement import split_statements + + query = """DELIMITER // +CREATE FUNCTION f() RETURNS INT BEGIN RETURN 1; END // +DELIMITER ; +SELECT 1; +SELECT 2;""" + statements = split_statements(query) + + assert len(statements) == 3 + assert "CREATE FUNCTION f()" in statements[0] + assert statements[1] == "SELECT 1" + assert statements[2] == "SELECT 2" + + def test_delimiter_commands_are_excluded(self): + """The DELIMITER lines themselves should not be executable statements.""" + from sqlit.domains.query.app.multi_statement import split_statements + + query = """DELIMITER $$ +CREATE PROCEDURE p() BEGIN SELECT 1; END $$ +DELIMITER ;""" + statements = split_statements(query) + + for stmt in statements: + assert not stmt.strip().upper().startswith("DELIMITER") + + def test_semicolons_inside_strings_with_delimiter(self): + """Semicolons inside string literals should not split procedure body.""" + from sqlit.domains.query.app.multi_statement import split_statements + + query = """DELIMITER $$ +CREATE PROCEDURE p() +BEGIN + SELECT 'a;b'; +END $$ +DELIMITER ;""" + statements = split_statements(query) + + assert len(statements) == 1 + assert "'a;b'" in statements[0] + + def test_real_mysql_procedure_with_double_quote_string(self): + """Procedure body with double-quoted string and semicolons splits correctly.""" + from sqlit.domains.query.app.multi_statement import split_statements + + query = '''DROP PROCEDURE IF EXISTS `getallpositivecache`; +DELIMITER $$ +CREATE DEFINER=`root`@`%` PROCEDURE `getallpositivecache`() +BEGIN +\tSELECT +\t\tCOALESCE(id, 0), +\t\tCOALESCE(sentence_id, 0), +\t\tCOALESCE(answer, "") +\tFROM +\t\tpositivecache; +END +$$ +DELIMITER ;''' + statements = split_statements(query) + + assert len(statements) == 2 + assert statements[0] == "DROP PROCEDURE IF EXISTS `getallpositivecache`" + assert "CREATE DEFINER=`root`@`%` PROCEDURE `getallpositivecache`()" in statements[1] + assert "COALESCE(answer, \"\")" in statements[1] + assert "positivecache;" in statements[1] + assert "END" in statements[1] + assert "$$" not in statements[1] diff --git a/tests/unit/test_mysql_adapter.py b/tests/unit/test_mysql_adapter.py new file mode 100644 index 00000000..adfd7fed --- /dev/null +++ b/tests/unit/test_mysql_adapter.py @@ -0,0 +1,90 @@ +"""Unit tests for MySQL-compatible adapter behavior.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +class TestMySQLProcedureDefinition: + """Tests for stored procedure definition retrieval.""" + + @pytest.fixture + def mock_pymysql(self): + """Create a mock pymysql module.""" + mock = MagicMock() + with patch.dict("sys.modules", {"pymysql": mock}): + yield mock + + @pytest.fixture + def adapter(self, mock_pymysql): + """Create a MySQL adapter instance.""" + from sqlit.domains.connections.providers.mysql.adapter import MySQLAdapter + + return MySQLAdapter() + + @pytest.fixture + def mock_conn(self): + """Create a mock connection with cursor.""" + conn = MagicMock() + cursor = MagicMock() + conn.cursor.return_value = cursor + return conn + + def test_get_procedure_definition_with_database(self, adapter, mock_conn): + """Test that SHOW CREATE PROCEDURE is qualified with the database.""" + cursor = mock_conn.cursor.return_value + cursor.fetchone.return_value = ( + "my_proc", + "STRICT_TRANS_TABLES", + "CREATE PROCEDURE `my_proc`() BEGIN SELECT 1; END", + "utf8mb4", + "utf8mb4_general_ci", + "utf8mb4_general_ci", + ) + + result = adapter.get_procedure_definition(mock_conn, "my_proc", database="mydb") + + assert result["name"] == "my_proc" + assert result["schema"] == "mydb" + assert result["language"] == "SQL" + assert "CREATE PROCEDURE" in result["definition"] + cursor.execute.assert_called_once() + executed = cursor.execute.call_args[0][0] + assert "SHOW CREATE PROCEDURE" in executed + assert "`mydb`" in executed + assert "`my_proc`" in executed + + def test_get_procedure_definition_without_database(self, adapter, mock_conn): + """Test that SHOW CREATE PROCEDURE is unqualified when no database is given.""" + cursor = mock_conn.cursor.return_value + cursor.fetchone.return_value = ( + "my_proc", + "STRICT_TRANS_TABLES", + "CREATE PROCEDURE `my_proc`() BEGIN SELECT 1; END", + "utf8mb4", + "utf8mb4_general_ci", + "utf8mb4_general_ci", + ) + + result = adapter.get_procedure_definition(mock_conn, "my_proc") + + assert result["name"] == "my_proc" + assert result["schema"] == "" + assert result["language"] == "SQL" + assert "CREATE PROCEDURE" in result["definition"] + cursor.execute.assert_called_once() + executed = cursor.execute.call_args[0][0] + assert executed == "SHOW CREATE PROCEDURE `my_proc`" + + def test_get_procedure_definition_not_found(self, adapter, mock_conn): + """Test that missing procedure returns empty definition.""" + cursor = mock_conn.cursor.return_value + cursor.fetchone.return_value = None + + result = adapter.get_procedure_definition(mock_conn, "missing_proc", database="mydb") + + assert result["name"] == "missing_proc" + assert result["definition"] is None + assert result["language"] is None diff --git a/tests/unit/test_object_info.py b/tests/unit/test_object_info.py new file mode 100644 index 00000000..ed4eb078 --- /dev/null +++ b/tests/unit/test_object_info.py @@ -0,0 +1,107 @@ +"""Unit tests for explorer object info helpers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from sqlit.domains.connections.providers.model import DatabaseProvider, ProviderMetadata +from sqlit.domains.explorer.domain.tree_nodes import ProcedureNode +from sqlit.domains.explorer.ui.tree import object_info + + +class FakeProvider: + def __init__(self, db_type: str): + self.metadata = ProviderMetadata( + db_type=db_type, + display_name=db_type, + badge_label=db_type, + default_port="3306", + supports_ssh=False, + is_file_based=False, + has_advanced_auth=False, + requires_auth=True, + url_schemes=(), + ) + + +def _make_host() -> MagicMock: + host = MagicMock() + host.current_provider = DatabaseProvider( + metadata=FakeProvider("mysql").metadata, + schema=None, # type: ignore[arg-type] + capabilities=None, # type: ignore[arg-type] + driver=None, + connection_factory=None, # type: ignore[arg-type] + query_executor=None, # type: ignore[arg-type] + schema_inspector=None, # type: ignore[arg-type] + dialect=None, # type: ignore[arg-type] + config_validator=None, # type: ignore[arg-type] + docker_detector=None, + explorer_nodes=None, # type: ignore[arg-type] + display_info=lambda _: "", + apply_database_override=lambda c, _: c, + post_connect=lambda _a, _b: None, + post_connect_warnings=lambda _: [], + get_auth_type=lambda _: None, + ) + return host + + +def test_show_procedure_info_loads_editable_definition_for_mysql() -> None: + """MySQL procedure DDL is loaded into the editor wrapped in DELIMITER blocks.""" + host = _make_host() + schema_service = MagicMock() + schema_service.get_procedure_definition.return_value = { + "name": "my_proc", + "schema": "mydb", + "language": "SQL", + "definition": "CREATE PROCEDURE `my_proc`() BEGIN SELECT 1; END", + } + host._get_schema_service.return_value = schema_service + + object_info.show_procedure_info(host, ProcedureNode(database="mydb", name="my_proc")) + + schema_service.get_procedure_definition.assert_called_once_with("mydb", "my_proc") + text = host.query_input.text + assert text.startswith("DROP PROCEDURE IF EXISTS `my_proc`;") + assert "DELIMITER $$" in text + assert "CREATE PROCEDURE `my_proc`()" in text + assert "$$\nDELIMITER ;" in text + host._replace_results_table.assert_called_once() + + +def test_show_procedure_info_not_supported() -> None: + """Unsupported provider shows a warning instead of crashing.""" + host = _make_host() + schema_service = MagicMock() + schema_service.get_procedure_definition.return_value = None + host._get_schema_service.return_value = schema_service + + object_info.show_procedure_info(host, ProcedureNode(database="mydb", name="my_proc")) + + host.notify.assert_called_once() + assert "warning" in host.notify.call_args.kwargs.get("severity", "") + + +def test_show_procedure_info_error_handling() -> None: + """Errors during definition retrieval are surfaced to the user.""" + host = _make_host() + schema_service = MagicMock() + schema_service.get_procedure_definition.side_effect = RuntimeError("boom") + host._get_schema_service.return_value = schema_service + + object_info.show_procedure_info(host, ProcedureNode(database="mydb", name="my_proc")) + + host.notify.assert_called_once() + assert "error" in host.notify.call_args.kwargs.get("severity", "") + + +def test_index_definition_is_wrapped_in_comment() -> None: + """Non-editable object definitions stay wrapped in a comment block.""" + host = _make_host() + object_info.display_object_info( + host, + "Index", + {"name": "IX_Test", "definition": "CREATE INDEX IX_Test ON t(c)"}, + ) + assert host.query_input.text == "/*\nCREATE INDEX IX_Test ON t(c)\n*/" diff --git a/tests/unit/test_query_changes_schema.py b/tests/unit/test_query_changes_schema.py new file mode 100644 index 00000000..d4fa9b69 --- /dev/null +++ b/tests/unit/test_query_changes_schema.py @@ -0,0 +1,69 @@ +"""Tests for schema-change detection used to refresh the explorer.""" + +from __future__ import annotations + +from sqlit.domains.query.ui.mixins.query_execution import _sql_changes_schema + + +def test_create_table_triggers_refresh() -> None: + assert _sql_changes_schema("CREATE TABLE users (id INT);") is True + + +def test_alter_table_triggers_refresh() -> None: + assert _sql_changes_schema("ALTER TABLE users ADD name VARCHAR(50);") is True + + +def test_drop_table_triggers_refresh() -> None: + assert _sql_changes_schema("DROP TABLE users;") is True + + +def test_create_procedure_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("CREATE PROCEDURE p() BEGIN SELECT 1; END;") is False + + +def test_create_procedure_with_definer_does_not_trigger_refresh() -> None: + sql = """DROP PROCEDURE IF EXISTS `p`; +DELIMITER $$ +CREATE DEFINER=`root`@`%` PROCEDURE `p`() +BEGIN + SELECT 1; +END $$ +DELIMITER ;""" + assert _sql_changes_schema(sql) is False + + +def test_drop_procedure_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("DROP PROCEDURE IF EXISTS p;") is False + + +def test_alter_procedure_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("ALTER PROCEDURE p SQL SECURITY INVOKER;") is False + + +def test_create_function_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("CREATE FUNCTION f() RETURNS INT BEGIN RETURN 1; END;") is False + + +def test_create_trigger_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("CREATE TRIGGER tr BEFORE INSERT ON t FOR EACH ROW SET NEW.x = 1;") is False + + +def test_create_event_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("CREATE EVENT ev ON SCHEDULE EVERY 1 HOUR DO SELECT 1;") is False + + +def test_mixed_ddl_triggers_refresh() -> None: + """If a batch contains both procedure and table DDL, refresh the explorer.""" + sql = """ + DROP PROCEDURE IF EXISTS p; + CREATE TABLE new_table (id INT); + """ + assert _sql_changes_schema(sql) is True + + +def test_select_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("SELECT * FROM users;") is False + + +def test_insert_does_not_trigger_refresh() -> None: + assert _sql_changes_schema("INSERT INTO users VALUES (1);") is False