uranusjr commented on code in PR #65618:
URL: https://github.com/apache/airflow/pull/65618#discussion_r3975773925


##########
providers/common/sql/src/airflow/providers/common/sql/triggers/sql.py:
##########
@@ -57,49 +84,160 @@ def serialize(self) -> tuple[str, dict[str, Any]]:
             {
                 "sql": self.sql,
                 "conn_id": self.conn_id,
+                "autocommit": self.autocommit,
+                "parameters": self.parameters,
+                "fetch_results": self.fetch_results,
+                "split_statements": self.split_statements,
+                "return_last": self.return_last,
+                "read_only": self.read_only,
                 "hook_params": self.hook_params,
             },
         )
 
-    def get_hook(self) -> DbApiHook:
+    @staticmethod
+    def _jsonsafe_descriptions(
+        descriptions: list[Sequence[Sequence] | None],
+    ) -> list[list[list[Any]] | None]:
+        """
+        Normalise cursor descriptions into a JSON-serializable form for the 
``TriggerEvent``.
+
+        ``cursor.description`` is a sequence of column 7-tuples whose 
``type_code`` can be a
+        driver-specific object that is not JSON-serializable; such values are 
stringified while
+        JSON-native fields (column names, sizes, precision, ...) are preserved.
+        """
+        safe: list[list[list[Any]] | None] = []
+        for description in descriptions:
+            if description is None:
+                safe.append(None)
+                continue
+            safe.append(
+                [
+                    [
+                        field if isinstance(field, (str, int, float, bool, 
type(None))) else str(field)
+                        for field in column
+                    ]
+                    for column in description
+                ]
+            )
+        return safe
+
+    async def aget_hook(self) -> DbApiHook:
         """
         Return DbApiHook.
 
         :return: DbApiHook for this connection
         """
-        connection = BaseHook.get_connection(self.conn_id)
-        hook = connection.get_hook(hook_params=self.hook_params)
+        hook = await get_async_hook(self.conn_id, hook_params=self.hook_params)
         if not isinstance(hook, DbApiHook):
-            raise AirflowException(
-                f"You are trying to use `common-sql` with 
{hook.__class__.__name__},"
-                " but its provider does not support it. Please upgrade the 
provider to a version that"
-                " supports `common-sql`. The hook class should be a subclass 
of"
-                f" `{hook.__class__.__module__}.{hook.__class__.__name__}`."
-                f" Got {hook.__class__.__name__} hook with class hierarchy: 
{hook.__class__.mro()}"
+            raise TypeError(
+                f"You are trying to use the SqlExecuteQueryOperator in 
deferrable mode with {hook.__class__.__name__},"
+                " but its provider does not support this. Please set 
deferrable=False"
+                f" Got {hook.__class__.__name__} with class hierarchy: 
{hook.__class__.mro()}"
+            )
+        if self.read_only and not (hook.supports_async_execution() and 
hook.supports_readonly_execution()):
+            raise NotImplementedError(
+                f"{hook.__class__.__name__} does not support read-only 
execution, so it cannot run a"
+                " deferred query safely (a triggerer restart could re-run it). 
Set"
+                " enforce_read_only=False to run without the read-only guard 
if the query is"
+                " idempotent, or deferrable=False to run it on the worker."
             )
         return hook
 
-    async def _get_records(self) -> Any:
-        from asgiref.sync import sync_to_async
-
-        hook = self.get_hook()
+    async def _run_query(self, hook: DbApiHook, fetch_results: bool):
+        """
+        Run the query against ``hook``, using its native async driver if 
available.
 
+        Hooks without a real async driver 
(:meth:`DbApiHook.supports_async_execution`) fall back to
+        running the synchronous :meth:`DbApiHook.run` in a worker thread, 
matching the compatibility the
+        `GenericTransfer` operator has always relied on for arbitrary 
DB-specific hooks.
+        """
+        if hook.supports_async_execution():
+            if fetch_results:
+                return await hook.arun(
+                    sql=self.sql,
+                    autocommit=self.autocommit,
+                    parameters=self.parameters,
+                    handler=fetch_all_handler,
+                    split_statements=self.split_statements,
+                    return_last=self.return_last,
+                    read_only=self.read_only,
+                )
+            return await hook.arun(
+                sql=self.sql,
+                autocommit=self.autocommit,
+                parameters=self.parameters,
+                handler=None,
+                split_statements=self.split_statements,
+                return_last=self.return_last,
+                read_only=self.read_only,
+            )
         if AIRFLOW_V_3_2_PLUS:
             # This is only supported from Airflow 3.2 or higher due to added 
async support in CommsDecoder
-            return await sync_to_async(hook.get_records)(self.sql)
-        return hook.get_records(self.sql)
+            # `sync_to_async` erases `run`'s overloads, so it is cast to a 
plain callable first.
+            arun_in_thread = sync_to_async(cast("Callable[..., Any]", 
hook.run))
+            if fetch_results:
+                return await arun_in_thread(
+                    sql=self.sql,
+                    autocommit=self.autocommit,
+                    parameters=self.parameters,
+                    handler=fetch_all_handler,
+                    split_statements=self.split_statements,
+                    return_last=self.return_last,
+                )
+            return await arun_in_thread(
+                sql=self.sql,
+                autocommit=self.autocommit,
+                parameters=self.parameters,
+                handler=None,
+                split_statements=self.split_statements,
+                return_last=self.return_last,
+            )
+        if fetch_results:
+            return hook.run(
+                sql=self.sql,
+                autocommit=self.autocommit,
+                parameters=self.parameters,
+                handler=fetch_all_handler,
+                split_statements=self.split_statements,
+                return_last=self.return_last,
+            )
+        return hook.run(
+            sql=self.sql,
+            autocommit=self.autocommit,
+            parameters=self.parameters,
+            handler=None,
+            split_statements=self.split_statements,
+            return_last=self.return_last,
+        )
 
     async def run(self) -> AsyncIterator[TriggerEvent]:
         try:
+            hook = await self.aget_hook()
+
             self.log.info("Extracting data from %s", self.conn_id)
             self.log.info("Executing: \n %s", self.sql)
-            self.log.info("Reading records from %s", self.conn_id)
 
-            results = await self._get_records()
+            if self.fetch_results:
+                # Fetch the raw rows with the built-in handler and return them 
with the cursor
+                # descriptions; the operator applies any user handler on the 
worker.
+                results = await self._run_query(hook, fetch_results=True)
+
+                self.log.info("Executing query from %s done!", self.conn_id)
+                self.log.debug("results: %s", results)
+                yield TriggerEvent(
+                    {
+                        "status": "success",
+                        "results": results,

Review Comment:
   `descriptions` get normalized via `_jsonsafe_descriptions`, but the 
`results` rows are returned raw. A Postgres row holding platform-dependant 
types (e.g. timestamptz) has to survive TriggerEvent serialization and the trip 
back to the worker.
   
   Could you add a test that selects numeric/timestamptz/bytea/uuid and asserts 
the round-tripped values match `deferrable=False`? If serde doesn't cover a 
type we'd silently corrupt or fail results in the exact path this PR adds. (I 
honestly do not know what we did around here before this change.)



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to