This is an automated email from the ASF dual-hosted git repository.

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 8d9727a9d10 feat(amazon): add CRUD methods to DynamoDBHook (#69142)
8d9727a9d10 is described below

commit 8d9727a9d10ec6a48a66fb2baba8eeaf4e957578
Author: Gokul <[email protected]>
AuthorDate: Mon Jul 6 09:21:24 2026 -0400

    feat(amazon): add CRUD methods to DynamoDBHook (#69142)
    
    Add get_item, put_item, update_item, delete_item, and query methods to
    DynamoDBHook using the boto3 resource API (plain Python types, no typed
    {"S": "value"} format). Closes #69050.
---
 .../airflow/providers/amazon/aws/hooks/dynamodb.py | 203 ++++++++++++++++++++-
 .../tests/unit/amazon/aws/hooks/test_dynamodb.py   |  97 ++++++++++
 2 files changed, 299 insertions(+), 1 deletion(-)

diff --git 
a/providers/amazon/src/airflow/providers/amazon/aws/hooks/dynamodb.py 
b/providers/amazon/src/airflow/providers/amazon/aws/hooks/dynamodb.py
index 6df2faab52a..978bed42b2e 100644
--- a/providers/amazon/src/airflow/providers/amazon/aws/hooks/dynamodb.py
+++ b/providers/amazon/src/airflow/providers/amazon/aws/hooks/dynamodb.py
@@ -21,7 +21,7 @@ from __future__ import annotations
 
 from collections.abc import Iterable
 from functools import cached_property
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
 
 from botocore.exceptions import ClientError
 
@@ -120,3 +120,204 @@ class DynamoDBHook(AwsBaseHook):
                     f"S3 import into Dynamodb job not found. Import ARN: 
{import_arn}"
                 ) from e
             raise
+
+    def get_item(
+        self,
+        table_name: str,
+        key: dict[str, Any],
+    ) -> dict[str, Any] | None:
+        """
+        Retrieve a single item from a DynamoDB table by primary key.
+
+        Uses the boto3 resource API so keys and attribute values are plain
+        Python types (str, int, Decimal, …) rather than the low-level typed
+        format (``{"S": "value"}``).
+
+        .. seealso::
+            - :external+boto3:py:meth:`DynamoDB.Table.get_item`
+
+        :param table_name: Name of the DynamoDB table.
+        :param key: Primary key of the item, e.g. ``{"pk": "value"}`` or
+            ``{"pk": "value", "sk": "range_value"}``.
+        :return: The item as a plain dict, or ``None`` if not found.
+        """
+        self.log.debug("Getting item with key %s from table %s", key, 
table_name)
+        try:
+            table = self.get_conn().Table(table_name)
+            response = table.get_item(Key=key)
+            item = response.get("Item")
+            if item is None:
+                self.log.info("Item with key %s not found in table %s", key, 
table_name)
+            return item
+        except ClientError as e:
+            self.log.error("Failed to get item from %s: %s", table_name, e)
+            raise
+
+    def put_item(
+        self,
+        table_name: str,
+        item: dict[str, Any],
+        condition_expression: str | None = None,
+    ) -> dict[str, Any]:
+        """
+        Insert or replace an item in a DynamoDB table.
+
+        .. seealso::
+            - :external+boto3:py:meth:`DynamoDB.Table.put_item`
+
+        :param table_name: Name of the DynamoDB table.
+        :param item: Item attributes as a plain dict,
+            e.g. ``{"pk": "value", "status": "pending"}``.
+        :param condition_expression: Optional condition expression string.
+        :return: The raw response from DynamoDB.
+        """
+        self.log.debug("Putting item into table %s", table_name)
+        try:
+            table = self.get_conn().Table(table_name)
+            params: dict[str, Any] = {"Item": item}
+            if condition_expression:
+                params["ConditionExpression"] = condition_expression
+            response = table.put_item(**params)
+            self.log.info("Successfully put item into table %s", table_name)
+            return response
+        except ClientError as e:
+            self.log.error("Failed to put item into %s: %s", table_name, e)
+            raise
+
+    def update_item(
+        self,
+        table_name: str,
+        key: dict[str, Any],
+        update_expression: str,
+        expression_attribute_values: dict[str, Any],
+        expression_attribute_names: dict[str, str] | None = None,
+        condition_expression: str | None = None,
+    ) -> dict[str, Any] | None:
+        """
+        Update attributes of an existing item in a DynamoDB table.
+
+        Uses the boto3 resource API so values are plain Python types.
+
+        .. seealso::
+            - :external+boto3:py:meth:`DynamoDB.Table.update_item`
+
+        :param table_name: Name of the DynamoDB table.
+        :param key: Primary key of the item to update.
+        :param update_expression: Update expression, e.g.
+            ``"SET #s = :status, updated_at = :ts"``.
+        :param expression_attribute_values: Substitution values for the
+            expression, e.g. ``{":status": "done", ":ts": "2024-01-01"}``.
+        :param expression_attribute_names: Optional name aliases for reserved
+            words, e.g. ``{"#s": "status"}``.
+        :param condition_expression: Optional condition expression string.
+        :return: The updated item attributes, or ``None`` if the update
+            returned no attributes.
+        """
+        self.log.debug("Updating item with key %s in table %s", key, 
table_name)
+        try:
+            table = self.get_conn().Table(table_name)
+            params: dict[str, Any] = {
+                "Key": key,
+                "UpdateExpression": update_expression,
+                "ExpressionAttributeValues": expression_attribute_values,
+                "ReturnValues": "ALL_NEW",
+            }
+            if expression_attribute_names:
+                params["ExpressionAttributeNames"] = expression_attribute_names
+            if condition_expression:
+                params["ConditionExpression"] = condition_expression
+            response = table.update_item(**params)
+            self.log.info("Successfully updated item in table %s", table_name)
+            return response.get("Attributes")
+        except ClientError as e:
+            self.log.error("Failed to update item in %s: %s", table_name, e)
+            raise
+
+    def delete_item(
+        self,
+        table_name: str,
+        key: dict[str, Any],
+        condition_expression: str | None = None,
+    ) -> dict[str, Any] | None:
+        """
+        Delete an item from a DynamoDB table.
+
+        .. seealso::
+            - :external+boto3:py:meth:`DynamoDB.Table.delete_item`
+
+        :param table_name: Name of the DynamoDB table.
+        :param key: Primary key of the item to delete.
+        :param condition_expression: Optional condition expression string.
+        :return: The deleted item's attributes, or ``None`` if the item did
+            not exist.
+        """
+        self.log.debug("Deleting item with key %s from table %s", key, 
table_name)
+        try:
+            table = self.get_conn().Table(table_name)
+            params: dict[str, Any] = {"Key": key, "ReturnValues": "ALL_OLD"}
+            if condition_expression:
+                params["ConditionExpression"] = condition_expression
+            response = table.delete_item(**params)
+            self.log.info("Successfully deleted item from table %s", 
table_name)
+            return response.get("Attributes")
+        except ClientError as e:
+            self.log.error("Failed to delete item from %s: %s", table_name, e)
+            raise
+
+    def query(
+        self,
+        table_name: str,
+        key_condition_expression: Any,
+        expression_attribute_values: dict[str, Any] | None = None,
+        expression_attribute_names: dict[str, str] | None = None,
+        filter_expression: Any | None = None,
+        index_name: str | None = None,
+        limit: int | None = None,
+    ) -> list[dict[str, Any]]:
+        """
+        Query items from a DynamoDB table or secondary index.
+
+        Uses the boto3 resource API so values are plain Python types.
+        ``key_condition_expression`` accepts either a string expression or a
+        boto3 :py:class:`boto3.dynamodb.conditions.ConditionBase` object
+        (e.g. ``Key("pk").eq("value")``).
+
+        .. seealso::
+            - :external+boto3:py:meth:`DynamoDB.Table.query`
+            - :external+boto3:py:class:`boto3.dynamodb.conditions.Key`
+
+        :param table_name: Name of the DynamoDB table.
+        :param key_condition_expression: Key condition — string or
+            ``boto3.dynamodb.conditions.ConditionBase``.
+        :param expression_attribute_values: Substitution values (required when
+            using string expressions, omit when using condition objects).
+        :param expression_attribute_names: Optional name aliases for reserved
+            words.
+        :param filter_expression: Optional filter applied after the query —
+            string or ``boto3.dynamodb.conditions.ConditionBase``.
+        :param index_name: Name of a secondary index to query.
+        :param limit: Maximum number of items to evaluate (see DynamoDB
+            ``Limit`` semantics — this is not a guaranteed page size).
+        :return: List of matching items as plain dicts.
+        """
+        self.log.debug("Querying table %s", table_name)
+        try:
+            table = self.get_conn().Table(table_name)
+            params: dict[str, Any] = {"KeyConditionExpression": 
key_condition_expression}
+            if expression_attribute_values:
+                params["ExpressionAttributeValues"] = 
expression_attribute_values
+            if expression_attribute_names:
+                params["ExpressionAttributeNames"] = expression_attribute_names
+            if filter_expression is not None:
+                params["FilterExpression"] = filter_expression
+            if index_name:
+                params["IndexName"] = index_name
+            if limit:
+                params["Limit"] = limit
+            response = table.query(**params)
+            items = response.get("Items", [])
+            self.log.info("Query on table %s returned %d items", table_name, 
len(items))
+            return items
+        except ClientError as e:
+            self.log.error("Failed to query table %s: %s", table_name, e)
+            raise
diff --git a/providers/amazon/tests/unit/amazon/aws/hooks/test_dynamodb.py 
b/providers/amazon/tests/unit/amazon/aws/hooks/test_dynamodb.py
index f39f426278a..4e476bd1285 100644
--- a/providers/amazon/tests/unit/amazon/aws/hooks/test_dynamodb.py
+++ b/providers/amazon/tests/unit/amazon/aws/hooks/test_dynamodb.py
@@ -147,3 +147,100 @@ class TestDynamoDBHook:
         hook = DynamoDBHook(aws_conn_id="aws_default")
         waiter = hook.get_waiter("import_table")
         assert waiter is not None
+
+    @mock_aws
+    def test_get_item_returns_item(self):
+        hook = DynamoDBHook(aws_conn_id="aws_default", region_name="us-east-1")
+        hook.get_conn().create_table(
+            TableName="test_get",
+            KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
+            AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": 
"S"}],
+            ProvisionedThroughput={"ReadCapacityUnits": 5, 
"WriteCapacityUnits": 5},
+        )
+        hook.get_conn().Table("test_get").put_item(Item={"pk": "abc", 
"status": "pending"})
+
+        item = hook.get_item("test_get", {"pk": "abc"})
+        assert item == {"pk": "abc", "status": "pending"}
+
+    @mock_aws
+    def test_get_item_returns_none_when_missing(self):
+        hook = DynamoDBHook(aws_conn_id="aws_default", region_name="us-east-1")
+        hook.get_conn().create_table(
+            TableName="test_get_missing",
+            KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
+            AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": 
"S"}],
+            ProvisionedThroughput={"ReadCapacityUnits": 5, 
"WriteCapacityUnits": 5},
+        )
+        assert hook.get_item("test_get_missing", {"pk": "does-not-exist"}) is 
None
+
+    @mock_aws
+    def test_put_item(self):
+        hook = DynamoDBHook(aws_conn_id="aws_default", region_name="us-east-1")
+        hook.get_conn().create_table(
+            TableName="test_put",
+            KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
+            AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": 
"S"}],
+            ProvisionedThroughput={"ReadCapacityUnits": 5, 
"WriteCapacityUnits": 5},
+        )
+        hook.put_item("test_put", {"pk": "xyz", "value": "hello"})
+        item = hook.get_item("test_put", {"pk": "xyz"})
+        assert item == {"pk": "xyz", "value": "hello"}
+
+    @mock_aws
+    def test_update_item(self):
+        hook = DynamoDBHook(aws_conn_id="aws_default", region_name="us-east-1")
+        hook.get_conn().create_table(
+            TableName="test_update",
+            KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
+            AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": 
"S"}],
+            ProvisionedThroughput={"ReadCapacityUnits": 5, 
"WriteCapacityUnits": 5},
+        )
+        hook.put_item("test_update", {"pk": "u1", "status": "pending"})
+        updated = hook.update_item(
+            table_name="test_update",
+            key={"pk": "u1"},
+            update_expression="SET #s = :s",
+            expression_attribute_values={":s": "approved"},
+            expression_attribute_names={"#s": "status"},
+        )
+        assert updated["status"] == "approved"
+
+    @mock_aws
+    def test_delete_item(self):
+        hook = DynamoDBHook(aws_conn_id="aws_default", region_name="us-east-1")
+        hook.get_conn().create_table(
+            TableName="test_delete",
+            KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
+            AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": 
"S"}],
+            ProvisionedThroughput={"ReadCapacityUnits": 5, 
"WriteCapacityUnits": 5},
+        )
+        hook.put_item("test_delete", {"pk": "d1", "data": "some-value"})
+        deleted = hook.delete_item("test_delete", {"pk": "d1"})
+        assert deleted == {"pk": "d1", "data": "some-value"}
+        assert hook.get_item("test_delete", {"pk": "d1"}) is None
+
+    @mock_aws
+    def test_query(self):
+        from boto3.dynamodb.conditions import Key
+
+        hook = DynamoDBHook(aws_conn_id="aws_default", region_name="us-east-1")
+        hook.get_conn().create_table(
+            TableName="test_query",
+            KeySchema=[
+                {"AttributeName": "pk", "KeyType": "HASH"},
+                {"AttributeName": "sk", "KeyType": "RANGE"},
+            ],
+            AttributeDefinitions=[
+                {"AttributeName": "pk", "AttributeType": "S"},
+                {"AttributeName": "sk", "AttributeType": "S"},
+            ],
+            ProvisionedThroughput={"ReadCapacityUnits": 5, 
"WriteCapacityUnits": 5},
+        )
+        table = hook.get_conn().Table("test_query")
+        table.put_item(Item={"pk": "p1", "sk": "2024-01", "val": "a"})
+        table.put_item(Item={"pk": "p1", "sk": "2024-02", "val": "b"})
+        table.put_item(Item={"pk": "p2", "sk": "2024-01", "val": "c"})
+
+        items = hook.query(table_name="test_query", 
key_condition_expression=Key("pk").eq("p1"))
+        assert len(items) == 2
+        assert all(i["pk"] == "p1" for i in items)

Reply via email to