Source code for operators.base_transform
from airflow.models import BaseOperator
from airflow.utils.decorators import apply_defaults
from airflow.providers.postgres.hooks.postgres import PostgresHook
[docs]
class BaseTransformOperator(BaseOperator):
"""
Base class to transform data from one connection to another. Uses `sqlalchemy` and `pandas`. Defaults to SQL Server.
:param source_conn_id: the source onnection ID
:type source_conn_id: str
:param source_sql: the source SQL statement. Should return the data to be
saved to the destination. (templated)
:type source_sql: str
:param dest_conn_id: the destination connection ID
:type dest_conn_id: str
:param dest_schema_name: the name of the destination schema
:type dest_schema_name: str
:param dest_table_name: the name of the destination table
:type dest_table_name: str
:param truncate: whether to truncate the destination table
:type truncate: bool
:param if_table_exists: what to do if destination table exists. Can be ``fail``, ``append``, ``replace``. Default ``fail``.
:type if_table_exists: str
:param query_limit: how many rows to pass in each chunk
:type query_limit: int
"""
# override if necessary
template_fields = ("source_sql", "dest_table_name", "dest_schema_name")
CHECK_TABLE_EXIST = "SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = '{}' AND tablename = '{}');"
TRUNCATE_QUERY = "TRUNCATE TABLE {};"
DROP_QUERY = "DROP TABLE {};"
DEFAULT_QUERY = "SELECT * FROM {}.{}"
@apply_defaults
def __init__(
self,
source_conn_id,
dest_conn_id,
dest_schema_name,
dest_table_name,
truncate=False,
source_sql="",
if_table_exists="replace",
query_limit=0,
*args,
**kwargs
):
super().__init__(*args, **kwargs)
self.source_conn_id = source_conn_id
self.source_sql = (
source_sql
if source_sql
else self.DEFAULT_QUERY.format(dest_schema_name, dest_table_name)
)
self.dest_conn_id = dest_conn_id
self.dest_schema_name = dest_schema_name
self.dest_table_name = dest_table_name
self.truncate = truncate
self.if_table_exists = if_table_exists
self.query_limit = query_limit
[docs]
@staticmethod
def check_result_exist(result):
"""wrapper for db differences - depending on what the CHECK_TABLE_EXIST query does, may need to do this differently"""
return result.fetchone()[0]
[docs]
def check_truncate_table(self, dest_conn, dest_schema_table):
"""delete all contents of the given table before writing if necessary"""
result_exist = dest_conn.execute(
self.CHECK_TABLE_EXIST.format(self.dest_schema_name, self.dest_table_name)
)
if self.check_result_exist(result_exist):
if self.truncate:
self.log.info("Truncating {}".format(dest_schema_table))
dest_conn.execute(self.TRUNCATE_QUERY.format(dest_schema_table))
# wrapping pandas functionality for chunking edge cases
if self.if_table_exists == "replace":
self.log.info("Replacing existing table: {}".format(dest_schema_table))
dest_conn.execute(self.DROP_QUERY.format(dest_schema_table))
elif self.if_table_exists == "fail":
raise
[docs]
def df_to_sql(self, df, dest_conn, dest_schema_table, if_exists=None):
"""helper for repeated code - write as a txn"""
with dest_conn.begin() as trans:
try:
df.to_sql(
self.dest_table_name,
dest_conn,
schema=self.dest_schema_name,
if_exists=if_exists or self.if_table_exists,
index=False,
method="multi",
)
trans.commit()
except:
trans.rollback()
raise
[docs]
def chunk_query(self, source_conn, dest_conn, dest_schema_table):
"""chunk the query result to obey memory limits"""
import pandas as pd
self.log.info("Running query....")
result = source_conn.execution_options(stream_results=True).execute(
self.source_sql
)
self.log.info("Chunking results...")
counter = 0
while True:
self.log.info("Reading chunk {}...".format(counter))
chunk = result.fetchmany(self.query_limit)
if not chunk:
break
df = pd.DataFrame(data=chunk)
df.columns = result.keys()
self.log.info(
"Writing chunk {} to {}...".format(counter, dest_schema_table)
)
self.df_to_sql(df, dest_conn, dest_schema_table, "append")
counter += 1
[docs]
def transform_query(self, source_conn, dest_conn, dest_schema_table):
"""read the entire query in a df then write it to a db"""
import pandas as pd
self.log.info("Reading data...")
df = pd.read_sql(self.source_sql, source_conn)
self.log.info("Writing data to {}".format(dest_schema_table))
self.df_to_sql(df, dest_conn, dest_schema_table)
[docs]
def execute(self, context):
"""
main call method - creates two engines and feeds them to pandas
"""
source_engine = self.get_source_hook().get_sqlalchemy_engine()
source_conn = source_engine.connect()
dest_engine = self.get_dest_hook().get_sqlalchemy_engine()
dest_conn = dest_engine.connect()
dest_schema_table = "{}.{}".format(self.dest_schema_name, self.dest_table_name)
self.log.info(self.source_sql)
try:
self.check_truncate_table(dest_conn, dest_schema_table)
if self.query_limit:
self.chunk_query(source_conn, dest_conn, dest_schema_table)
else:
self.transform_query(source_conn, dest_conn, dest_schema_table)
finally:
source_conn.close()
dest_conn.close()
[docs]
def get_source_hook(self):
"""
add src hook that is able to make a sqlalchemy connection
"""
return PostgresHook(postgres_conn_id=self.source_conn_id)
[docs]
def get_dest_hook(self):
"""
add dest hook that is able to make a sqlalchemy connection
"""
return PostgresHook(postgres_conn_id=self.dest_conn_id)