From 72343e167188bb4a170b73a4ea32a832d041a438 Mon Sep 17 00:00:00 2001 From: PApostol Date: Fri, 11 Aug 2023 23:30:00 +0300 Subject: [PATCH 1/5] Sanitize filenames in MySQLHook --- airflow/providers/mysql/hooks/mysql.py | 11 +++++-- tests/providers/mysql/hooks/test_mysql.py | 38 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/airflow/providers/mysql/hooks/mysql.py b/airflow/providers/mysql/hooks/mysql.py index fa011ed35bc10..4ed0c6db58f9e 100644 --- a/airflow/providers/mysql/hooks/mysql.py +++ b/airflow/providers/mysql/hooks/mysql.py @@ -20,6 +20,7 @@ import json import logging +import re from typing import TYPE_CHECKING, Any, Union from airflow.exceptions import AirflowOptionalProviderFeatureException @@ -164,6 +165,10 @@ def _get_conn_config_mysql_connector_python(self, conn: Connection) -> dict: return conn_config + @staticmethod + def _sanitize_filename(filename: str) -> str: + return re.sub(r"(;.*)$", "", filename) + def get_conn(self) -> MySQLConnectionTypes: """ Connection to a MySQL database. @@ -208,7 +213,7 @@ def bulk_load(self, table: str, tmp_file: str) -> None: cur = conn.cursor() cur.execute( f""" - LOAD DATA LOCAL INFILE '{tmp_file}' + LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' INTO TABLE {table} """ ) @@ -221,7 +226,7 @@ def bulk_dump(self, table: str, tmp_file: str) -> None: cur = conn.cursor() cur.execute( f""" - SELECT * INTO OUTFILE '{tmp_file}' + SELECT * INTO OUTFILE '{self._sanitize_filename(tmp_file)}' FROM {table} """ ) @@ -288,7 +293,7 @@ def bulk_load_custom( cursor.execute( f""" - LOAD DATA LOCAL INFILE '{tmp_file}' + LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' {duplicate_key_handling} INTO TABLE {table} {extra_options} diff --git a/tests/providers/mysql/hooks/test_mysql.py b/tests/providers/mysql/hooks/test_mysql.py index 81773e9e3f1ea..2eeac8c4aa3e5 100644 --- a/tests/providers/mysql/hooks/test_mysql.py +++ b/tests/providers/mysql/hooks/test_mysql.py @@ -279,6 +279,15 @@ def test_bulk_load(self): """ ) + def test_bulk_load_with_semicolon_in_filename(self): + self.db_hook.bulk_load("table", "/tmp/file; SELECT * FROM DUAL") + self.cur.execute.assert_called_once_with( + """ + LOAD DATA LOCAL INFILE '/tmp/file' + INTO TABLE table + """ + ) + def test_bulk_dump(self): self.db_hook.bulk_dump("table", "/tmp/file") self.cur.execute.assert_called_once_with( @@ -288,6 +297,15 @@ def test_bulk_dump(self): """ ) + def test_bulk_dump_with_semicolon_in_filename(self): + self.db_hook.bulk_dump("table", "/tmp/file; SELECT * FROM DUAL") + self.cur.execute.assert_called_once_with( + """ + SELECT * INTO OUTFILE '/tmp/file' + FROM table + """ + ) + def test_serialize_cell(self): assert "foo" == self.db_hook._serialize_cell("foo", None) @@ -311,6 +329,26 @@ def test_bulk_load_custom(self): """ ) + def test_bulk_load_custom_with_semicolon_in_filename(self): + self.db_hook.bulk_load_custom( + "table", + "/tmp/file; SELECT * FROM DUAL", + "IGNORE", + """FIELDS TERMINATED BY ';' + OPTIONALLY ENCLOSED BY '"' + IGNORE 1 LINES""", + ) + self.cur.execute.assert_called_once_with( + """ + LOAD DATA LOCAL INFILE '/tmp/file' + IGNORE + INTO TABLE table + FIELDS TERMINATED BY ';' + OPTIONALLY ENCLOSED BY '"' + IGNORE 1 LINES + """ + ) + DEFAULT_DATE = timezone.datetime(2015, 1, 1) DEFAULT_DATE_ISO = DEFAULT_DATE.isoformat() From 572e59cbd96436d1bddb5eb97fc8734e024a9d76 Mon Sep 17 00:00:00 2001 From: PApostol Date: Sun, 13 Aug 2023 23:16:59 +0300 Subject: [PATCH 2/5] Use prepared statements in MySQLHook for bulk operations --- airflow/providers/mysql/hooks/mysql.py | 36 ++++++------ tests/providers/mysql/hooks/test_mysql.py | 68 +++++++---------------- 2 files changed, 36 insertions(+), 68 deletions(-) diff --git a/airflow/providers/mysql/hooks/mysql.py b/airflow/providers/mysql/hooks/mysql.py index 4ed0c6db58f9e..1ced8f30e50cb 100644 --- a/airflow/providers/mysql/hooks/mysql.py +++ b/airflow/providers/mysql/hooks/mysql.py @@ -20,7 +20,6 @@ import json import logging -import re from typing import TYPE_CHECKING, Any, Union from airflow.exceptions import AirflowOptionalProviderFeatureException @@ -165,10 +164,6 @@ def _get_conn_config_mysql_connector_python(self, conn: Connection) -> dict: return conn_config - @staticmethod - def _sanitize_filename(filename: str) -> str: - return re.sub(r"(;.*)$", "", filename) - def get_conn(self) -> MySQLConnectionTypes: """ Connection to a MySQL database. @@ -210,12 +205,13 @@ def get_conn(self) -> MySQLConnectionTypes: def bulk_load(self, table: str, tmp_file: str) -> None: """Load a tab-delimited file into a database table.""" conn = self.get_conn() - cur = conn.cursor() + cur = conn.cursor(prepared=True) cur.execute( - f""" - LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' - INTO TABLE {table} """ + LOAD DATA LOCAL INFILE '%s' + INTO TABLE %s + """, + (tmp_file, table), ) conn.commit() conn.close() # type: ignore[misc] @@ -223,12 +219,13 @@ def bulk_load(self, table: str, tmp_file: str) -> None: def bulk_dump(self, table: str, tmp_file: str) -> None: """Dump a database table into a tab-delimited file.""" conn = self.get_conn() - cur = conn.cursor() + cur = conn.cursor(prepared=True) cur.execute( - f""" - SELECT * INTO OUTFILE '{self._sanitize_filename(tmp_file)}' - FROM {table} """ + SELECT * INTO OUTFILE '%s' + FROM %s + """, + (tmp_file, table), ) conn.commit() conn.close() # type: ignore[misc] @@ -289,15 +286,16 @@ def bulk_load_custom( .. seealso:: https://dev.mysql.com/doc/refman/8.0/en/load-data.html """ conn = self.get_conn() - cursor = conn.cursor() + cursor = conn.cursor(prepared=True) cursor.execute( - f""" - LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' - {duplicate_key_handling} - INTO TABLE {table} - {extra_options} """ + LOAD DATA LOCAL INFILE '%s' + %s + INTO TABLE %s + %s + """, + (tmp_file, duplicate_key_handling, table, extra_options), ) cursor.close() diff --git a/tests/providers/mysql/hooks/test_mysql.py b/tests/providers/mysql/hooks/test_mysql.py index 2eeac8c4aa3e5..8102dc825a123 100644 --- a/tests/providers/mysql/hooks/test_mysql.py +++ b/tests/providers/mysql/hooks/test_mysql.py @@ -274,36 +274,20 @@ def test_bulk_load(self): self.db_hook.bulk_load("table", "/tmp/file") self.cur.execute.assert_called_once_with( """ - LOAD DATA LOCAL INFILE '/tmp/file' - INTO TABLE table - """ - ) - - def test_bulk_load_with_semicolon_in_filename(self): - self.db_hook.bulk_load("table", "/tmp/file; SELECT * FROM DUAL") - self.cur.execute.assert_called_once_with( - """ - LOAD DATA LOCAL INFILE '/tmp/file' - INTO TABLE table - """ + LOAD DATA LOCAL INFILE '%s' + INTO TABLE %s + """, + ("/tmp/file", "table"), ) def test_bulk_dump(self): self.db_hook.bulk_dump("table", "/tmp/file") self.cur.execute.assert_called_once_with( """ - SELECT * INTO OUTFILE '/tmp/file' - FROM table - """ - ) - - def test_bulk_dump_with_semicolon_in_filename(self): - self.db_hook.bulk_dump("table", "/tmp/file; SELECT * FROM DUAL") - self.cur.execute.assert_called_once_with( - """ - SELECT * INTO OUTFILE '/tmp/file' - FROM table - """ + SELECT * INTO OUTFILE '%s' + FROM %s + """, + ("/tmp/file", "table"), ) def test_serialize_cell(self): @@ -320,33 +304,19 @@ def test_bulk_load_custom(self): ) self.cur.execute.assert_called_once_with( """ - LOAD DATA LOCAL INFILE '/tmp/file' - IGNORE - INTO TABLE table - FIELDS TERMINATED BY ';' - OPTIONALLY ENCLOSED BY '"' - IGNORE 1 LINES - """ - ) - - def test_bulk_load_custom_with_semicolon_in_filename(self): - self.db_hook.bulk_load_custom( - "table", - "/tmp/file; SELECT * FROM DUAL", - "IGNORE", - """FIELDS TERMINATED BY ';' + LOAD DATA LOCAL INFILE '%s' + %s + INTO TABLE %s + %s + """, + ( + "/tmp/file", + "IGNORE", + "table", + """FIELDS TERMINATED BY ';' OPTIONALLY ENCLOSED BY '"' IGNORE 1 LINES""", - ) - self.cur.execute.assert_called_once_with( - """ - LOAD DATA LOCAL INFILE '/tmp/file' - IGNORE - INTO TABLE table - FIELDS TERMINATED BY ';' - OPTIONALLY ENCLOSED BY '"' - IGNORE 1 LINES - """ + ), ) From 59615fdccc5958e744a4504e5331c31c26d23207 Mon Sep 17 00:00:00 2001 From: PApostol Date: Fri, 11 Aug 2023 23:30:00 +0300 Subject: [PATCH 3/5] Sanitize filenames in MySQLHook --- airflow/providers/mysql/hooks/mysql.py | 11 +++++-- tests/providers/mysql/hooks/test_mysql.py | 38 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/airflow/providers/mysql/hooks/mysql.py b/airflow/providers/mysql/hooks/mysql.py index d7a1bfdd55368..c6f7c340b35cf 100644 --- a/airflow/providers/mysql/hooks/mysql.py +++ b/airflow/providers/mysql/hooks/mysql.py @@ -20,6 +20,7 @@ import json import logging +import re from typing import TYPE_CHECKING, Any, Union from airflow.exceptions import AirflowOptionalProviderFeatureException @@ -171,6 +172,10 @@ def _get_conn_config_mysql_connector_python(self, conn: Connection) -> dict: return conn_config + @staticmethod + def _sanitize_filename(filename: str) -> str: + return re.sub(r"(;.*)$", "", filename) + def get_conn(self) -> MySQLConnectionTypes: """ Connection to a MySQL database. @@ -215,7 +220,7 @@ def bulk_load(self, table: str, tmp_file: str) -> None: cur = conn.cursor() cur.execute( f""" - LOAD DATA LOCAL INFILE '{tmp_file}' + LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' INTO TABLE {table} """ ) @@ -228,7 +233,7 @@ def bulk_dump(self, table: str, tmp_file: str) -> None: cur = conn.cursor() cur.execute( f""" - SELECT * INTO OUTFILE '{tmp_file}' + SELECT * INTO OUTFILE '{self._sanitize_filename(tmp_file)}' FROM {table} """ ) @@ -295,7 +300,7 @@ def bulk_load_custom( cursor.execute( f""" - LOAD DATA LOCAL INFILE '{tmp_file}' + LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' {duplicate_key_handling} INTO TABLE {table} {extra_options} diff --git a/tests/providers/mysql/hooks/test_mysql.py b/tests/providers/mysql/hooks/test_mysql.py index b4de3ce20f559..cb443a1f0d767 100644 --- a/tests/providers/mysql/hooks/test_mysql.py +++ b/tests/providers/mysql/hooks/test_mysql.py @@ -288,6 +288,15 @@ def test_bulk_load(self): """ ) + def test_bulk_load_with_semicolon_in_filename(self): + self.db_hook.bulk_load("table", "/tmp/file; SELECT * FROM DUAL") + self.cur.execute.assert_called_once_with( + """ + LOAD DATA LOCAL INFILE '/tmp/file' + INTO TABLE table + """ + ) + def test_bulk_dump(self): self.db_hook.bulk_dump("table", "/tmp/file") self.cur.execute.assert_called_once_with( @@ -297,6 +306,15 @@ def test_bulk_dump(self): """ ) + def test_bulk_dump_with_semicolon_in_filename(self): + self.db_hook.bulk_dump("table", "/tmp/file; SELECT * FROM DUAL") + self.cur.execute.assert_called_once_with( + """ + SELECT * INTO OUTFILE '/tmp/file' + FROM table + """ + ) + def test_serialize_cell(self): assert "foo" == self.db_hook._serialize_cell("foo", None) @@ -320,6 +338,26 @@ def test_bulk_load_custom(self): """ ) + def test_bulk_load_custom_with_semicolon_in_filename(self): + self.db_hook.bulk_load_custom( + "table", + "/tmp/file; SELECT * FROM DUAL", + "IGNORE", + """FIELDS TERMINATED BY ';' + OPTIONALLY ENCLOSED BY '"' + IGNORE 1 LINES""", + ) + self.cur.execute.assert_called_once_with( + """ + LOAD DATA LOCAL INFILE '/tmp/file' + IGNORE + INTO TABLE table + FIELDS TERMINATED BY ';' + OPTIONALLY ENCLOSED BY '"' + IGNORE 1 LINES + """ + ) + DEFAULT_DATE = timezone.datetime(2015, 1, 1) DEFAULT_DATE_ISO = DEFAULT_DATE.isoformat() From af7693b875ff7e2593f2abe257d15e87baf0fdd3 Mon Sep 17 00:00:00 2001 From: PApostol Date: Sun, 13 Aug 2023 23:16:59 +0300 Subject: [PATCH 4/5] Use prepared statements in MySQLHook for bulk operations --- airflow/providers/mysql/hooks/mysql.py | 36 ++++++------ tests/providers/mysql/hooks/test_mysql.py | 68 +++++++---------------- 2 files changed, 36 insertions(+), 68 deletions(-) diff --git a/airflow/providers/mysql/hooks/mysql.py b/airflow/providers/mysql/hooks/mysql.py index c6f7c340b35cf..7c3deedd0b50c 100644 --- a/airflow/providers/mysql/hooks/mysql.py +++ b/airflow/providers/mysql/hooks/mysql.py @@ -20,7 +20,6 @@ import json import logging -import re from typing import TYPE_CHECKING, Any, Union from airflow.exceptions import AirflowOptionalProviderFeatureException @@ -172,10 +171,6 @@ def _get_conn_config_mysql_connector_python(self, conn: Connection) -> dict: return conn_config - @staticmethod - def _sanitize_filename(filename: str) -> str: - return re.sub(r"(;.*)$", "", filename) - def get_conn(self) -> MySQLConnectionTypes: """ Connection to a MySQL database. @@ -217,12 +212,13 @@ def get_conn(self) -> MySQLConnectionTypes: def bulk_load(self, table: str, tmp_file: str) -> None: """Load a tab-delimited file into a database table.""" conn = self.get_conn() - cur = conn.cursor() + cur = conn.cursor(prepared=True) cur.execute( - f""" - LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' - INTO TABLE {table} """ + LOAD DATA LOCAL INFILE '%s' + INTO TABLE %s + """, + (tmp_file, table), ) conn.commit() conn.close() # type: ignore[misc] @@ -230,12 +226,13 @@ def bulk_load(self, table: str, tmp_file: str) -> None: def bulk_dump(self, table: str, tmp_file: str) -> None: """Dump a database table into a tab-delimited file.""" conn = self.get_conn() - cur = conn.cursor() + cur = conn.cursor(prepared=True) cur.execute( - f""" - SELECT * INTO OUTFILE '{self._sanitize_filename(tmp_file)}' - FROM {table} """ + SELECT * INTO OUTFILE '%s' + FROM %s + """, + (tmp_file, table), ) conn.commit() conn.close() # type: ignore[misc] @@ -296,15 +293,16 @@ def bulk_load_custom( .. seealso:: https://dev.mysql.com/doc/refman/8.0/en/load-data.html """ conn = self.get_conn() - cursor = conn.cursor() + cursor = conn.cursor(prepared=True) cursor.execute( - f""" - LOAD DATA LOCAL INFILE '{self._sanitize_filename(tmp_file)}' - {duplicate_key_handling} - INTO TABLE {table} - {extra_options} """ + LOAD DATA LOCAL INFILE '%s' + %s + INTO TABLE %s + %s + """, + (tmp_file, duplicate_key_handling, table, extra_options), ) cursor.close() diff --git a/tests/providers/mysql/hooks/test_mysql.py b/tests/providers/mysql/hooks/test_mysql.py index cb443a1f0d767..6dfb873efd466 100644 --- a/tests/providers/mysql/hooks/test_mysql.py +++ b/tests/providers/mysql/hooks/test_mysql.py @@ -283,36 +283,20 @@ def test_bulk_load(self): self.db_hook.bulk_load("table", "/tmp/file") self.cur.execute.assert_called_once_with( """ - LOAD DATA LOCAL INFILE '/tmp/file' - INTO TABLE table - """ - ) - - def test_bulk_load_with_semicolon_in_filename(self): - self.db_hook.bulk_load("table", "/tmp/file; SELECT * FROM DUAL") - self.cur.execute.assert_called_once_with( - """ - LOAD DATA LOCAL INFILE '/tmp/file' - INTO TABLE table - """ + LOAD DATA LOCAL INFILE '%s' + INTO TABLE %s + """, + ("/tmp/file", "table"), ) def test_bulk_dump(self): self.db_hook.bulk_dump("table", "/tmp/file") self.cur.execute.assert_called_once_with( """ - SELECT * INTO OUTFILE '/tmp/file' - FROM table - """ - ) - - def test_bulk_dump_with_semicolon_in_filename(self): - self.db_hook.bulk_dump("table", "/tmp/file; SELECT * FROM DUAL") - self.cur.execute.assert_called_once_with( - """ - SELECT * INTO OUTFILE '/tmp/file' - FROM table - """ + SELECT * INTO OUTFILE '%s' + FROM %s + """, + ("/tmp/file", "table"), ) def test_serialize_cell(self): @@ -329,33 +313,19 @@ def test_bulk_load_custom(self): ) self.cur.execute.assert_called_once_with( """ - LOAD DATA LOCAL INFILE '/tmp/file' - IGNORE - INTO TABLE table - FIELDS TERMINATED BY ';' - OPTIONALLY ENCLOSED BY '"' - IGNORE 1 LINES - """ - ) - - def test_bulk_load_custom_with_semicolon_in_filename(self): - self.db_hook.bulk_load_custom( - "table", - "/tmp/file; SELECT * FROM DUAL", - "IGNORE", - """FIELDS TERMINATED BY ';' + LOAD DATA LOCAL INFILE '%s' + %s + INTO TABLE %s + %s + """, + ( + "/tmp/file", + "IGNORE", + "table", + """FIELDS TERMINATED BY ';' OPTIONALLY ENCLOSED BY '"' IGNORE 1 LINES""", - ) - self.cur.execute.assert_called_once_with( - """ - LOAD DATA LOCAL INFILE '/tmp/file' - IGNORE - INTO TABLE table - FIELDS TERMINATED BY ';' - OPTIONALLY ENCLOSED BY '"' - IGNORE 1 LINES - """ + ), ) From 076449d36f160c36736a709f23a57547cccac497 Mon Sep 17 00:00:00 2001 From: PApostol Date: Wed, 3 Jan 2024 16:52:48 +0200 Subject: [PATCH 5/5] Fix code & tests --- airflow/providers/mysql/hooks/mysql.py | 29 +++++++---------------- tests/providers/mysql/hooks/test_mysql.py | 29 ++++------------------- 2 files changed, 13 insertions(+), 45 deletions(-) diff --git a/airflow/providers/mysql/hooks/mysql.py b/airflow/providers/mysql/hooks/mysql.py index 7c3deedd0b50c..00ff92b62cf01 100644 --- a/airflow/providers/mysql/hooks/mysql.py +++ b/airflow/providers/mysql/hooks/mysql.py @@ -212,13 +212,10 @@ def get_conn(self) -> MySQLConnectionTypes: def bulk_load(self, table: str, tmp_file: str) -> None: """Load a tab-delimited file into a database table.""" conn = self.get_conn() - cur = conn.cursor(prepared=True) + cur = conn.cursor() cur.execute( - """ - LOAD DATA LOCAL INFILE '%s' - INTO TABLE %s - """, - (tmp_file, table), + f"LOAD DATA LOCAL INFILE %s INTO TABLE {table}", + (tmp_file,), ) conn.commit() conn.close() # type: ignore[misc] @@ -226,13 +223,10 @@ def bulk_load(self, table: str, tmp_file: str) -> None: def bulk_dump(self, table: str, tmp_file: str) -> None: """Dump a database table into a tab-delimited file.""" conn = self.get_conn() - cur = conn.cursor(prepared=True) + cur = conn.cursor() cur.execute( - """ - SELECT * INTO OUTFILE '%s' - FROM %s - """, - (tmp_file, table), + f"SELECT * INTO OUTFILE %s FROM {table}", + (tmp_file,), ) conn.commit() conn.close() # type: ignore[misc] @@ -293,16 +287,11 @@ def bulk_load_custom( .. seealso:: https://dev.mysql.com/doc/refman/8.0/en/load-data.html """ conn = self.get_conn() - cursor = conn.cursor(prepared=True) + cursor = conn.cursor() cursor.execute( - """ - LOAD DATA LOCAL INFILE '%s' - %s - INTO TABLE %s - %s - """, - (tmp_file, duplicate_key_handling, table, extra_options), + f"LOAD DATA LOCAL INFILE %s %s INTO TABLE {table} %s", + (tmp_file, duplicate_key_handling, extra_options), ) cursor.close() diff --git a/tests/providers/mysql/hooks/test_mysql.py b/tests/providers/mysql/hooks/test_mysql.py index 6dfb873efd466..271e249193c26 100644 --- a/tests/providers/mysql/hooks/test_mysql.py +++ b/tests/providers/mysql/hooks/test_mysql.py @@ -281,23 +281,11 @@ def test_run_multi_queries(self): def test_bulk_load(self): self.db_hook.bulk_load("table", "/tmp/file") - self.cur.execute.assert_called_once_with( - """ - LOAD DATA LOCAL INFILE '%s' - INTO TABLE %s - """, - ("/tmp/file", "table"), - ) + self.cur.execute.assert_called_once_with("LOAD DATA LOCAL INFILE %s INTO TABLE table", ("/tmp/file",)) def test_bulk_dump(self): self.db_hook.bulk_dump("table", "/tmp/file") - self.cur.execute.assert_called_once_with( - """ - SELECT * INTO OUTFILE '%s' - FROM %s - """, - ("/tmp/file", "table"), - ) + self.cur.execute.assert_called_once_with("SELECT * INTO OUTFILE %s FROM table", ("/tmp/file",)) def test_serialize_cell(self): assert "foo" == self.db_hook._serialize_cell("foo", None) @@ -312,16 +300,10 @@ def test_bulk_load_custom(self): IGNORE 1 LINES""", ) self.cur.execute.assert_called_once_with( - """ - LOAD DATA LOCAL INFILE '%s' - %s - INTO TABLE %s - %s - """, + "LOAD DATA LOCAL INFILE %s %s INTO TABLE table %s", ( "/tmp/file", "IGNORE", - "table", """FIELDS TERMINATED BY ';' OPTIONALLY ENCLOSED BY '"' IGNORE 1 LINES""", @@ -420,8 +402,5 @@ def test_mysql_hook_test_bulk_dump_mock(self, mock_get_conn, client): hook.bulk_dump(table, tmp_file) assert mock_execute.call_count == 1 - query = f""" - SELECT * INTO OUTFILE '{tmp_file}' - FROM {table} - """ + query = f"SELECT * INTO OUTFILE %s FROM {table}" assert_equal_ignore_multiple_spaces(mock_execute.call_args.args[0], query)