Repository: aws/aws-sdk-pandas

Issue:
Support `to_sql` over Data API
**Is your feature request related to a problem? Please describe.**
Can't use Data API to write a data frame to an Aurora db

**Describe the solution you'd like**
Support for `to_sql` over the Data API, similar to regular Postgres:
https://github.com/awslabs/aws-data-wrangler/blob/7441c4fba88b49abe47d17d6b1dd63f9f0199dde/awswrangler/postgresql.py#L282-L293

With the Data API connector added in #828, I suppose most of the same logic could be used. Although I'm not fully aware of the Data APIs limitations around record size and result set limits. 


Discussion:
Looking into it further, probably more of the DB-API 2.0 methods would need to be implemented, expanding support from just `cursor.execute()` now. 

Something similar has been done already in this project: https://github.com/cloud-utils/aurora-data-api/blob/main/aurora_data_api/__init__.py
This issue requires triage and should be assigned.

Resolving patch:
diff --git a/awswrangler/data_api/_connector.py b/awswrangler/data_api/_connector.py
--- a/awswrangler/data_api/_connector.py
+++ b/awswrangler/data_api/_connector.py
@@ -1,18 +1,24 @@
 """Data API Connector base class."""
-import logging
-from typing import Any, Dict, Optional
+import datetime as dt
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from decimal import Decimal
+from types import TracebackType
+from typing import Any, Dict, List, Optional, Type, Union
 
 import awswrangler.pandas as pd
 
 
-class DataApiConnector:
+class DataApiConnector(ABC):
     """Base class for Data API (RDS, Redshift, etc.) connectors."""
 
-    def __init__(self, client: Any, logger: logging.Logger):
-        self.client = client
-        self.logger: logging.Logger = logger
-
-    def execute(self, sql: str, database: Optional[str] = None) -> pd.DataFrame:
+    def execute(
+        self,
+        sql: str,
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameters: Optional[List[Dict[str, Any]]] = None,
+    ) -> pd.DataFrame:
         """Execute SQL statement against a Data API Service.
 
         Parameters
@@ -24,17 +30,84 @@ def execute(self, sql: str, database: Optional[str] = None) -> pd.DataFrame:
         -------
         A Pandas DataFrame containing the execution results.
         """
-        request_id: str = self._execute_statement(sql, database=database)
+        request_id: str = self._execute_statement(
+            sql, database=database, transaction_id=transaction_id, parameters=parameters
+        )
         return self._get_statement_result(request_id)
 
-    def _execute_statement(self, sql: str, database: Optional[str] = None) -> str:
-        raise NotImplementedError()
+    def batch_execute(
+        self,
+        sql: Union[str, List[str]],
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameter_sets: Optional[List[List[Dict[str, Any]]]] = None,
+    ) -> None:
+        """Batch execute SQL statements against a Data API Service.
 
+        Parameters
+        ----------
+        sql: str
+            SQL statement to execute.
+        """
+        self._batch_execute_statement(
+            sql, database=database, transaction_id=transaction_id, parameter_sets=parameter_sets
+        )
+
+    def __enter__(self) -> "DataApiConnector":
+        return self
+
+    @abstractmethod
+    def close(self) -> None:
+        """Close underlying endpoint connections."""
+        pass
+
+    def __exit__(
+        self,
+        exception_type: Optional[Type[BaseException]],
+        exception_value: Optional[BaseException],
+        traceback: Optional[TracebackType],
+    ) -> Optional[bool]:
+        self.close()
+        return None
+
+    @abstractmethod
+    def begin_transaction(self, database: Optional[str] = None, schema: Optional[str] = None) -> str:
+        pass
+
+    @abstractmethod
+    def commit_transaction(self, transaction_id: str) -> str:
+        pass
+
+    @abstractmethod
+    def rollback_transaction(self, transaction_id: str) -> str:
+        pass
+
+    @abstractmethod
+    def _execute_statement(
+        self,
+        sql: str,
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameters: Optional[List[Dict[str, Any]]] = None,
+    ) -> str:
+        pass
+
+    @abstractmethod
+    def _batch_execute_statement(
+        self,
+        sql: Union[str, List[str]],
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameter_sets: Optional[List[List[Dict[str, Any]]]] = None,
+    ) -> str:
+        pass
+
+    @abstractmethod
     def _get_statement_result(self, request_id: str) -> pd.DataFrame:
-        raise NotImplementedError()
+        pass
 
     @staticmethod
