diff --git a/changelog.md b/changelog.md index e479eb76..97e07509 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,8 @@ Features: --------- * Added DSN alias name as a format specifier to the prompt (Thanks: [Georgy Frolov]). +* Added DELIMITER command (Thanks: [Georgy Frolov]) + 1.20.1 ====== @@ -723,3 +725,4 @@ Bug Fixes: [Dick Marinus]: https://github.com/meeuw [François Pietka]: https://github.com/fpietka [Frederic Aoustin]: https://github.com/fraoustin +[Georgy Frolov]: https://github.com/pasenor \ No newline at end of file diff --git a/mycli/clibuffer.py b/mycli/clibuffer.py index f6cc737a..d3f1af4b 100644 --- a/mycli/clibuffer.py +++ b/mycli/clibuffer.py @@ -4,6 +4,7 @@ from prompt_toolkit.filters import Condition from prompt_toolkit.application import get_app from .packages.parseutils import is_open_quote +from .packages import special def cli_is_multiline(mycli): @@ -17,6 +18,7 @@ def cond(): return not _multiline_exception(doc.text) return cond + def _multiline_exception(text): orig = text text = text.strip() @@ -27,12 +29,28 @@ def _multiline_exception(text): if text.startswith('\\fs'): return orig.endswith('\n') - return (text.startswith('\\') or # Special Command - text.endswith(';') or # Ended with a semi-colon - text.endswith('\\g') or # Ended with \g - text.endswith('\\G') or # Ended with \G - (text == 'exit') or # Exit doesn't need semi-colon - (text == 'quit') or # Quit doesn't need semi-colon - (text == ':q') or # To all the vim fans out there - (text == '') # Just a plain enter without any text - ) + return ( + # Special Command + text.startswith('\\') or + + # Delimiter declaration + text.lower().startswith('delimiter') or + + # Ended with the current delimiter (usually a semi-column) + text.endswith(special.delimiter.current) or + + text.endswith('\\g') or + text.endswith('\\G') or + + # Exit doesn't need semi-column` + (text == 'exit') or + + # Quit doesn't need semi-column + (text == 'quit') or + + # To all teh vim fans out there + (text == ':q') or + + # just a plain enter without any text + (text == '') + ) diff --git a/mycli/clitoolbar.py b/mycli/clitoolbar.py index 89e6afa0..03e39cf2 100644 --- a/mycli/clitoolbar.py +++ b/mycli/clitoolbar.py @@ -3,6 +3,7 @@ from prompt_toolkit.key_binding.vi_state import InputMode from prompt_toolkit.application import get_app from prompt_toolkit.enums import EditingMode +from .packages import special def create_toolbar_tokens_func(mycli, show_fish_help): @@ -12,8 +13,13 @@ def get_toolbar_tokens(): result.append(('class:bottom-toolbar', ' ')) if mycli.multi_line: + delimiter = special.delimiter.current result.append( - ('class:bottom-toolbar', ' (Semi-colon [;] will end the line) ')) + ( + 'class:bottom-toolbar', + ' ({} [{}] will end the line) '.format( + 'Semi-colon' if delimiter == ';' else 'Delimiter', delimiter) + )) if mycli.multi_line: result.append(('class:bottom-toolbar.on', '[F3] Multiline: ON ')) diff --git a/mycli/packages/special/__init__.py b/mycli/packages/special/__init__.py index 92bcca6d..f4d105e0 100644 --- a/mycli/packages/special/__init__.py +++ b/mycli/packages/special/__init__.py @@ -8,3 +8,5 @@ def export(defn): from . import dbcommands from . import iocommands + +delimiter = iocommands.delimiter_command diff --git a/mycli/packages/special/delimitercommand.py b/mycli/packages/special/delimitercommand.py new file mode 100644 index 00000000..a7dc7979 --- /dev/null +++ b/mycli/packages/special/delimitercommand.py @@ -0,0 +1,83 @@ +# coding: utf-8 +from __future__ import unicode_literals + +import re +import sqlparse + + +class DelimiterCommand(object): + def __init__(self): + self._delimiter = ';' + + def _split(self, sql): + """Temporary workaround until sqlparse.split() learns about custom + delimiters.""" + + placeholder = "\ufffc" # unicode object replacement character + + if self._delimiter == ';': + return sqlparse.split(sql) + + # We must find a string that original sql does not contain. + # Most likely, our placeholder is enough, but if not, keep looking + while placeholder in sql: + placeholder += placeholder[0] + sql = sql.replace(';', placeholder) + sql = sql.replace(self._delimiter, ';') + + split = sqlparse.split(sql) + + return [ + stmt.replace(';', self._delimiter).replace(placeholder, ';') + for stmt in split + ] + + def queries_iter(self, input): + """Iterate over queries in the input string.""" + + queries = self._split(input) + while queries: + for sql in queries: + delimiter = self._delimiter + sql = queries.pop(0) + if sql.endswith(delimiter): + trailing_delimiter = True + sql = sql.strip(delimiter) + else: + trailing_delimiter = False + + yield sql + + # if the delimiter was changed by the last command, + # re-split everything, and if we previously stripped + # the delimiter, append it to the end + if self._delimiter != delimiter: + combined_statement = ' '.join([sql] + queries) + if trailing_delimiter: + combined_statement += delimiter + queries = self._split(combined_statement)[1:] + + def set(self, arg, **_): + """Change delimiter. + + Since `arg` is everything that follows the DELIMITER token + after sqlparse (it may include other statements separated by + the new delimiter), we want to set the delimiter to the first + word of it. + + """ + match = arg and re.search(r'[^\s]+', arg) + if not match: + message = 'Missing required argument, delimiter' + return [(None, None, None, message)] + + delimiter = match.group() + if delimiter.lower() == 'delimiter': + return [(None, None, None, 'Invalid delimiter "delimiter"')] + + self._delimiter = delimiter + return [(None, None, None, "Changed delimiter to {}".format(delimiter))] + + @property + def current(self): + return self._delimiter diff --git a/mycli/packages/special/favoritequeries.py b/mycli/packages/special/favoritequeries.py index ed47127f..55dd8cad 100644 --- a/mycli/packages/special/favoritequeries.py +++ b/mycli/packages/special/favoritequeries.py @@ -26,7 +26,7 @@ class FavoriteQueries(object): ╒════════╤════════╕ │ a │ b │ ╞════════╪════════╡ - │ 日本語 │ 日本語 │ + │ 日本語 │ 日本語 │ ╘════════╧════════╛ # Delete a favorite query. diff --git a/mycli/packages/special/iocommands.py b/mycli/packages/special/iocommands.py index 166e457c..84c668eb 100644 --- a/mycli/packages/special/iocommands.py +++ b/mycli/packages/special/iocommands.py @@ -15,6 +15,7 @@ from . import export from .main import special_command, NO_QUERY, PARSED_QUERY from .favoritequeries import FavoriteQueries +from .delimitercommand import DelimiterCommand from .utils import handle_cd_command from mycli.packages.prompt_utils import confirm_destructive_query @@ -24,6 +25,8 @@ tee_file = None once_file = written_to_once_file = None favoritequeries = FavoriteQueries(ConfigObj()) +delimiter_command = DelimiterCommand() + @export def set_timing_enabled(val): @@ -437,3 +440,8 @@ def watch_query(arg, **kwargs): return finally: set_pager_enabled(old_pager_enabled) + + +@special_command('delimiter', None, 'Change SQL delimiter.') +def set_delimiter(arg, **_): + return delimiter_command.set(arg) diff --git a/mycli/sqlcompleter.py b/mycli/sqlcompleter.py index 1e11c9c3..2f932338 100644 --- a/mycli/sqlcompleter.py +++ b/mycli/sqlcompleter.py @@ -21,7 +21,7 @@ class SQLCompleter(Completer): 'CHARACTER SET', 'CHECK', 'COLLATE', 'COLUMN', 'COMMENT', 'COMMIT', 'CONSTRAINT', 'CREATE', 'CURRENT', 'CURRENT_TIMESTAMP', 'DATABASE', 'DATE', 'DECIMAL', 'DEFAULT', - 'DELETE FROM', 'DELIMITER', 'DESC', 'DESCRIBE', 'DROP', + 'DELETE FROM', 'DESC', 'DESCRIBE', 'DROP', 'ELSE', 'END', 'ENGINE', 'ESCAPE', 'EXISTS', 'FILE', 'FLOAT', 'FOR', 'FOREIGN KEY', 'FORMAT', 'FROM', 'FULL', 'FUNCTION', 'GRANT', 'GROUP BY', 'HAVING', 'HOST', 'IDENTIFIED', 'IN', diff --git a/mycli/sqlexecute.py b/mycli/sqlexecute.py index 61ba6848..fa79af9c 100644 --- a/mycli/sqlexecute.py +++ b/mycli/sqlexecute.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import pymysql import sqlparse @@ -166,12 +168,9 @@ def run(self, statement): if statement.startswith('\\fs'): components = [statement] else: - components = sqlparse.split(statement) + components = special.delimiter.queries_iter(statement) for sql in components: - # Remove spaces, eol and semi-colons. - sql = sql.rstrip(';') - # \G is treated specially since we have to set the expanded output. if sql.endswith('\\G'): special.set_expanded_output(True) @@ -194,6 +193,7 @@ def run(self, statement): if not cur.nextset() or (not cur.rowcount and cur.description is None): break + def get_result(self, cursor): """Get the current result's data from the cursor.""" title = headers = None diff --git a/test/features/iocommands.feature b/test/features/iocommands.feature index 38efbbb0..246a44a3 100644 --- a/test/features/iocommands.feature +++ b/test/features/iocommands.feature @@ -2,16 +2,31 @@ Feature: I/O commands Scenario: edit sql in file with external editor When we start external editor providing a file name - and we type sql in the editor + and we type "select * from abc" in the editor and we exit the editor then we see dbcli prompt - and we see the sql in prompt + and we see "select * from abc" in prompt Scenario: tee output from query When we tee output and we wait for prompt - and we query "select 123456" + and we select "select 123456" and we wait for prompt and we notee output and we wait for prompt then we see 123456 in tee output + + Scenario: set delimiter + When we query "delimiter $" + then delimiter is set to "$" + + Scenario: set delimiter twice + When we query "delimiter $" + and we query "delimiter ]]" + then delimiter is set to "]]" + + Scenario: set delimiter and query on same line + When we query "select 123; delimiter $ select 456 $ delimiter %" + then we see result "123" + and we see result "456" + and delimiter is set to "%" diff --git a/test/features/steps/crud_database.py b/test/features/steps/crud_database.py index 046e829d..ef7f3f62 100644 --- a/test/features/steps/crud_database.py +++ b/test/features/steps/crud_database.py @@ -36,7 +36,7 @@ def step_db_connect_test(context): """Send connect to database.""" db_name = context.conf['dbname'] context.currentdb = db_name - context.cli.sendline('use {0}'.format(db_name)) + context.cli.sendline('use {0};'.format(db_name)) @when('we connect to tmp database') diff --git a/test/features/steps/iocommands.py b/test/features/steps/iocommands.py index 206ca802..2f3efb31 100644 --- a/test/features/steps/iocommands.py +++ b/test/features/steps/iocommands.py @@ -21,10 +21,10 @@ def step_edit_file(context): wrappers.expect_exact(context, '\r\n:', timeout=2) -@when('we type sql in the editor') -def step_edit_type_sql(context): +@when('we type "{query}" in the editor') +def step_edit_type_sql(context, query): context.cli.sendline('i') - context.cli.sendline('select * from abc') + context.cli.sendline(query) context.cli.sendline('.') wrappers.expect_exact(context, '\r\n:', timeout=2) @@ -35,9 +35,9 @@ def step_edit_quit(context): wrappers.expect_exact(context, "written", timeout=2) -@then('we see the sql in prompt') -def step_edit_done_sql(context): - for match in 'select * from abc'.split(' '): +@then('we see "{query}" in prompt') +def step_edit_done_sql(context, query): + for match in query.split(' '): wrappers.expect_exact(context, match, timeout=5) # Cleanup the command line. context.cli.sendcontrol('c') @@ -56,20 +56,35 @@ def step_tee_ouptut(context): os.path.basename(context.tee_file_name))) -@when(u'we query "select 123456"') -def step_query_select_123456(context): - context.cli.sendline('select 123456') - wrappers.expect_pager(context, dedent("""\ - +--------+\r - | 123456 |\r - +--------+\r - | 123456 |\r - +--------+\r +@when(u'we select "select {param}"') +def step_query_select_number(context, param): + context.cli.sendline(u'select {}'.format(param)) + wrappers.expect_pager(context, dedent(u"""\ + +{dashes}+\r + | {param} |\r + +{dashes}+\r + | {param} |\r + +{dashes}+\r \r - """), timeout=5) + """.format(param=param, dashes='-' * (len(param) + 2)) + ), timeout=5) wrappers.expect_exact(context, '1 row in set', timeout=2) +@then(u'we see result "{result}"') +def step_see_result(context, result): + wrappers.expect_exact( + context, + u"| {} |".format(result), + timeout=2 + ) + + +@when(u'we query "{query}"') +def step_query(context, query): + context.cli.sendline(query) + + @when(u'we notee output') def step_notee_output(context): context.cli.sendline('notee') @@ -81,3 +96,12 @@ def step_see_123456_in_ouput(context): assert '123456' in f.read() if os.path.exists(context.tee_file_name): os.remove(context.tee_file_name) + + +@then(u'delimiter is set to "{delimiter}"') +def delimiter_is_set(context, delimiter): + wrappers.expect_exact( + context, + u'Changed delimiter to {}'.format(delimiter), + timeout=2 + ) diff --git a/test/test_parseutils.py b/test/test_parseutils.py index f11dcdb4..9fd5daf1 100644 --- a/test/test_parseutils.py +++ b/test/test_parseutils.py @@ -1,6 +1,6 @@ import pytest from mycli.packages.parseutils import ( - extract_tables, query_starts_with, queries_start_with, is_destructive + extract_tables, query_starts_with, queries_start_with, is_destructive, ) diff --git a/test/test_special_iocommands.py b/test/test_special_iocommands.py index c7f802b1..7ca33839 100644 --- a/test/test_special_iocommands.py +++ b/test/test_special_iocommands.py @@ -1,4 +1,6 @@ # coding: utf-8 +from __future__ import unicode_literals + import os import stat import tempfile @@ -232,3 +234,44 @@ def test_asserts(gen): cur=cur)) test_asserts(watch_query('-c {0!s} select 1;'.format(seconds), cur=cur)) + + +def test_split_sql_by_delimiter(): + delimiter = mycli.packages.special.delimiter + for delimiter_str in (';', '$', '😀'): + delimiter.set(delimiter_str) + sql_input = "select 1{} select \ufffc2".format(delimiter_str) + queries = ( + "select 1", + "select \ufffc2" + ) + for query, parsed_query in zip( + queries, delimiter.queries_iter(sql_input)): + assert(query == parsed_query) + + +def test_switch_delimiter_within_query(): + delimiter = mycli.packages.special.delimiter + delimiter.set(';') + sql_input = "select 1; delimiter $$ select 2 $$ select 3 $$" + queries = ( + "select 1", + "delimiter $$ select 2 $$ select 3 $$", + "select 2", + "select 3" + ) + for query, parsed_query in zip( + queries, delimiter.queries_iter(sql_input)): + assert(query == parsed_query) + + +def test_set_delimiter(): + delimiter = mycli.packages.special.delimiter + for delim in ('foo', 'bar'): + delimiter.set(delim) + assert delimiter.current == delim + + +def teardown_function(): + delimiter = mycli.packages.special.delimiter + delimiter.set(';')