|
5 | 5 | from typing import Generic |
6 | 6 | from typing import TypeVar |
7 | 7 |
|
| 8 | +from pydantic import BaseModel as PydanticBaseModel |
8 | 9 | from pydantic import Field |
9 | 10 | from pydantic import ValidationInfo |
10 | 11 | from pydantic import field_validator |
@@ -257,10 +258,14 @@ def patch(self, resource: ResourceT) -> bool: |
257 | 258 | "add", "replace", and "remove". If any operation modifies the resource, the method |
258 | 259 | returns True; otherwise, False. |
259 | 260 |
|
| 261 | + Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`, when an operation sets a value's |
| 262 | + ``primary`` sub-attribute to ``True``, any other values in the same multi-valued |
| 263 | + attribute will have their ``primary`` set to ``False`` automatically. |
| 264 | +
|
260 | 265 | :param resource: The SCIM resource to patch. This object is modified in-place. |
261 | | - :type resource: T |
262 | 266 | :return: True if the resource was modified by any operation, False otherwise. |
263 | | - :raises SCIMException: If an operation is invalid (e.g., invalid path, forbidden mutation). |
| 267 | + :raises InvalidValueException: If multiple values are marked as primary in a single |
| 268 | + operation, or if multiple primary values already exist before the patch. |
264 | 269 | """ |
265 | 270 | if not self.operations: |
266 | 271 | return False |
@@ -291,13 +296,102 @@ def _apply_add_replace( |
291 | 296 | self, resource: Resource[Any], operation: PatchOperation[ResourceT] |
292 | 297 | ) -> bool: |
293 | 298 | """Apply an add or replace operation.""" |
| 299 | + before_state = self._capture_primary_state(resource) |
| 300 | + |
294 | 301 | path = operation.path if operation.path is not None else Path("") |
295 | | - return path.set( |
| 302 | + modified = path.set( |
296 | 303 | resource, # type: ignore[arg-type] |
297 | 304 | operation.value, |
298 | 305 | is_add=operation.op == PatchOperation.Op.add, |
299 | 306 | ) |
300 | 307 |
|
| 308 | + if modified: |
| 309 | + self._normalize_primary_after_patch(resource, before_state) |
| 310 | + |
| 311 | + return modified |
| 312 | + |
| 313 | + def _capture_primary_state(self, resource: Resource[Any]) -> dict[str, set[int]]: |
| 314 | + """Capture indices of elements with primary=True for each multi-valued attribute.""" |
| 315 | + state: dict[str, set[int]] = {} |
| 316 | + for field_name in type(resource).model_fields: |
| 317 | + if not resource.get_field_multiplicity(field_name): |
| 318 | + continue |
| 319 | + |
| 320 | + field_value = getattr(resource, field_name, None) |
| 321 | + if not field_value: |
| 322 | + continue |
| 323 | + |
| 324 | + element_type = resource.get_field_root_type(field_name) |
| 325 | + if ( |
| 326 | + not element_type |
| 327 | + or not isclass(element_type) |
| 328 | + or not issubclass(element_type, PydanticBaseModel) |
| 329 | + or "primary" not in element_type.model_fields |
| 330 | + ): |
| 331 | + continue |
| 332 | + |
| 333 | + primary_indices = { |
| 334 | + i |
| 335 | + for i, item in enumerate(field_value) |
| 336 | + if getattr(item, "primary", None) is True |
| 337 | + } |
| 338 | + state[field_name] = primary_indices |
| 339 | + |
| 340 | + return state |
| 341 | + |
| 342 | + def _normalize_primary_after_patch( |
| 343 | + self, resource: Resource[Any], before_state: dict[str, set[int]] |
| 344 | + ) -> None: |
| 345 | + """Normalize primary attributes after a patch operation. |
| 346 | +
|
| 347 | + Per :rfc:`RFC 7644 §3.5.2 <7644#section-3.5.2>`: a PATCH operation that |
| 348 | + sets a value's "primary" sub-attribute to "true" SHALL cause the server |
| 349 | + to automatically set "primary" to "false" for any other values. |
| 350 | + """ |
| 351 | + for field_name in type(resource).model_fields: |
| 352 | + if not resource.get_field_multiplicity(field_name): |
| 353 | + continue |
| 354 | + |
| 355 | + field_value = getattr(resource, field_name, None) |
| 356 | + if not field_value: |
| 357 | + continue |
| 358 | + |
| 359 | + element_type = resource.get_field_root_type(field_name) |
| 360 | + if ( |
| 361 | + not element_type |
| 362 | + or not isclass(element_type) |
| 363 | + or not issubclass(element_type, PydanticBaseModel) |
| 364 | + or "primary" not in element_type.model_fields |
| 365 | + ): |
| 366 | + continue |
| 367 | + |
| 368 | + current_primary_indices = { |
| 369 | + i |
| 370 | + for i, item in enumerate(field_value) |
| 371 | + if getattr(item, "primary", None) is True |
| 372 | + } |
| 373 | + |
| 374 | + if len(current_primary_indices) <= 1: |
| 375 | + continue |
| 376 | + |
| 377 | + before_primaries = before_state.get(field_name, set()) |
| 378 | + new_primaries = current_primary_indices - before_primaries |
| 379 | + |
| 380 | + if len(new_primaries) > 1: |
| 381 | + raise InvalidValueException( |
| 382 | + detail=f"Multiple values marked as primary in field '{field_name}'" |
| 383 | + ) |
| 384 | + |
| 385 | + if not new_primaries: |
| 386 | + raise InvalidValueException( |
| 387 | + detail=f"Multiple primary values already exist in field '{field_name}'" |
| 388 | + ) |
| 389 | + |
| 390 | + keep_index = next(iter(new_primaries)) |
| 391 | + for i in current_primary_indices: |
| 392 | + if i != keep_index: |
| 393 | + field_value[i].primary = False |
| 394 | + |
301 | 395 | def _apply_remove( |
302 | 396 | self, resource: Resource[Any], operation: PatchOperation[ResourceT] |
303 | 397 | ) -> bool: |
|
0 commit comments