-    def _get_column_value(column_value: Dict[str, Any]) -> Any:
+    def _get_column_value(column_value: Dict[str, Any], col_type: Optional[str] = None) -> Any:
         """Return the first non-null key value for a given dictionary.
 
         The key names for a given record depend on the column type: stringValue, longValue, etc.
@@ -60,14 +133,28 @@ def _get_column_value(column_value: Dict[str, Any]) -> Any:
                     return None
                 if key == "arrayValue":
                     raise ValueError(f"arrayValue not supported yet - could not extract {column_value[key]}")
+
+                if key == "stringValue":
+                    if col_type == "DATETIME":
+                        return dt.datetime.strptime(column_value[key], "%Y-%m-%d %H:%M:%S")
+
+                    if col_type == "DATE":
+                        return dt.datetime.strptime(column_value[key], "%Y-%m-%d").date()
+
+                    if col_type == "TIME":
+                        return dt.datetime.strptime(column_value[key], "%H:%M:%S").time()
+
+                    if col_type == "DECIMAL":
+                        return Decimal(column_value[key])
+
                 return column_value[key]
         return None
 
 
+@dataclass
 class WaitConfig:
     """Holds standard wait configuration values."""
 
-    def __init__(self, sleep: float, backoff: float, retries: int) -> None:
-        self.sleep = sleep
-        self.backoff = backoff
-        self.retries = retries
+    sleep: float
+    backoff: float
+    retries: int
diff --git a/awswrangler/data_api/rds.py b/awswrangler/data_api/rds.py
--- a/awswrangler/data_api/rds.py
+++ b/awswrangler/data_api/rds.py
@@ -1,15 +1,30 @@
 """RDS Data API Connector."""
+import datetime as dt
 import logging
 import time
 import uuid
-from typing import Any, Dict, List, Optional
+from decimal import Decimal
+from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union, cast
 
 import boto3
+from typing_extensions import Literal
 
 import awswrangler.pandas as pd
-from awswrangler import _utils
+from awswrangler import _data_types, _databases, _utils, exceptions
 from awswrangler.data_api import _connector
 
+if TYPE_CHECKING:
+    from mypy_boto3_rds_data.client import BotocoreClientError
+    from mypy_boto3_rds_data.type_defs import BatchExecuteStatementResponseTypeDef, ExecuteStatementResponseTypeDef
+
+
+_logger = logging.getLogger(__name__)
+
+
+_ExecuteStatementResponseType = TypeVar(
+    "_ExecuteStatementResponseType", "ExecuteStatementResponseTypeDef", "BatchExecuteStatementResponseTypeDef"
+)
+
 
 class RdsDataApi(_connector.DataApiConnector):
     """Provides access to the RDS Data API.
@@ -42,42 +57,78 @@ def __init__(
         retries: int = 30,
         boto3_session: Optional[boto3.Session] = None,
     ) -> None:
+        super().__init__()
+
+        self.client = _utils.client(service_name="rds-data", session=boto3_session)
+
         self.resource_arn = resource_arn
         self.database = database
         self.secret_arn = secret_arn
         self.wait_config = _connector.WaitConfig(sleep, backoff, retries)
-        self.client = _utils.client(service_name="rds-data", session=boto3_session)
-        self.results: Dict[str, Dict[str, Any]] = {}
-        logger: logging.Logger = logging.getLogger(__name__)
-        super().__init__(self.client, logger)
+        self.results: Dict[str, Union["ExecuteStatementResponseTypeDef", "BatchExecuteStatementResponseTypeDef"]] = {}
 
-    def _execute_statement(self, sql: str, database: Optional[str] = None) -> str:
+    def close(self) -> None:
+        """Close underlying endpoint connections."""
+        self.client.close()
+
+    def begin_transaction(self, database: Optional[str] = None, schema: Optional[str] = None) -> str:
+        """Start an SQL transaction."""
         if database is None:
             database = self.database
 
+        kwargs = {}
+        if database:
+            kwargs["database"] = database
+        if schema:
+            kwargs["schema"] = schema
+
+        response = self.client.begin_transaction(
+            resourceArn=self.resource_arn,
+            secretArn=self.secret_arn,
+            **kwargs,
+        )
+        return response["transactionId"]
+
+    def commit_transaction(self, transaction_id: str) -> str:
+        """Commit an SQL transaction."""
+        response = self.client.commit_transaction(
+            resourceArn=self.resource_arn,
+            secretArn=self.secret_arn,
+            transactionId=transaction_id,
+        )
+        return response["transactionStatus"]
+
+    def rollback_transaction(self, transaction_id: str) -> str:
+        """Roll back an SQL transaction."""
+        response = self.client.rollback_transaction(
+            resourceArn=self.resource_arn,
+            secretArn=self.secret_arn,
+            transactionId=transaction_id,
+        )
+        return response["transactionStatus"]
+
+    def _execute_with_retry(
+        self,
+        sql: str,
+        function: Callable[[str], _ExecuteStatementResponseType],
+    ) -> str:
         sleep: float = self.wait_config.sleep
         total_tries: int = 0
         total_sleep: float = 0
-        response: Optional[Dict[str, Any]] = None
-        last_exception: Optional[Exception] = None
+        response: Optional[_ExecuteStatementResponseType] = None
+        last_exception: Optional["BotocoreClientError"] = None
         while total_tries < self.wait_config.retries:
             try:
