Skip to content

Commit a058381

Browse files
frsteFrank Stenzhornamochin
authored
Support splitting SQL strings (#253)
Support splitting SQL strings - similar to SQL script files. Includes a new keyword "Split SQL String" and a new parameter "split" for the existing "Execute SQL String" keyword using internal or external parsers. --------- Co-authored-by: Frank Stenzhorn <[email protected]> Co-authored-by: amochin <[email protected]>
1 parent b1f72e7 commit a058381

22 files changed

+240
-140
lines changed

src/DatabaseLibrary/query.py

Lines changed: 107 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,7 @@ def execute_sql_script(
328328
else:
329329
statements_to_execute = self.split_sql_script(script_path, external_parser=external_parser)
330330
for statement in statements_to_execute:
331-
proc_end_pattern = re.compile("end(?!( if;| loop;| case;| while;| repeat;)).*;()?")
332-
line_ends_with_proc_end = re.compile(r"(\s|;)" + proc_end_pattern.pattern + "$")
333-
omit_semicolon = not line_ends_with_proc_end.search(statement.lower())
331+
omit_semicolon = self._omit_semicolon_needed(statement)
334332
self._execute_sql(cur, statement, omit_semicolon, replace_robot_variables=replace_robot_variables)
335333
self._commit_if_needed(db_connection, no_transaction)
336334
except Exception as e:
@@ -349,73 +347,83 @@ def split_sql_script(
349347
Set ``external_parser`` to _True_ to use the external library [https://pypi.org/project/sqlparse/|sqlparse].
350348
"""
351349
with open(script_path, encoding="UTF-8") as sql_file:
352-
logger.info("Splitting script file into statements...")
353-
statements_to_execute = []
354-
if external_parser:
355-
split_statements = sqlparse.split(sql_file.read())
356-
for statement in split_statements:
357-
statement_without_comments = sqlparse.format(statement, strip_comments=True)
358-
if statement_without_comments:
359-
statements_to_execute.append(statement_without_comments)
360-
else:
361-
current_statement = ""
362-
inside_statements_group = False
363-
proc_start_pattern = re.compile("create( or replace)? (procedure|function){1}( )?")
364-
proc_end_pattern = re.compile("end(?!( if;| loop;| case;| while;| repeat;)).*;()?")
365-
for line in sql_file:
366-
line = line.strip()
367-
if line.startswith("#") or line.startswith("--") or line == "/":
350+
return self.split_sql_string(sql_file.read(), external_parser=external_parser)
351+
352+
def split_sql_string(self, sql_string: str, external_parser=False):
353+
"""
354+
Splits the content of the ``sql_string`` into individual SQL commands
355+
and returns them as a list of strings.
356+
SQL commands are expected to be delimited by a semicolon (';').
357+
358+
Set ``external_parser`` to _True_ to use the external library [https://pypi.org/project/sqlparse/|sqlparse].
359+
"""
360+
logger.info(f"Splitting SQL into statements. Using external parser: {external_parser}")
361+
statements_to_execute = []
362+
if external_parser:
363+
split_statements = sqlparse.split(sql_string)
364+
for statement in split_statements:
365+
statement_without_comments = sqlparse.format(statement, strip_comments=True)
366+
if statement_without_comments:
367+
statements_to_execute.append(statement_without_comments)
368+
else:
369+
current_statement = ""
370+
inside_statements_group = False
371+
proc_start_pattern = re.compile("create( or replace)? (procedure|function){1}( )?")
372+
proc_end_pattern = re.compile("end(?!( if;| loop;| case;| while;| repeat;)).*;()?")
373+
for line in sql_string.splitlines():
374+
line = line.strip()
375+
if line.startswith("#") or line.startswith("--") or line == "/":
376+
continue
377+
378+
# check if the line matches the creating procedure regexp pattern
379+
if proc_start_pattern.match(line.lower()):
380+
inside_statements_group = True
381+
elif line.lower().startswith("begin"):
382+
inside_statements_group = True
383+
384+
# semicolons inside the line? use them to separate statements
385+
# ... but not if they are inside a begin/end block (aka. statements group)
386+
sqlFragments = line.split(";")
387+
# no semicolons
388+
if len(sqlFragments) == 1:
389+
current_statement += line + " "
390+
continue
391+
quotes = 0
392+
# "select * from person;" -> ["select..", ""]
393+
for sqlFragment in sqlFragments:
394+
if len(sqlFragment.strip()) == 0:
368395
continue
369396

370-
# check if the line matches the creating procedure regexp pattern
371-
if proc_start_pattern.match(line.lower()):
397+
if inside_statements_group:
398+
# if statements inside a begin/end block have semicolns,
399+
# they must persist - even with oracle
400+
sqlFragment += "; "
401+
402+
if proc_end_pattern.match(sqlFragment.lower()):
403+
inside_statements_group = False
404+
elif proc_start_pattern.match(sqlFragment.lower()):
372405
inside_statements_group = True
373-
elif line.lower().startswith("begin"):
406+
elif sqlFragment.lower().startswith("begin"):
374407
inside_statements_group = True
375408

376-
# semicolons inside the line? use them to separate statements
377-
# ... but not if they are inside a begin/end block (aka. statements group)
378-
sqlFragments = line.split(";")
379-
# no semicolons
380-
if len(sqlFragments) == 1:
381-
current_statement += line + " "
382-
continue
383-
quotes = 0
384-
# "select * from person;" -> ["select..", ""]
385-
for sqlFragment in sqlFragments:
386-
if len(sqlFragment.strip()) == 0:
387-
continue
388-
389-
if inside_statements_group:
390-
# if statements inside a begin/end block have semicolns,
391-
# they must persist - even with oracle
392-
sqlFragment += "; "
393-
394-
if proc_end_pattern.match(sqlFragment.lower()):
395-
inside_statements_group = False
396-
elif proc_start_pattern.match(sqlFragment.lower()):
397-
inside_statements_group = True
398-
elif sqlFragment.lower().startswith("begin"):
399-
inside_statements_group = True
400-
401-
# check if the semicolon is a part of the value (quoted string)
402-
quotes += sqlFragment.count("'")
403-
quotes -= sqlFragment.count("\\'")
404-
inside_quoted_string = quotes % 2 != 0
405-
if inside_quoted_string:
406-
sqlFragment += ";" # restore the semicolon
407-
408-
current_statement += sqlFragment
409-
if not inside_statements_group and not inside_quoted_string:
410-
statements_to_execute.append(current_statement.strip())
411-
current_statement = ""
412-
quotes = 0
413-
414-
current_statement = current_statement.strip()
415-
if len(current_statement) != 0:
416-
statements_to_execute.append(current_statement)
417-
418-
return statements_to_execute
409+
# check if the semicolon is a part of the value (quoted string)
410+
quotes += sqlFragment.count("'")
411+
quotes -= sqlFragment.count("\\'")
412+
inside_quoted_string = quotes % 2 != 0
413+
if inside_quoted_string:
414+
sqlFragment += ";" # restore the semicolon
415+
416+
current_statement += sqlFragment
417+
if not inside_statements_group and not inside_quoted_string:
418+
statements_to_execute.append(current_statement.strip())
419+
current_statement = ""
420+
quotes = 0
421+
422+
current_statement = current_statement.strip()
423+
if len(current_statement) != 0:
424+
statements_to_execute.append(current_statement)
425+
426+
return statements_to_execute
419427

420428
@renamed_args(
421429
mapping={
@@ -433,12 +441,20 @@ def execute_sql_string(
433441
omit_trailing_semicolon: Optional[bool] = None,
434442
*,
435443
replace_robot_variables=False,
444+
split: bool = False,
445+
external_parser: bool = False,
436446
sqlString: Optional[str] = None,
437447
sansTran: Optional[bool] = None,
438448
omitTrailingSemicolon: Optional[bool] = None,
439449
):
440450
"""
441-
Executes the ``sql_string`` as a single SQL command.
451+
Executes the ``sql_string`` - as a single SQL command (default) or as separate statements.
452+
453+
Set ``split`` to _True_ to enable dividing the string into SQL commands similar to the `Execute SQL Script`
454+
keyword. The commands are expected to be delimited by a semicolon (';') in this case -
455+
they will be split and executed separately.
456+
457+
Set ``external_parser`` to _True_ to use the external library [https://pypi.org/project/sqlparse/|sqlparse] for splitting the script.
442458
443459
Set ``no_transaction`` to _True_ to run command without explicit transaction commit
444460
or rollback in case of error.
@@ -473,13 +489,20 @@ def execute_sql_string(
473489
cur = db_connection.client.cursor()
474490
if omit_trailing_semicolon is None:
475491
omit_trailing_semicolon = db_connection.omit_trailing_semicolon
476-
self._execute_sql(
477-
cur,
478-
sql_string,
479-
omit_trailing_semicolon=omit_trailing_semicolon,
480-
parameters=parameters,
481-
replace_robot_variables=replace_robot_variables,
482-
)
492+
if not split:
493+
self._execute_sql(
494+
cur,
495+
sql_string,
496+
omit_trailing_semicolon=omit_trailing_semicolon,
497+
parameters=parameters,
498+
replace_robot_variables=replace_robot_variables,
499+
)
500+
else:
501+
statements_to_execute = self.split_sql_string(sql_string, external_parser=external_parser)
502+
for statement in statements_to_execute:
503+
omit_semicolon = self._omit_semicolon_needed(statement)
504+
self._execute_sql(cur, statement, omit_semicolon, replace_robot_variables=replace_robot_variables)
505+
483506
self._commit_if_needed(db_connection, no_transaction)
484507
except Exception as e:
485508
self._rollback_and_raise(db_connection, no_transaction, e)
@@ -808,6 +831,17 @@ def set_logging_query_results(self, enabled: Optional[bool] = None, log_head: Op
808831
raise ValueError(f"Wrong log head value provided: {log_head}. The value can't be negative!")
809832
self.LOG_QUERY_RESULTS_HEAD = log_head
810833

834+
def _omit_semicolon_needed(self, statement: str) -> bool:
835+
"""
836+
Checks if the `statement` ends with a procedure ending keyword - so that semicolon should be omitted -
837+
and returns the result.
838+
The function is used when running multiple SQL statements from a script or an SQL string.
839+
"""
840+
proc_end_pattern = re.compile("end(?!( if;| loop;| case;| while;| repeat;)).*;()?")
841+
line_ends_with_proc_end = re.compile(r"(\s|;)" + proc_end_pattern.pattern + "$")
842+
omit_semicolon = not line_ends_with_proc_end.search(statement.lower())
843+
return omit_semicolon
844+
811845
def _execute_sql(
812846
self,
813847
cur,

test/resources/common.resource

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ ${DB_NAME} db
1717
${DB_PASS} pass
1818
${DB_PORT} 5432
1919
${DB_USER} db_user
20-
${Script files dir} ${CURDIR}/script_file_tests
20+
${Script files dir} ${CURDIR}/script_files
2121

2222
# used for MySQL via PyODBC only
2323
${DB_DRIVER} ODBC Driver 18 for SQL Server
@@ -96,9 +96,9 @@ Create Person Table And Insert Data
9696
Insert Data In Person Table Using SQL Script
9797
[Arguments] ${alias}=${None}
9898
IF $alias is None
99-
${output}= Execute SQL Script ${CURDIR}/insert_data_in_person_table.sql
99+
${output}= Execute SQL Script ${Script files dir}/insert_data_in_person_table.sql
100100
ELSE
101-
${output}= Execute SQL Script ${CURDIR}/insert_data_in_person_table.sql alias=${alias}
101+
${output}= Execute SQL Script ${Script files dir}/insert_data_in_person_table.sql alias=${alias}
102102
END
103103
RETURN ${output}
104104

test/resources/create_stored_procedures_mssql.sql renamed to test/resources/script_files/create_stored_procedures_mssql.sql

File renamed without changes.

test/resources/create_stored_procedures_mysql.sql renamed to test/resources/script_files/create_stored_procedures_mysql.sql

File renamed without changes.

test/resources/create_stored_procedures_oracle.sql renamed to test/resources/script_files/create_stored_procedures_oracle.sql

File renamed without changes.

test/resources/create_stored_procedures_postgres.sql renamed to test/resources/script_files/create_stored_procedures_postgres.sql

File renamed without changes.
File renamed without changes.
File renamed without changes.

test/resources/insert_data_in_person_table_utf8.sql renamed to test/resources/script_files/insert_data_in_person_table_utf8.sql

File renamed without changes.

test/resources/script_file_tests/select_with_robot_variables.sql renamed to test/resources/script_files/select_with_robot_variables.sql

File renamed without changes.

0 commit comments

Comments
 (0)