Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/core_api/services/public/common.py: 82%
50 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
1# Licensed to the Apache Software Foundation (ASF) under one
2# or more contributor license agreements. See the NOTICE file
3# distributed with this work for additional information
4# regarding copyright ownership. The ASF licenses this file
5# to you under the Apache License, Version 2.0 (the
6# "License"); you may not use this file except in compliance
7# with the License. You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing,
12# software distributed under the License is distributed on an
13# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14# KIND, either express or implied. See the License for the
15# specific language governing permissions and limitations
16# under the License.
18from __future__ import annotations
20from abc import ABC, abstractmethod
21from typing import Generic
23from fastapi import HTTPException, status
24from pydantic import BaseModel
25from sqlalchemy.ext.declarative import DeclarativeMeta
26from sqlalchemy.orm import Session
28from airflow.api_fastapi.core_api.datamodels.common import (
29 BulkAction,
30 BulkActionResponse,
31 BulkBody,
32 BulkCreateAction,
33 BulkDeleteAction,
34 BulkResponse,
35 BulkUpdateAction,
36 T,
37)
40class BulkService(Generic[T], ABC):
41 """Base class for bulk service operations."""
43 def __init__(self, session: Session, request: BulkBody[T]):
44 self.session = session
45 self.request = request
47 def handle_request(self) -> BulkResponse:
48 """Handle request for bulk actions."""
49 results: dict[str, BulkActionResponse] = {}
51 for action in self.request.actions:
52 if action.action.value not in results:
53 results[action.action.value] = BulkActionResponse()
55 if action.action == BulkAction.CREATE:
56 self.handle_bulk_create(action, results[action.action.value])
57 elif action.action == BulkAction.UPDATE:
58 self.handle_bulk_update(action, results[action.action.value])
59 elif action.action == BulkAction.DELETE: 59 ↛ 51line 59 didn't jump to line 51 because the condition on line 59 was always true
60 self.handle_bulk_delete(action, results[action.action.value])
62 return BulkResponse(**results)
64 @abstractmethod
65 def handle_bulk_create(self, action: BulkCreateAction[T], results: BulkActionResponse) -> None:
66 """Bulk create entities."""
67 raise NotImplementedError
69 @abstractmethod
70 def handle_bulk_update(self, action: BulkUpdateAction[T], results: BulkActionResponse) -> None:
71 """Bulk update entities."""
72 raise NotImplementedError
74 @abstractmethod
75 def handle_bulk_delete(self, action: BulkDeleteAction[T], results: BulkActionResponse) -> None:
76 """Bulk delete entities."""
77 raise NotImplementedError
79 @staticmethod
80 def apply_patch_with_update_mask(
81 model: DeclarativeMeta,
82 patch_body: BaseModel,
83 update_mask: list[str] | None,
84 non_update_fields: set[str] | None = None,
85 ) -> DeclarativeMeta:
86 """
87 Apply a patch to the given model using the provided update mask.
89 :param model: The SQLAlchemy model instance to update.
90 :param patch_body: Pydantic model containing patch data.
91 :param update_mask: Optional list of fields to update.
92 :param non_update_fields: Fields that should not be updated.
93 :return: The updated SQLAlchemy model instance.
94 :raises HTTPException: If invalid fields are provided in update_mask.
95 """
96 # Always dump without aliases for internal validation
97 raw_data = patch_body.model_dump(by_alias=False)
98 fields_to_update = set(patch_body.model_fields_set)
100 non_update_fields = non_update_fields or set()
102 if update_mask: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 restricted_in_mask = set(update_mask).intersection(non_update_fields)
104 if restricted_in_mask:
105 raise HTTPException(
106 status_code=status.HTTP_400_BAD_REQUEST,
107 detail=f"Update not allowed: the following fields are immutable and cannot be modified: {restricted_in_mask}",
108 )
109 fields_to_update = fields_to_update.intersection(update_mask)
111 if non_update_fields: 111 ↛ 114line 111 didn't jump to line 114 because the condition on line 111 was always true
112 fields_to_update = fields_to_update - non_update_fields
114 validated_data = {key: raw_data[key] for key in fields_to_update if key in raw_data}
116 data = patch_body.model_dump(include=set(validated_data.keys()), by_alias=True)
118 # Update the model with the validated data
119 for key, value in data.items():
120 setattr(model, key, value)
122 return model