Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions cog.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ ignore_merge_commits = true
# so we can do our own skip logic in workflow
skip_ci = "[ci-skip]"
pre_bump_hooks = [
"uv version --no-sync {{version}}",
"git add :pyproject.toml :uv.lock"
"uv version --no-sync {{version}}",
"git add :pyproject.toml :uv.lock"
]
post_bump_hooks = [
"git push",
"git push origin {{version_tag}}",
"git push",
"git push origin {{version_tag}}",
]

[changelog]
Expand All @@ -21,8 +21,8 @@ owner = "gazorby"
repository = "strawchemy"

authors = [
{ signature = "gazorby", username = "gazorby" },
{ signature = "Matthieu", username = "gazorby" }
{ signature = "gazorby", username = "gazorby" },
{ signature = "Matthieu", username = "gazorby" }
]

[commit_types]
Expand All @@ -42,10 +42,8 @@ test = { omit_from_changelog = true }

[bump_profiles.manual]
pre_bump_hooks = [
"uv version --no-sync {{version}}",
"mise run lint",
"uv version --no-sync {{version}}",
"mise run lint",
]

post_bump_hooks = [
"echo 'Manual bump from {{latest}} to {{version}}.'"
]
post_bump_hooks = ["echo 'Manual bump from {{latest}} to {{version}}.'"]
3 changes: 3 additions & 0 deletions src/strawchemy/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
StrawchemyUpdateMutationField,
StrawchemyUpsertMutationField,
)
from strawchemy.schema.mutation.input import EventRegistry
from strawchemy.utils.registry import StrawberryRegistry