-                response = self.client.execute_statement(
-                    resourceArn=self.resource_arn,
-                    database=database,
-                    sql=sql,
-                    secretArn=self.secret_arn,
-                    includeResultMetadata=True,
-                )
-                self.logger.debug(
+                response = function(sql)
+                _logger.debug(
                     "Response received after %s tries and sleeping for a total of %s seconds", total_tries, total_sleep
                 )
                 break
             except self.client.exceptions.BadRequestException as exception:
                 last_exception = exception
                 total_sleep += sleep
-                self.logger.debug("BadRequestException occurred: %s", exception)
-                self.logger.debug(
+                _logger.debug("BadRequestException occurred: %s", exception)
+                _logger.debug(
                     "Cluster may be paused - sleeping for %s seconds for a total of %s before retrying",
                     sleep,
                     total_sleep,
@@ -87,16 +138,74 @@ def _execute_statement(self, sql: str, database: Optional[str] = None) -> str:
                 sleep *= self.wait_config.backoff
 
         if response is None:
-            self.logger.exception("Maximum BadRequestException retries reached for query %s", sql)
+            _logger.exception("Maximum BadRequestException retries reached for query %s", sql)
             raise last_exception  # type: ignore[misc]
 
         request_id: str = uuid.uuid4().hex
         self.results[request_id] = response
         return request_id
 
+    def _execute_statement(
+        self,
+        sql: str,
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameters: Optional[List[Dict[str, Any]]] = None,
+    ) -> str:
+        if database is None:
+            database = self.database
+
+        additional_kwargs: Dict[str, Any] = {}
+        if transaction_id:
+            additional_kwargs["transactionId"] = transaction_id
+        if parameters:
+            additional_kwargs["parameters"] = parameters
+
+        def function(sql: str) -> "ExecuteStatementResponseTypeDef":
+            return self.client.execute_statement(
+                resourceArn=self.resource_arn,
+                database=database,  # type: ignore[arg-type]
+                sql=sql,
+                secretArn=self.secret_arn,
+                includeResultMetadata=True,
+                **additional_kwargs,
+            )
+
+        return self._execute_with_retry(sql=sql, function=function)
+
+    def _batch_execute_statement(
+        self,
+        sql: Union[str, List[str]],
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameter_sets: Optional[List[List[Dict[str, Any]]]] = None,
+    ) -> str:
+        if isinstance(sql, list):
+            raise exceptions.InvalidArgumentType("`sql` parameter cannot be list.")
+
+        if database is None:
+            database = self.database
+
+        additional_kwargs: Dict[str, Any] = {}
+        if transaction_id:
+            additional_kwargs["transactionId"] = transaction_id
+        if parameter_sets:
+            additional_kwargs["parameterSets"] = parameter_sets
+
+        def function(sql: str) -> "BatchExecuteStatementResponseTypeDef":
+            return self.client.batch_execute_statement(
+                resourceArn=self.resource_arn,
+                database=database,  # type: ignore[arg-type]
+                sql=sql,
+                secretArn=self.secret_arn,
+                **additional_kwargs,
+            )
+
+        return self._execute_with_retry(sql=sql, function=function)
+
     def _get_statement_result(self, request_id: str) -> pd.DataFrame:
         try:
-            result = self.results.pop(request_id)
+            result = cast("ExecuteStatementResponseTypeDef", self.results.pop(request_id))
         except KeyError as exception:
             raise KeyError(f"Request {request_id} not found in results {self.results}") from exception
 
@@ -104,10 +213,12 @@ def _get_statement_result(self, request_id: str) -> pd.DataFrame:
             return pd.DataFrame()
 
         rows: List[List[Any]] = []
+        column_types = [col.get("typeName") for col in result["columnMetadata"]]
+
         for record in result["records"]:
             row: List[Any] = [
-                _connector.DataApiConnector._get_column_value(column)  # pylint: disable=protected-access
-                for column in record
+                _connector.DataApiConnector._get_column_value(column, col_type)  # type: ignore[arg-type]  # pylint: disable=protected-access
+                for column, col_type in zip(record, column_types)
             ]
             rows.append(row)
 
@@ -158,3 +269,204 @@ def read_sql_query(sql: str, con: RdsDataApi, database: Optional[str] = None) ->
     A Pandas DataFrame containing the query results.
     """
     return con.execute(sql, database=database)
+
+
+def _drop_table(con: RdsDataApi, table: str, database: str, transaction_id: str) -> None:
+    sql = f"DROP TABLE IF EXISTS `{table}`"
+    _logger.debug("Drop table query:\n%s", sql)
+    con.execute(sql, database=database, transaction_id=transaction_id)
+
+
+def _does_table_exist(con: RdsDataApi, table: str, database: str, transaction_id: str) -> bool:
+    res = con.execute(f"SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = '{table}'")
+    return not res.empty
+
+
+def _create_table(
+    df: pd.DataFrame,
+    con: RdsDataApi,
+    table: str,
+    database: str,
+    transaction_id: str,
+    mode: str,
+    index: bool,
+    dtype: Optional[Dict[str, str]],
+    varchar_lengths: Optional[Dict[str, int]],
+) -> None:
+    if mode == "overwrite":
+        _drop_table(con=con, table=table, database=database, transaction_id=transaction_id)
+    elif _does_table_exist(con=con, table=table, database=database, transaction_id=transaction_id):
+        return
+
+    mysql_types: Dict[str, str] = _data_types.database_types_from_pandas(
+        df=df,
+        index=index,
+        dtype=dtype,
+        varchar_lengths_default="TEXT",
+        varchar_lengths=varchar_lengths,
+        converter_func=_data_types.pyarrow2mysql,
+    )
+    cols_str: str = "".join([f"`{k}` {v},\n" for k, v in mysql_types.items()])[:-2]
+    sql = f"CREATE TABLE IF NOT EXISTS `{table}` (\n{cols_str})"
+
+    _logger.debug("Create table query:\n%s", sql)
+    con.execute(sql, database=database, transaction_id=transaction_id)
+
+
+def _create_value_dict(value: Any) -> Tuple[Dict[str, Any], Optional[str]]:
+    if value is None or pd.isnull(value):
+        return {"isNull": True}, None
+
+    if isinstance(value, bool):
+        return {"booleanValue": value}, None
+
+    if isinstance(value, int):
+        return {"longValue": value}, None
+
+    if isinstance(value, float):
+        return {"doubleValue": value}, None
+
+    if isinstance(value, str):
+        return {"stringValue": value}, None
+
+    if isinstance(value, bytes):
+        return {"blobValue": value}, None
+
+    if isinstance(value, dt.datetime):
+        return {"stringValue": str(value)}, "TIMESTAMP"
+
+    if isinstance(value, dt.date):
+        return {"stringValue": str(value)}, "DATE"
+
+    if isinstance(value, dt.time):
+        return {"stringValue": str(value)}, "TIME"
+
+    if isinstance(value, Decimal):
+        return {"stringValue": str(value)}, "DECIMAL"
+
+    raise exceptions.InvalidArgumentType(f"Value {value} not supported.")
+
+
+def _generate_parameters(columns: List[str], values: List[Any]) -> List[Dict[str, Any]]:
+    parameter_list = []
+
+    for col, value in zip(columns, values):
+        value, type_hint = _create_value_dict(value)
+
+        parameter = {
+            "name": col,
+            "value": value,
+        }
+        if type_hint:
+            parameter["typeHint"] = type_hint
+
+        parameter_list.append(parameter)
+
+    return parameter_list
+
+
+def _generate_parameter_sets(df: pd.DataFrame) -> List[List[Dict[str, Any]]]:
+    parameter_sets = []
+
+    columns = df.columns.tolist()
+    for values in df.values.tolist():
+        parameter_sets.append(_generate_parameters(columns, values))
+
+    return parameter_sets
+
+
+def to_sql(
+    df: pd.DataFrame,
+    con: RdsDataApi,
+    table: str,
+    database: str,
+    mode: Literal["append", "overwrite"] = "append",
+    index: bool = False,
+    dtype: Optional[Dict[str, str]] = None,
+    varchar_lengths: Optional[Dict[str, int]] = None,
+    use_column_names: bool = False,
+    chunksize: int = 200,
+) -> None:
+    """
+    Insert data using an SQL query on a Data API connection.
+
+    Parameters
+    ----------
+    df: pandas.DataFrame
+        `Pandas DataFrame <https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html>`_
+    con: RdsDataApi
+        A RdsDataApi connection instance
+    database: str
+        Database to run query on - defaults to the database specified by `con`.
+    table: str
+        Table name
+    mode: str
+        append, overwrite
+            append: Inserts new records into table.
+            overwrite: Drops table and recreates.
+    index: bool
+        True to store the DataFrame index as a column in the table,
+        otherwise False to ignore it.
+    dtype: Dict[str, str], optional
+        Dictionary of columns names and MySQL types to be casted.
+        Useful when you have columns with undetermined or mixed data types.
+        (e.g. {'col name': 'TEXT', 'col2 name': 'FLOAT'})
+    varchar_lengths : Dict[str, int], optional
+        Dict of VARCHAR length by columns. (e.g. {"col1": 10, "col5": 200}).
+    use_column_names: bool
+        If set to True, will use the column names of the DataFrame for generating the INSERT SQL Query.
+        E.g. If the DataFrame has two columns `col1` and `col3` and `use_column_names` is True, data will only be
+        inserted into the database columns `col1` and `col3`.
+    chunksize: int
+        Number of rows which are inserted with each SQL query. Defaults to inserting 200 rows per query.
+    """
+    if df.empty is True:
+        raise exceptions.EmptyDataFrame("DataFrame cannot be empty.")
+
+    _databases.validate_mode(mode=mode, allowed_modes=["append", "overwrite"])
+
+    transaction_id: Optional[str] = None
+    try:
+        transaction_id = con.begin_transaction(database=database)
+
+        _create_table(
+            df=df,
+            con=con,
+            table=table,
+            database=database,
+            transaction_id=transaction_id,
+            mode=mode,
+            index=index,
+            dtype=dtype,
+            varchar_lengths=varchar_lengths,
+        )
+
+        if index:
+            df = df.reset_index(level=df.index.names)
+
+        if use_column_names:
+            insertion_columns = "(" + ", ".join([f"`{col}`" for col in df.columns]) + ")"
+        else:
+            insertion_columns = ""
+
+        placeholders = ", ".join([f":{col}" for col in df.columns])
+
+        sql = f"""INSERT INTO `{table}` {insertion_columns} VALUES ({placeholders})"""
+        parameter_sets = _generate_parameter_sets(df)
+
+        for parameter_sets_chunk in _utils.chunkify(parameter_sets, max_length=chunksize):
+            con.batch_execute(
+                sql=sql,
+                database=database,
+                transaction_id=transaction_id,
+                parameter_sets=parameter_sets_chunk,
+            )
+
+    except Exception as ex:
+        if transaction_id:
+            con.rollback_transaction(transaction_id=transaction_id)
+        _logger.error(ex)
+        raise
+
+    else:
+        con.commit_transaction(transaction_id=transaction_id)
diff --git a/awswrangler/data_api/redshift.py b/awswrangler/data_api/redshift.py
--- a/awswrangler/data_api/redshift.py
+++ b/awswrangler/data_api/redshift.py
@@ -1,14 +1,21 @@
 """Redshift Data API Connector."""
 import logging
 import time
-from typing import Any, Dict, List, Optional
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
 
 import boto3
 
 import awswrangler.pandas as pd
-from awswrangler import _utils
+from awswrangler import _utils, exceptions
 from awswrangler.data_api import _connector
 
+if TYPE_CHECKING:
+    from mypy_boto3_redshift_data.client import RedshiftDataAPIServiceClient
+    from mypy_boto3_redshift_data.type_defs import ColumnMetadataTypeDef
+
+
+_logger = logging.getLogger(__name__)
+
 
 class RedshiftDataApi(_connector.DataApiConnector):
     """Provides access to a Redshift cluster via the Data API.
@@ -52,15 +59,32 @@ def __init__(
         retries: int = 15,
         boto3_session: Optional[boto3.Session] = None,
     ) -> None:
+        super().__init__()
+
+        self.client = _utils.client(service_name="redshift-data", session=boto3_session)
+
         self.cluster_id = cluster_id
         self.database = database
         self.workgroup_name = workgroup_name
         self.secret_arn = secret_arn
         self.db_user = db_user
-        self.client = _utils.client(service_name="redshift-data", session=boto3_session)
         self.waiter = RedshiftDataApiWaiter(self.client, sleep, backoff, retries)
-        logger: logging.Logger = logging.getLogger(__name__)
-        super().__init__(self.client, logger)
+
+    def close(self) -> None:
+        """Close underlying endpoint connections."""
+        self.client.close()
+
+    def begin_transaction(self, database: Optional[str] = None, schema: Optional[str] = None) -> str:
+        """Start an SQL transaction."""
+        raise NotImplementedError("Redshift Data API does not support transactions.")
+
+    def commit_transaction(self, transaction_id: str) -> str:
+        """Commit an SQL transaction."""
+        raise NotImplementedError("Redshift Data API does not support transactions.")
+
+    def rollback_transaction(self, transaction_id: str) -> str:
+        """Roll back an SQL transaction."""
+        raise NotImplementedError("Redshift Data API does not support transactions.")
 
     def _validate_redshift_target(self) -> None:
         if self.database == "":
@@ -72,7 +96,18 @@ def _validate_auth_method(self) -> None:
         if self.workgroup_name == "" and self.secret_arn == "" and self.db_user == "":
             raise ValueError("Either `secret_arn` or `db_user` must be set for authentication")
 
-    def _execute_statement(self, sql: str, database: Optional[str] = None) -> str:
+    def _execute_statement(
+        self,
+        sql: str,
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameters: Optional[List[Dict[str, Any]]] = None,
+    ) -> str:
+        if transaction_id:
+            exceptions.InvalidArgument("`transaction_id` not supported for Redshift Data API")
+        if parameters:
+            exceptions.InvalidArgument("`parameters` not supported for Redshift Data API")
+
         self._validate_redshift_target()
         self._validate_auth_method()
         credentials = {}
@@ -89,32 +124,40 @@ def _execute_statement(self, sql: str, database: Optional[str] = None) -> str:
         elif self.workgroup_name:
             redshift_target = {"WorkgroupName": self.workgroup_name}
 
-        self.logger.debug("Executing %s", sql)
+        _logger.debug("Executing %s", sql)
         response = self.client.execute_statement(
-            **redshift_target,
+            **redshift_target,  # type: ignore[arg-type]
             Database=database,
             Sql=sql,
-            **credentials,
+            **credentials,  # type: ignore[arg-type]
         )
-        return str(response["Id"])
+        return response["Id"]
+
+    def _batch_execute_statement(
+        self,
+        sql: Union[str, List[str]],
+        database: Optional[str] = None,
+        transaction_id: Optional[str] = None,
+        parameter_sets: Optional[List[List[Dict[str, Any]]]] = None,
+    ) -> str:
+        raise NotImplementedError("Batch execute statement not support for Redshift Data API.")
 
     def _get_statement_result(self, request_id: str) -> pd.DataFrame:
         self.waiter.wait(request_id)
-        response: Dict[str, Any]
-        response = self.client.describe_statement(Id=request_id)
-        if not response["HasResultSet"]:
+        describe_response = self.client.describe_statement(Id=request_id)
+        if not describe_response["HasResultSet"]:
             return pd.DataFrame()
 
         paginator = self.client.get_paginator("get_statement_result")
         response_iterator = paginator.paginate(Id=request_id)
 
         rows: List[List[Any]] = []
-        column_metadata: List[Dict[str, str]]
+        column_metadata: List["ColumnMetadataTypeDef"]
         for response in response_iterator:
             column_metadata = response["ColumnMetadata"]
             for record in response["Records"]:
                 row: List[Any] = [
-                    _connector.DataApiConnector._get_column_value(column)  # pylint: disable=protected-access
+                    _connector.DataApiConnector._get_column_value(column)  # type: ignore[arg-type]  # pylint: disable=protected-access
                     for column in record
                 ]
                 rows.append(row)
@@ -139,10 +182,9 @@ class RedshiftDataApiWaiter:
         Maximum number of tries.
     """
 
-    def __init__(self, client: Any, sleep: float, backoff: float, retries: int) -> None:
+    def __init__(self, client: "RedshiftDataAPIServiceClient", sleep: float, backoff: float, retries: int) -> None:
         self.client = client
         self.wait_config = _connector.WaitConfig(sleep, backoff, retries)
-        self.logger: logging.Logger = logging.getLogger(__name__)
 
     def wait(self, request_id: str) -> bool:
         """Wait for the `describe_statement` function of self.client to return a completed status.
@@ -162,7 +204,7 @@ def wait(self, request_id: str) -> bool:
         total_sleep: float = 0
         total_tries: int = 0
         while total_tries <= self.wait_config.retries:
-            response: Dict[str, Any] = self.client.describe_statement(Id=request_id)
+            response = self.client.describe_statement(Id=request_id)
             status: str = response["Status"]
             if status == "FINISHED":
                 return True
@@ -171,7 +213,7 @@ def wait(self, request_id: str) -> bool:
                 raise RedshiftDataApiFailedException(
                     f"Request {request_id} failed with status {status} and error {error}"
                 )
-            self.logger.debug("Statement execution status %s - sleeping for %s seconds", status, sleep)
+            _logger.debug("Statement execution status %s - sleeping for %s seconds", status, sleep)
             time.sleep(sleep)
             sleep = sleep * self.wait_config.backoff
             total_tries += 1


Test patch:
diff --git a/tests/unit/test_data_api.py b/tests/unit/test_data_api.py
--- a/tests/unit/test_data_api.py
+++ b/tests/unit/test_data_api.py
@@ -1,39 +1,45 @@
+from typing import Any, Dict, Iterator
+
 import boto3
 import pytest
 
 import awswrangler as wr
 import awswrangler.pandas as pd
+from awswrangler.data_api.rds import RdsDataApi
+from awswrangler.data_api.redshift import RedshiftDataApi
 
-from .._utils import get_time_str_with_random_suffix, pandas_equals
+from .._utils import assert_pandas_equals, get_df, get_time_str_with_random_suffix
 
 pytestmark = pytest.mark.distributed
 
 
 @pytest.fixture
-def redshift_connector(databases_parameters):
+def redshift_connector(databases_parameters: Dict[str, Any]) -> Iterator["RedshiftDataApi"]:
     cluster_id = databases_parameters["redshift"]["identifier"]
     database = databases_parameters["redshift"]["database"]
     secret_arn = databases_parameters["redshift"]["secret_arn"]
     con = wr.data_api.redshift.connect(
         cluster_id=cluster_id, database=database, secret_arn=secret_arn, boto3_session=None
     )
-    return con
+    with con:
+        yield con
 
 
-def create_rds_connector(rds_type, parameters):
+def create_rds_connector(rds_type: str, parameters: Dict[str, Any]) -> "RdsDataApi":
     cluster_id = parameters[rds_type]["arn"]
     database = parameters[rds_type]["database"]
     secret_arn = parameters[rds_type]["secret_arn"]
-    con = wr.data_api.rds.connect(cluster_id, database, secret_arn=secret_arn, boto3_session=boto3.DEFAULT_SESSION)
-    return con
+    return wr.data_api.rds.connect(cluster_id, database, secret_arn=secret_arn, boto3_session=boto3.DEFAULT_SESSION)
 
 
 @pytest.fixture
-def mysql_serverless_connector(databases_parameters):
-    return create_rds_connector("mysql_serverless", databases_parameters)
+def mysql_serverless_connector(databases_parameters: Dict[str, Any]) -> "RdsDataApi":
+    con = create_rds_connector("mysql_serverless", databases_parameters)
+    with con:
+        yield con
 
 
-def test_connect_redshift_serverless_iam_role(databases_parameters):
+def test_connect_redshift_serverless_iam_role(databases_parameters: Dict[str, Any]) -> None:
     workgroup_name = databases_parameters["redshift_serverless"]["workgroup"]
     database = databases_parameters["redshift_serverless"]["database"]
     con = wr.data_api.redshift.connect(workgroup_name=workgroup_name, database=database, boto3_session=None)
@@ -41,7 +47,7 @@ def test_connect_redshift_serverless_iam_role(databases_parameters):
     assert df.shape == (1, 1)
 
 
-def test_connect_redshift_serverless_secrets_manager(databases_parameters):
+def test_connect_redshift_serverless_secrets_manager(databases_parameters: Dict[str, Any]) -> None:
     workgroup_name = databases_parameters["redshift_serverless"]["workgroup"]
     database = databases_parameters["redshift_serverless"]["database"]
     secret_arn = databases_parameters["redshift_serverless"]["secret_arn"]
@@ -53,21 +59,23 @@ def test_connect_redshift_serverless_secrets_manager(databases_parameters):
 
 
 @pytest.fixture(scope="function")
-def mysql_serverless_table(mysql_serverless_connector):
+def mysql_serverless_table(mysql_serverless_connector: "RdsDataApi") -> Iterator[str]:
     name = f"tbl_{get_time_str_with_random_suffix()}"
     print(f"Table name: {name}")
-    yield name
-    wr.data_api.rds.read_sql_query(f"DROP TABLE IF EXISTS test.{name}", con=mysql_serverless_connector)
+    try:
+        yield name
+    finally:
+        mysql_serverless_connector.execute(f"DROP TABLE IF EXISTS test.{name}")
 
 
-def test_data_api_redshift_columnless_query(redshift_connector):
+def test_data_api_redshift_columnless_query(redshift_connector: "RedshiftDataApi") -> None:
     dataframe = wr.data_api.redshift.read_sql_query("SELECT 1", con=redshift_connector)
     unknown_column_indicator = "?column?"
     expected_dataframe = pd.DataFrame([[1]], columns=[unknown_column_indicator])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(dataframe, expected_dataframe)
 
 
-def test_data_api_redshift_basic_select(redshift_connector, redshift_table):
+def test_data_api_redshift_basic_select(redshift_connector: "RedshiftDataApi", redshift_table: str) -> None:
     wr.data_api.redshift.read_sql_query(
         f"CREATE TABLE public.{redshift_table} (id INT, name VARCHAR)", con=redshift_connector
     )
@@ -76,10 +84,10 @@ def test_data_api_redshift_basic_select(redshift_connector, redshift_table):
     )
     dataframe = wr.data_api.redshift.read_sql_query(f"SELECT * FROM  public.{redshift_table}", con=redshift_connector)
     expected_dataframe = pd.DataFrame([[42, "test"]], columns=["id", "name"])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(dataframe, expected_dataframe)
 
 
-def test_data_api_redshift_empty_results_select(redshift_connector, redshift_table):
+def test_data_api_redshift_empty_results_select(redshift_connector: "RedshiftDataApi", redshift_table: str) -> None:
     wr.data_api.redshift.read_sql_query(
         f"CREATE TABLE public.{redshift_table} (id INT, name VARCHAR)", con=redshift_connector
     )
@@ -90,10 +98,10 @@ def test_data_api_redshift_empty_results_select(redshift_connector, redshift_tab
         f"SELECT * FROM  public.{redshift_table} where id = 50", con=redshift_connector
     )
     expected_dataframe = pd.DataFrame([], columns=["id", "name"])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(dataframe, expected_dataframe)
 
 
-def test_data_api_redshift_column_subset_select(redshift_connector, redshift_table):
+def test_data_api_redshift_column_subset_select(redshift_connector: "RedshiftDataApi", redshift_table: str) -> None:
     wr.data_api.redshift.read_sql_query(
         f"CREATE TABLE public.{redshift_table} (id INT, name VARCHAR)", con=redshift_connector
     )
@@ -102,58 +110,110 @@ def test_data_api_redshift_column_subset_select(redshift_connector, redshift_tab
     )
     dataframe = wr.data_api.redshift.read_sql_query(f"SELECT name FROM public.{redshift_table}", con=redshift_connector)
     expected_dataframe = pd.DataFrame([["test"]], columns=["name"])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(dataframe, expected_dataframe)
 
 
-def test_data_api_mysql_columnless_query(mysql_serverless_connector):
+def test_data_api_mysql_columnless_query(mysql_serverless_connector: "RdsDataApi") -> None:
     dataframe = wr.data_api.rds.read_sql_query("SELECT 1", con=mysql_serverless_connector)
     expected_dataframe = pd.DataFrame([[1]], columns=["1"])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(dataframe, expected_dataframe)
+
 
+@pytest.mark.parametrize("use_column_names", [False, True])
+def test_data_api_mysql_basic_select(
+    mysql_serverless_connector: "RdsDataApi", mysql_serverless_table: str, use_column_names: bool
+) -> None:
+    database = "test"
+    frame = pd.DataFrame([[42, "test", None], [23, "foo", "bar"]], columns=["id", "name", "missing"])
 
-def test_data_api_mysql_basic_select(mysql_serverless_connector, mysql_serverless_table):
-    wr.data_api.rds.read_sql_query(
-        f"CREATE TABLE test.{mysql_serverless_table} (id INT, name VARCHAR(128), missing VARCHAR(256))",
+    wr.data_api.rds.to_sql(
+        df=frame,
         con=mysql_serverless_connector,
+        table=mysql_serverless_table,
+        database=database,
+        use_column_names=use_column_names,
     )
-    wr.data_api.rds.read_sql_query(
-        f"INSERT INTO test.{mysql_serverless_table} (id, name) VALUES (42, 'test')", con=mysql_serverless_connector
-    )
-    dataframe = wr.data_api.rds.read_sql_query(
+
+    out_frame = wr.data_api.rds.read_sql_query(
         f"SELECT * FROM test.{mysql_serverless_table}", con=mysql_serverless_connector
     )
-    expected_dataframe = pd.DataFrame([[42, "test", None]], columns=["id", "name", "missing"])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(out_frame, frame)
 
 
-def test_data_api_mysql_empty_results_select(mysql_serverless_connector, mysql_serverless_table):
-    wr.data_api.rds.read_sql_query(
-        f"CREATE TABLE test.{mysql_serverless_table} (id INT, name VARCHAR(128))", con=mysql_serverless_connector
-    )
-    wr.data_api.rds.read_sql_query(
-        f"INSERT INTO test.{mysql_serverless_table} VALUES (42, 'test')", con=mysql_serverless_connector
+def test_data_api_mysql_empty_results_select(
+    mysql_serverless_connector: "RdsDataApi", mysql_serverless_table: str
+) -> None:
+    database = "test"
+    frame = pd.DataFrame([[42, "test"]], columns=["id", "name"])
+
+    wr.data_api.rds.to_sql(
+        df=frame,
+        con=mysql_serverless_connector,
+        table=mysql_serverless_table,
+        database=database,
     )
-    dataframe = wr.data_api.rds.read_sql_query(
+
+    out_frame = wr.data_api.rds.read_sql_query(
         f"SELECT * FROM  test.{mysql_serverless_table} where id = 50", con=mysql_serverless_connector
     )
     expected_dataframe = pd.DataFrame([], columns=["id", "name"])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(out_frame, expected_dataframe)
 
 
-def test_data_api_mysql_column_subset_select(mysql_serverless_connector, mysql_serverless_table):
-    wr.data_api.rds.read_sql_query(
-        f"CREATE TABLE test.{mysql_serverless_table} (id INT, name VARCHAR(128))", con=mysql_serverless_connector
-    )
-    wr.data_api.rds.read_sql_query(
-        f"INSERT INTO test.{mysql_serverless_table} VALUES (42, 'test')", con=mysql_serverless_connector
+def test_data_api_mysql_column_subset_select(
+    mysql_serverless_connector: "RdsDataApi", mysql_serverless_table: str
+) -> None:
+    database = "test"
+    frame = pd.DataFrame([[42, "test"]], columns=["id", "name"])
+
+    wr.data_api.rds.to_sql(
+        df=frame,
+        con=mysql_serverless_connector,
+        table=mysql_serverless_table,
+        database=database,
     )
-    dataframe = wr.data_api.rds.read_sql_query(
+
+    out_frame = wr.data_api.rds.read_sql_query(
         f"SELECT name FROM test.{mysql_serverless_table}", con=mysql_serverless_connector
     )
     expected_dataframe = pd.DataFrame([["test"]], columns=["name"])
-    pandas_equals(dataframe, expected_dataframe)
+    assert_pandas_equals(out_frame, expected_dataframe)
+
+
+@pytest.mark.parametrize("mode", ["overwrite", "append"])
+def test_data_api_mysql_to_sql_mode(
+    mysql_serverless_connector: "RdsDataApi", mysql_serverless_table: str, mode: str
+) -> None:
+    database = "test"
+    frame = get_df()
+    wr.data_api.rds.to_sql(
+        df=frame,
+        con=mysql_serverless_connector,
+        table=mysql_serverless_table,
+        database=database,
+    )
+
+    frame2 = get_df()
+    wr.data_api.rds.to_sql(
+        df=frame2,
+        con=mysql_serverless_connector,
+        table=mysql_serverless_table,
+        database=database,
+        mode=mode,
+    )
+
+    out_frame = wr.data_api.rds.read_sql_query(
+        f"SELECT * FROM test.{mysql_serverless_table}", con=mysql_serverless_connector
+    )
+
+    if mode == "overwrite":
+        expected_frame = frame2
+    else:
+        expected_frame = pd.concat([frame, frame2], axis=0).reset_index(drop=True)
+
+    assert_pandas_equals(out_frame.astype(expected_frame.dtypes), expected_frame)
 
 
-def test_data_api_exception(mysql_serverless_connector, mysql_serverless_table):
+def test_data_api_exception(mysql_serverless_connector: "RdsDataApi", mysql_serverless_table: str) -> None:
     with pytest.raises(boto3.client("rds-data").exceptions.BadRequestException):
         wr.data_api.rds.read_sql_query("CUPCAKE", con=mysql_serverless_connector)