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 @@ -4,6 +4,7 @@ Upcoming (TBD)
Bug Fixes
--------
* Open the audit log and the `\T`/`\o` output files as UTF-8, so non-ASCII output no longer fails under non-UTF-8 locales.
* Support `DELIMITER` in batch input (`mycli < file.sql`, `--batch`), so dumps with triggers or routines restore.


v2.30.4 (2026/11/12)
Expand Down
1 change: 1 addition & 0 deletions mycli/AUTHORS.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ Contributors:
* Wei-Lun Li
* Shaurya Kesarwani
* Alex Castillo
* Jay Hemnani


Created by:
Expand Down
29 changes: 28 additions & 1 deletion mycli/packages/utils/batch_utils.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,52 @@
import re
from typing import IO, Generator

import sqlglot
import sqlparse

MAX_MULTILINE_BATCH_STATEMENT = 5000

DELIMITER_RE = re.compile(r'^\s*delimiter\s+(\S+)\s*$', re.IGNORECASE)


def _has_tokens(text: str) -> bool:
try:
return bool(sqlglot.tokenize(text, read='mysql'))
except sqlglot.errors.TokenError:
return True


def statements_from_filehandle(file_h: IO) -> Generator[tuple[str, int], None, None]:
statements = ''
line_counter = 0
batch_counter = 0
delimiter = ';'
for batch_text in file_h:
line_counter += 1
if line_counter > MAX_MULTILINE_BATCH_STATEMENT:
raise ValueError(f'Saw single input statement greater than {MAX_MULTILINE_BATCH_STATEMENT} lines; assuming a parsing error.')
# DELIMITER is a client command, as in the mysql client: switch delimiters, don't send it
delimiter_match = DELIMITER_RE.match(batch_text)
if delimiter_match and not _has_tokens(statements):
delimiter = delimiter_match.group(1)
statements = ''
line_counter = 0
continue
statements += batch_text
if delimiter != ';':
statement = statements.rstrip()
# A custom delimiter is only recognized at the end of a line, so
# two statements on one line are sent together as one
if statement.endswith(delimiter):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment is probably warranted regarding the limitation that the custom delimiter may only be recognized at EOL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment there in 23a3762: the custom delimiter is only recognized at the end of a line, so two statements on one line go out as one.

yield (statement[: -len(delimiter)].strip(), batch_counter)
batch_counter += 1
statements = ''
line_counter = 0
continue
try:
tokens = sqlglot.tokenize(statements, read='mysql')
if not tokens:
continue
# we don't yet handle changing the delimiter within the batch input
if tokens[-1].text == ';':
# The advantage of sqlparse for splitting is that it preserves the input.
# https://github.com/tobymao/sqlglot/issues/2587#issuecomment-1823109501
Expand Down
9 changes: 9 additions & 0 deletions mycli_test/pytests/test_batch_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,12 @@ def fake_tokenize(sql: str, read: str):
('select 1;', 0),
('select 2;', 1),
]


def test_statements_from_filehandle_handles_delimiter_blocks() -> None:
statements = collect_statements('DELIMITER //\nCREATE PROCEDURE p1()\nBEGIN\n SELECT 1;\nEND //\nDELIMITER ;\nCALL p1();\n')

assert statements == [
('CREATE PROCEDURE p1()\nBEGIN\n SELECT 1;\nEND', 0),
('CALL p1();', 1),
]
Loading