if TYPE_CHECKING:
Expand Down Expand Up @@ -108,6 +109,7 @@ def __init__(
"""
self.config = StrawchemyConfig(cast("SupportedDialect", config)) if isinstance(config, str) else config
self.registry = StrawberryRegistry(strawberry_config or StrawberryConfig())
self._event_registry = EventRegistry()

strawberry_backend = StrawberrryDTOBackend(
MappedStrawberryGraphQLDTO, auto_is_type_of=self.config.auto_is_type_of
Expand Down Expand Up @@ -137,6 +139,7 @@ def __init__(
order_by_factory=self.order_by_factory,
filter_factory=self.filter_factory,
distinct_on_factory=self.distinct_on_enum_factory,
event_registry=self._event_registry,
)

# Decorators
Expand Down
23 changes: 17 additions & 6 deletions src/strawchemy/schema/mutation/field_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

from strawberry.annotation import StrawberryAnnotation

from strawchemy.schema.mutation.fields import (
StrawchemyCreateMutationField,
StrawchemyDeleteMutationField,
StrawchemyUpdateMutationField,
StrawchemyUpsertMutationField,
)

if TYPE_CHECKING:
from collections.abc import Callable, Mapping, Sequence

Expand All @@ -17,12 +24,7 @@
from strawchemy.config.base import StrawchemyConfig
from strawchemy.schema.factories import DistinctOnEnumFactory
from strawchemy.schema.factories.inputs import BooleanFilterFactory, OrderByFactory
from strawchemy.schema.mutation.fields import (
StrawchemyCreateMutationField,
StrawchemyDeleteMutationField,
StrawchemyUpdateMutationField,
StrawchemyUpsertMutationField,
)
from strawchemy.schema.mutation.input import EventRegistry
from strawchemy.typing import AnyRepositoryType


Expand All @@ -40,6 +42,8 @@ class MutationFieldBuilder:
order_by_factory: OrderByFactory
filter_factory: BooleanFilterFactory
distinct_on_factory: DistinctOnEnumFactory
event_registry: EventRegistry
"""Shared event registry owned by the Strawchemy instance, reused across mutation requests."""

def build(
self,
Expand Down Expand Up @@ -92,6 +96,13 @@ def build(
namespace = self.registry_namespace_getter()
type_annotation = StrawberryAnnotation.from_annotation(graphql_type, namespace) if graphql_type else None

# Inject the shared registry only for input mutation fields (create/update/upsert),
# not for the delete field which shares the `input_type` kwarg name for filter types.
if issubclass(
field_class, (StrawchemyCreateMutationField, StrawchemyUpdateMutationField, StrawchemyUpsertMutationField)
):
field_specific_kwargs.setdefault("event_registry", self.event_registry)

field = field_class(
config=self.config,
repository_type=repository_type,
Expand Down
11 changes: 7 additions & 4 deletions src/strawchemy/schema/mutation/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from strawchemy.dto.strawberry import BooleanFilterDTO, EnumDTO
from strawchemy.repository.strawberry.base import GraphQLResult
from strawchemy.schema.mutation.input import EventRegistry
from strawchemy.typing import AnyMappedDTO, CreateOrUpdateResolverResult, ListResolverResult, MappedGraphQLDTO
from strawchemy.validation import ValidationProtocol

Expand All @@ -41,12 +42,14 @@ def __init__(
input_type: type[MappedGraphQLDTO[T]],
*args: Any,
validation: ValidationProtocol[T] | None = None,
event_registry: EventRegistry | None = None,
**kwargs: Any,
) -> None:
super().__init__(*args, **kwargs)
self.is_root_field = True
self._input_type = input_type
self._validation = validation
self._event_registry = event_registry


class _StrawchemyMutationField:
Expand All @@ -68,7 +71,7 @@ def _create_resolver(
) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
repository = self._get_repository(info)
try:
input_data = Input(data, self._validation)
input_data = Input(data, self._validation, registry=self._event_registry)
except InputValidationError as error:
return error.graphql_type()
if self._is_repo_async(repository):
Expand Down Expand Up @@ -111,7 +114,7 @@ def _upsert_resolver(
) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
repository = self._get_repository(info)
try:
input_data = Input(data, self._validation)
input_data = Input(data, self._validation, registry=self._event_registry)
except InputValidationError as error:
return error.graphql_type()
if self._is_repo_async(repository):
Expand Down Expand Up @@ -165,7 +168,7 @@ def _update_by_ids_resolver(
) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
repository = self._get_repository(info)
try:
input_data = Input(data, self._validation)
input_data = Input(data, self._validation, registry=self._event_registry)
except InputValidationError as error:
error_result = error.graphql_type()
return [error_result] if isinstance(data, Sequence) else error_result
Expand All @@ -179,7 +182,7 @@ def _update_by_filter_resolver(
) -> CreateOrUpdateResolverResult | Coroutine[CreateOrUpdateResolverResult, Any, Any]:
repository = self._get_repository(info)
try:
input_data = Input(data, self._validation)
input_data = Input(data, self._validation, registry=self._event_registry)
except InputValidationError as error:
return [error.graphql_type()]
if self._is_repo_async(repository):
Expand Down
85 changes: 72 additions & 13 deletions src/strawchemy/schema/mutation/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Hashable, Iterator, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, TypeVar, cast, final
from weakref import WeakValueDictionary

from sqlalchemy import event, inspect
from sqlalchemy.orm import MapperProperty, RelationshipDirection, object_mapper
Expand All @@ -30,8 +31,7 @@
from strawchemy.typing import MappedGraphQLDTO
from strawchemy.validation.base import ValidationProtocol


__all__ = ("Input", "InputModel", "LevelInput", "RelationType")
__all__ = ("EventRegistry", "Input", "InputModel", "LevelInput", "RelationInput", "RelationType")

T = TypeVar("T", bound=MappedDTO[Any])
DeclarativeBaseT = TypeVar("DeclarativeBaseT", bound="DeclarativeBase")
Expand Down Expand Up @@ -103,22 +103,19 @@ def __bool__(self) -> bool:
@dataclass
class RelationInput(_UnboundRelationInput):
parent: DeclarativeBase = field(kw_only=True)
event_registry: EventRegistry = field(kw_only=True)

def __post_init__(self) -> None:
super().__post_init__()

if self.relation_type is RelationType.TO_ONE:
event.listens_for(self.attribute, "set")(self._set_event)
else:
event.listens_for(self.attribute, "append")(self._append_event)
event.listens_for(self.attribute, "remove")(self._remove_event)
self.event_registry.register(self)

@classmethod
def from_unbound(cls, unbound: _UnboundRelationInput, model: DeclarativeBase) -> Self:
def from_unbound(cls, unbound: _UnboundRelationInput, model: DeclarativeBase, registry: EventRegistry) -> Self:
return cls(
attribute=unbound.attribute,
related=unbound.related,
parent=model,
event_registry=registry,
set_=unbound.set,
add=unbound.add,
remove=unbound.remove,
Expand All @@ -129,27 +126,85 @@ def from_unbound(cls, unbound: _UnboundRelationInput, model: DeclarativeBase) ->
upsert=unbound.upsert,
)

def _set_event(self, target: DeclarativeBase, value: DeclarativeBase | None, *_: Any, **__: Any) -> None:
def handle_set(self, value: DeclarativeBase | None) -> None:
if value is None:
return
if _has_record(value):
self.set = [value]
else:
self.create = [value]

Comment on lines +129 to 136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset TO_ONE relation state on every set event.

Line 129 currently ignores None, and Lines 132-135 only update one bucket (set or create). That leaves stale state in the other bucket after multiple assignments in one request, which can drive incorrect mutation operations.

Proposed fix
 def handle_set(self, value: DeclarativeBase | None) -> None:
-    if value is None:
-        return
-    if _has_record(value):
-        self.set = [value]
-    else:
-        self.create = [value]
+    self.create = []
+    if value is None:
+        self.set = None
+        return
+    if _has_record(value):
+        self.set = [value]
+    else:
+        self.set = []
+        self.create = [value]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/strawchemy/schema/mutation/input.py` around lines 129 - 136, The
handle_set method needs to reset both the TO_ONE relation state buckets on every
set event to prevent stale state from persisting across multiple assignments in
one request. Currently, when value is None, the method returns early without
clearing the other bucket, and when a value is provided, only one bucket
(self.set or self.create) is updated. Fix this by resetting both self.set and
self.create to empty lists at the start of the handle_set method before the None
check, ensuring both buckets are always cleared when a new set event occurs,
then selectively populate the appropriate bucket based on the provided value.

def _append_event(self, target: DeclarativeBase, value: DeclarativeBase, *_: Any, **__: Any) -> None:
def handle_append(self, value: DeclarativeBase) -> None:
if _has_record(value):
self.add.append(value)
else:
self.create.append(value)

def _remove_event(self, target: DeclarativeBase, value: DeclarativeBase, *_: Any, **__: Any) -> None:
def handle_remove(self, value: DeclarativeBase) -> None:
if _has_record(value):
self.add = [model for model in self.add if model is not value]
else:
self.create = [model for model in self.create if model is not value]


def _new_entries() -> WeakValueDictionary[tuple[int, str], RelationInput]:
return WeakValueDictionary()


@dataclass
class EventRegistry:
"""Routes SQLAlchemy relationship events to the RelationInput owning (target, attribute).

A single set/append/remove listener is registered per relationship attribute for the
process lifetime. Each event is dispatched to the one RelationInput whose ``parent`` is
the event's ``target``, looked up in a weakly-held per-parent mapping.
"""

_entries: WeakValueDictionary[tuple[int, str], RelationInput] = field(init=False, default_factory=_new_entries)
"""Maps ``(id(parent), attr_key)`` to the owning RelationInput, held weakly.

The value (``RelationInput``) strongly references ``parent``, so ``id(parent)``
cannot be recycled while this entry is live — the key is therefore stable for the
entry's lifetime.
"""
_registry: set[MapperProperty[Any]] = field(init=False, default_factory=set)
"""Relationship attributes whose dispatcher has already been registered."""

def register(self, relation: RelationInput) -> None:
"""Wire the relation's attribute once and index the relation by its parent."""
self._register(relation.attribute, relation.relation_type)
# Weak-ownership invariant: the registry holds entries weakly (by value), so a
# RelationInput that is no longer retained by its owning Input stops routing once
# collected. The owning Input strongly holds every relation it consumes via
# self.relations, so live relations are always reachable.
self._entries[(id(relation.parent), relation.attribute.key)] = relation

def _register(self, attribute: MapperProperty[Any], relation_type: RelationType) -> None:
if attribute in self._registry:
return
if relation_type is RelationType.TO_ONE:
event.listens_for(attribute, "set")(self._dispatch_set)
else:
event.listens_for(attribute, "append")(self._dispatch_append)
event.listens_for(attribute, "remove")(self._dispatch_remove)
self._registry.add(attribute)
Comment on lines +170 to +190

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and nearby symbols first.
ast-grep outline src/strawchemy/schema/mutation/input.py --view expanded

# Show the relevant line ranges with line numbers.
sed -n '150,340p' src/strawchemy/schema/mutation/input.py | cat -n

# Find EventRegistry, Input, RelationInput, and any registry wiring or locking.
rg -n "class EventRegistry|def register\(|_registry|EventRegistry\(|RelationInput|threading|Lock|RLock|listens_for|event\.listens_for" src/strawchemy/schema/mutation/input.py src/strawchemy/schema -g '!**/__pycache__/**'

Repository: gazorby/strawchemy

Length of output: 25511


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how EventRegistry is created and shared by field builders/field definitions.
sed -n '1,240p' src/strawchemy/schema/mutation/field_builder.py | cat -n
printf '\n----\n'
sed -n '1,240p' src/strawchemy/schema/mutation/fields.py | cat -n

# Look for other EventRegistry construction sites.
rg -n "EventRegistry\(" src/strawchemy -g '!**/__pycache__/**'

Repository: gazorby/strawchemy

Length of output: 18700


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find every direct Input(...) construction to see whether the registry is always provided.
rg -n "\bInput\s*\(" src/strawchemy -g '!**/__pycache__/**'

Repository: gazorby/strawchemy

Length of output: 757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the registry owner in mapper.py and any other ad-hoc EventRegistry creation paths.
sed -n '90,140p' src/strawchemy/mapper.py | cat -n
printf '\n----\n'
rg -n "EventRegistry\(\)|registry=" src/strawchemy -g '!**/__pycache__/**'

Repository: gazorby/strawchemy

Length of output: 3997


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the surrounding mapper code that creates the second EventRegistry.
sed -n '1,180p' src/strawchemy/mapper.py | cat -n

# Check whether Input is part of the public API or used internally elsewhere.
rg -n "^from strawchemy\.schema\.mutation\.input import Input|^from .* import Input|Input\(" src/strawchemy -g '!**/__pycache__/**'

Repository: gazorby/strawchemy

Length of output: 12262


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the surrounding mapper code that creates the second EventRegistry.
sed -n '1,180p' src/strawchemy/mapper.py | cat -n

printf '\n----\n'

# Check whether Input is used outside the mutation field code paths.
rg -n "from strawchemy\.schema\.mutation\.input import Input|\\bInput\\(" src/strawchemy -g '!**/__pycache__/**'

Repository: gazorby/strawchemy

Length of output: 10364


Make listener registration shared and synchronized. The mutation path already reuses one EventRegistry per Strawchemy instance, but Input(..., registry=None) still creates a fresh registry. That leaves a public path that can attach duplicate SQLAlchemy listeners to the same mapper attribute, and _register() can race if the same registry is first used concurrently. Make the registry mandatory or guard listener wiring with a lock.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/strawchemy/schema/mutation/input.py` around lines 170 - 190, The listener
wiring in EventRegistry is still reachable through Input(..., registry=None),
which can create a separate registry and allow duplicate SQLAlchemy listeners on
the same attribute. Make the registry path shared and mandatory (or otherwise
ensure all Inputs reuse the same registry), and add synchronization around
EventRegistry._register so concurrent first-time registration of the same
MapperProperty cannot attach listeners twice.


def _get_input(self, target: DeclarativeBase, initiator: Any) -> RelationInput | None:
return self._entries.get((id(target), initiator.key))

def _dispatch_set(self, target: DeclarativeBase, value: DeclarativeBase, _oldvalue: Any, initiator: Any) -> None:
if (relation := self._get_input(target, initiator)) is not None:
relation.handle_set(value)

def _dispatch_append(self, target: DeclarativeBase, value: DeclarativeBase, initiator: Any) -> None:
if (relation := self._get_input(target, initiator)) is not None:
relation.handle_append(value)

def _dispatch_remove(self, target: DeclarativeBase, value: DeclarativeBase, initiator: Any) -> None:
Comment on lines +192 to +203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file with line numbers and nearby context.
sed -n '1,280p' src/strawchemy/schema/mutation/input.py | cat -n

# Find the event hookup sites and related handler signatures.
rg -n "handle_set|handle_append|handle_remove|_dispatch_set|_dispatch_append|_dispatch_remove|_get_input|AttributeEventToken|initiator|_oldvalue" src/strawchemy -S

# Check project typing/lint configuration for Ruff/mypy rules that might flag Any.
rg -n "ruff|Any|ANN|FA|TC|typing" pyproject.toml setup.cfg tox.ini . -g 'pyproject.toml' -g 'setup.cfg' -g 'tox.ini' -g '*.toml' -S

# Inspect installed SQLAlchemy typing symbols if available in the sandbox runtime.
python3 - <<'PY'
import importlib.util
mods = ["sqlalchemy", "sqlalchemy.orm.attributes"]
for m in mods:
    spec = importlib.util.find_spec(m)
    print(f"{m}: {'found' if spec else 'missing'}")
if importlib.util.find_spec("sqlalchemy"):
    import sqlalchemy
    print("sqlalchemy version:", getattr(sqlalchemy, "__version__", "unknown"))
    try:
        from sqlalchemy.orm.attributes import AttributeEventToken
        print("AttributeEventToken:", AttributeEventToken)
    except Exception as e:
        print("AttributeEventToken import error:", type(e).__name__, e)
PY

Repository: gazorby/strawchemy

Length of output: 18398


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the SQLAlchemy version pinned by this project.
rg -n 'sqlalchemy|SQLAlchemy' pyproject.toml uv.lock poetry.lock requirements*.txt -S

# Inspect the runtime typing surface for the event token and relationship event docs in the installed package, if present.
python3 - <<'PY'
import importlib.util
from pprint import pprint

mods = ["sqlalchemy", "sqlalchemy.orm.attributes", "sqlalchemy.event"]
for m in mods:
    spec = importlib.util.find_spec(m)
    print(f"{m}: {'found' if spec else 'missing'}")

if importlib.util.find_spec("sqlalchemy"):
    import sqlalchemy
    print("sqlalchemy.__version__ =", getattr(sqlalchemy, "__version__", "unknown"))
    try:
        from sqlalchemy.orm.attributes import AttributeEventToken
        print("AttributeEventToken =", AttributeEventToken)
    except Exception as e:
        print("AttributeEventToken import failed:", type(e).__name__, e)

    try:
        from sqlalchemy.orm import attributes
        names = [n for n in dir(attributes) if "Event" in n or "Token" in n or "NO_VALUE" in n]
        pprint(names[:80])
    except Exception as e:
        print("dir(sqlalchemy.orm.attributes) failed:", type(e).__name__, e)
PY

Repository: gazorby/strawchemy

Length of output: 16741


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import importlib.util
import inspect

print("sqlalchemy:", bool(importlib.util.find_spec("sqlalchemy")))
if importlib.util.find_spec("sqlalchemy"):
    import sqlalchemy
    print("sqlalchemy.__version__ =", getattr(sqlalchemy, "__version__", "unknown"))

    from sqlalchemy.orm.attributes import AttributeEventToken
    print("AttributeEventToken import ok:", AttributeEventToken)
    print("AttributeEventToken module:", AttributeEventToken.__module__)
    print("AttributeEventToken members:", [n for n in dir(AttributeEventToken) if not n.startswith('_')])

    # Show whether the token exposes the key attribute used by the code.
    try:
        print("has key attribute:", hasattr(AttributeEventToken, "key"))
    except Exception as e:
        print("hasattr(key) error:", type(e).__name__, e)

    # Inspect listener signature docs in installed package if available.
    import sqlalchemy.orm.attributes as attrs
    for name in ["set_attribute", "append", "remove"]:
        obj = getattr(attrs, name, None)
        if obj is not None:
            try:
                print(name, inspect.signature(obj))
            except Exception as e:
                print(name, "signature error:", type(e).__name__, e)
PY

Repository: gazorby/strawchemy

Length of output: 174


🌐 Web query:

SQLAlchemy attribute event set listener initiator type AttributeEventToken oldvalue object None documentation

💡 Result:

In SQLAlchemy, the AttributeEventToken (formerly named AttributeEvent in versions prior to 2.0) is a token propagated through a chain of attribute events to track the source and control the propagation of operations [1][2]. When using the AttributeEvents.set listener, the initiator argument receives an instance of this token, which serves as an indicator of the event's origin [1][3][4]. Regarding your specific interests: 1. AttributeEventToken: This class acts as a propagation token [1][2]. Its primary attributes are impl (the AttributeImpl that initiated the event) and op (a symbol indicating the operation type, such as OP_APPEND, OP_REMOVE, OP_REPLACE, or OP_BULK_REPLACE) [1][5]. 2. Role in 'set' events: When an attribute 'set' event occurs, the initiator argument is passed to your listener function [3][4]. While often used internally by SQLAlchemy's backref handlers to manage chains of dependent attribute changes, you can inspect it to understand the source of the event [1][2][4]. 3. oldvalue: In the AttributeEvents.set signature, the oldvalue parameter represents the previous value of the attribute being modified [3][6]. It is passed as an object or potentially None if the attribute had no previous value or if SQLAlchemy was not able to retrieve it [1][6]. 4. Interaction: You can safely use these arguments in your listener signature: def my_set_listener(target, value, oldvalue, initiator): # 'target' is the instance being modified # 'value' is the new value being set # 'oldvalue' is the previous value (or None) # 'initiator' is the AttributeEventToken instance pass The initiator is an object and will not be None under normal operation of the event system, as it is designed to represent the event's path [1][7]. If you are seeing None, it may suggest an unusual invocation path or a custom implementation [7].

Citations:


🌐 Web query:

site:docs.sqlalchemy.org AttributeEventToken set listener initiator oldvalue object relationship event

💡 Result:

In SQLAlchemy, AttributeEventToken (formerly named AttributeEvent in versions prior to 2.0) is a specialized object used to track and control the propagation of attribute events across a chain of operations [1][2]. When you define event listeners for ORM attributes—such as those using AttributeEvents.set, AttributeEvents.append, or AttributeEvents.remove—these listeners often accept an initiator argument [1][2]. This initiator is typically an instance of AttributeEventToken [2]. Key characteristics and roles of the AttributeEventToken include: 1. Identification of Source: It acts as an indicator of the source of the event, allowing the system to identify the original operation that triggered a sequence of changes [1][2]. 2. Propagation Control: It is used to manage and control the propagation of events across mutually dependent attributes, most notably in backref event handlers [1][2]. 3. Event Chaining: Because attribute changes (like setting a value or appending to a collection) can trigger cascading changes in related objects or attributes, the token is propagated through this chain to prevent infinite loops or redundant operations [1][2]. 4. Listener Signature: In a standard event listener function (e.g., a validator or a 'set' listener), the initiator is passed as the final argument [3][2]. For example: def my_listener(target, value, oldvalue, initiator): # 'initiator' is the AttributeEventToken instance pass [3] The token provides access to the AttributeImpl (the current initiator) and the operation type (such as OP_APPEND, OP_REMOVE, OP_REPLACE, or OP_BULK_REPLACE), which helps in inspecting the specific nature of the change within the event handler [1][2].

Citations:


Replace the SQLAlchemy event callback annotations. Use AttributeEventToken for initiator, object for _oldvalue, and DeclarativeBase | None for value; clearing a to-one relation dispatches None, and handle_set already accepts it.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 192-192: Dynamically typed expressions (typing.Any) are disallowed in initiator

(ANN401)


[warning] 195-195: Dynamically typed expressions (typing.Any) are disallowed in _oldvalue

(ANN401)


[warning] 195-195: Dynamically typed expressions (typing.Any) are disallowed in initiator

(ANN401)


[warning] 199-199: Dynamically typed expressions (typing.Any) are disallowed in initiator

(ANN401)


[warning] 203-203: Dynamically typed expressions (typing.Any) are disallowed in initiator

(ANN401)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/strawchemy/schema/mutation/input.py` around lines 192 - 203, The
SQLAlchemy event callback signatures in RelationInput use the wrong annotation
types, so update the methods in RelationInput to match the actual event
payloads. In _dispatch_set, _dispatch_append, and _dispatch_remove, annotate
initiator as AttributeEventToken, use object for _oldvalue, and allow
DeclarativeBase | None for value where applicable, since clearing a to-one
relation passes None and handle_set already supports it. Make the signature
changes consistently across these event handler methods so the callback types
align with SQLAlchemy.

Source: Linters/SAST tools

if (relation := self._get_input(target, initiator)) is not None:
relation.handle_remove(value)


@dataclass
class _InputVisitor(VisitorProtocol[DeclarativeBaseT], Generic[DeclarativeBaseT, InputModel]):
input_data: Input[InputModel]
Expand Down Expand Up @@ -228,7 +283,7 @@ def model(
delattr(model, attribute)

for relation in self.current_relations:
self.input_data.add_relation(RelationInput.from_unbound(relation, model))
self.input_data.add_relation(RelationInput.from_unbound(relation, model, self.input_data.registry))
self.current_relations.clear()
# Return dict because .model_validate will be called at root level
return model if level == 1 or self.input_data.validation is None else params
Expand All @@ -250,8 +305,11 @@ def __init__(
self,
dtos: MappedGraphQLDTO[InputModel] | Sequence[MappedGraphQLDTO[InputModel]],
_validation_: ValidationProtocol[InputModel] | None = None,
*,
registry: EventRegistry | None = None,
**override: Any,
) -> None:
self.registry = registry if registry is not None else EventRegistry()
self.max_level = 0
self.relations: list[RelationInput] = []
self.instances: list[InputModel] = []
Expand Down Expand Up @@ -305,6 +363,7 @@ def _add_non_input_relations(
relation = RelationInput(
attribute=relationship,
parent=model,
event_registry=self.registry,
level=_level,
input_index=input_index,
relation_type=relation_type,
Expand Down
Loading
Loading