Skip to content

fix(mutation): only register sqla events once per mapper - #201

Merged
gazorby merged 4 commits into
mainfrom
worktree-better-sqla-event-listeners
Jul 16, 2026
Merged

fix(mutation): only register sqla events once per mapper#201
gazorby merged 4 commits into
mainfrom
worktree-better-sqla-event-listeners

Conversation

@gazorby

@gazorby gazorby commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • Refactor
    • Updated mutation and relationship inputs to share a centralized event registry, ensuring relationship mutation events are routed to the correct owning input.
    • Mutation field construction and Input now support using a shared registry instance.
  • Tests
    • Added unit tests covering relationship set/append/remove behavior and verifying SQLAlchemy listeners are registered only once and reused across inputs.
  • Chores
    • Reformatted configuration in the project’s build/test/lint settings without changing effective behavior.

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@gazorby, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: eef60a92-7f1f-47be-b7a1-173719264a09

📥 Commits

Reviewing files that changed from the base of the PR and between 76d6247 and f2a6c04.

📒 Files selected for processing (6)
  • cog.toml
  • src/strawchemy/mapper.py
  • src/strawchemy/schema/mutation/field_builder.py
  • src/strawchemy/schema/mutation/fields.py
  • src/strawchemy/schema/mutation/input.py
  • tests/unit/test_event_registry.py
📝 Walkthrough

Walkthrough

Introduces EventRegistry for shared SQLAlchemy relationship event routing, threads it through mutation input construction, and adds tests for per-parent routing and listener deduplication. pyproject.toml is reformatted without changing its entries.

Changes

EventRegistry: relationship event routing

Layer / File(s) Summary
EventRegistry and RelationInput/Input contracts
src/strawchemy/schema/mutation/input.py
Imports WeakValueDictionary, exports EventRegistry, adds RelationInput registry state and registration, replaces relation callbacks with handle_set/handle_append/handle_remove, implements EventRegistry with weak ownership tracking and one-time per-attribute listener wiring, and extends Input to accept and reuse a registry across relation attachment paths.
Mutation builder and field wiring
src/strawchemy/mapper.py, src/strawchemy/schema/mutation/field_builder.py, src/strawchemy/schema/mutation/fields.py
Strawchemy creates a shared EventRegistry and passes it into MutationFieldBuilder; MutationFieldBuilder stores the registry and injects it for create, update, and upsert mutation fields; mutation field constructors retain the registry and pass it into Input(..., registry=...) across create, upsert, update-by-ids, and update-by-filter resolvers.
EventRegistry routing and deduplication tests
tests/unit/test_event_registry.py
Adds helpers for Color.fruits and Fruit.color relationship properties, then tests per-parent routing for append, remove, and set events, plus one-time listener registration and reuse of a shared registry across multiple Input builds.

pyproject.toml formatting

Layer / File(s) Summary
pyproject.toml whitespace and list reformatting
pyproject.toml
Reformats project classifiers, dependencies, dependency-groups, git-cliff parser settings, pytest markers, ruff excludes and ignores, and ty.src exclude patterns with equivalent content.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: mutation SQLAlchemy events are now registered once per mapper/relationship instead of per input.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-better-sqla-event-listeners

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/unit/test_event_registry.py (1)

32-99: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add TO_ONE set/clear regression coverage.

These tests only validate TO_MANY append/remove wiring paths. Please add one TO_ONE case that exercises reassignment and clear (value -> other_value -> None) so stale state regressions in handle_set are caught.

🤖 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 `@tests/unit/test_event_registry.py` around lines 32 - 99, The existing test
cases only validate TO_MANY append/remove wiring paths. Add a new test function
that covers TO_ONE relationship set/clear operations to catch stale state
regressions in the handle_set method. Create a test that exercises TO_ONE
reassignment (changing from one value to another) and clearing (setting to
None), similar to how test_event_routes_only_to_owning_relation validates
TO_MANY operations. The test should create a Color instance with a TO_ONE
relationship, reassign that relationship to a different value, then clear it to
None, and verify that the EventRegistry properly tracks each state change in the
corresponding RelationInput without stale data.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@pyproject.toml`:
- Line 316: In the ruff exclude configuration, the alembic pattern entry
contains two trailing spaces that need to be removed. Locate the line with
"alembic/*  " in the exclude list and remove the two trailing spaces after the
asterisk, leaving just "alembic/*" to match the clean formatting of all other
entries in the exclude list.

In `@src/strawchemy/schema/mutation/input.py`:
- Around line 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.

---

Outside diff comments:
In `@tests/unit/test_event_registry.py`:
- Around line 32-99: The existing test cases only validate TO_MANY append/remove
wiring paths. Add a new test function that covers TO_ONE relationship set/clear
operations to catch stale state regressions in the handle_set method. Create a
test that exercises TO_ONE reassignment (changing from one value to another) and
clearing (setting to None), similar to how
test_event_routes_only_to_owning_relation validates TO_MANY operations. The test
should create a Color instance with a TO_ONE relationship, reassign that
relationship to a different value, then clear it to None, and verify that the
EventRegistry properly tracks each state change in the corresponding
RelationInput without stale data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a100093f-d476-408b-996c-5156ecfe7ab1

📥 Commits

Reviewing files that changed from the base of the PR and between ea50659 and 7b8d0d3.

📒 Files selected for processing (6)
  • pyproject.toml
  • src/strawchemy/mapper.py
  • src/strawchemy/schema/mutation/field_builder.py
  • src/strawchemy/schema/mutation/fields.py
  • src/strawchemy/schema/mutation/input.py
  • tests/unit/test_event_registry.py

Comment thread pyproject.toml
"node_modules",
"venv",
"tests/codegen/snapshots",
"alembic/* ",

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether "alembic/*  " with trailing spaces is the intended pattern.
# Check if this pattern appears elsewhere in the repo or if it's unique to this PR.

rg -n 'alembic/\*' pyproject.toml --no-heading
git log --oneline -p --follow -S 'alembic/*' -- pyproject.toml | head -50 2>/dev/null || echo "(git history unavailable)"

Repository: gazorby/strawchemy

Length of output: 1852


🏁 Script executed:

sed -n '290,330p' pyproject.toml | cat -A

Repository: gazorby/strawchemy

Length of output: 1104


Remove trailing whitespace from ruff exclude glob pattern.

Line 316 contains "alembic/* " with two trailing spaces inside the string literal. All other entries in the same exclude list are clean (e.g., .bzr, .direnv, tests/codegen/snapshots), making this trailing whitespace clearly unintentional. The glob pattern will not match the intended paths with this extra whitespace.

Change "alembic/* " to "alembic/*".

🤖 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 `@pyproject.toml` at line 316, In the ruff exclude configuration, the alembic
pattern entry contains two trailing spaces that need to be removed. Locate the
line with "alembic/*  " in the exclude list and remove the two trailing spaces
after the asterisk, leaving just "alembic/*" to match the clean formatting of
all other entries in the exclude list.

Comment on lines +129 to 136
def handle_set(self, value: DeclarativeBase | None) -> None:
if value is None:
return
if _has_record(value):
self.set = [value]
else:
self.create = [value]

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.

@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.22%. Comparing base (c3cb4d1) to head (f2a6c04).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #201      +/-   ##
==========================================
- Coverage   93.26%   93.22%   -0.04%     
==========================================
  Files          72       72              
  Lines        6486     6524      +38     
  Branches      853      858       +5     
==========================================
+ Hits         6049     6082      +33     
- Misses        296      297       +1     
- Partials      141      145       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/unit/test_event_registry.py (1)

97-150: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a regression for unshared/default registries.

These tests only prove dedupe within a single shared registry. Add a case that builds two independent EventRegistry/default Input paths and asserts the mapper attribute is still wired once, matching the PR objective.

🤖 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 `@tests/unit/test_event_registry.py` around lines 97 - 150, Add a regression
test covering the unshared/default registry path, not just the shared
EventRegistry case. Extend the event registry tests to build two independent
registries or two default Input constructions and verify the mapper attribute
listener on _fruits_prop() is still registered only once. Reuse the existing
counting_listens_for pattern and the EventRegistry, Input, and Strawchemy entry
points so the new test exercises the same wiring behavior without relying on a
shared registry instance.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/strawchemy/schema/mutation/input.py`:
- Around line 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.
- Around line 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.

---

Outside diff comments:
In `@tests/unit/test_event_registry.py`:
- Around line 97-150: Add a regression test covering the unshared/default
registry path, not just the shared EventRegistry case. Extend the event registry
tests to build two independent registries or two default Input constructions and
verify the mapper attribute listener on _fruits_prop() is still registered only
once. Reuse the existing counting_listens_for pattern and the EventRegistry,
Input, and Strawchemy entry points so the new test exercises the same wiring
behavior without relying on a shared registry instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e59e3ac8-483a-486a-a490-2804c9e0de49

📥 Commits

Reviewing files that changed from the base of the PR and between 7b8d0d3 and 76d6247.

📒 Files selected for processing (2)
  • src/strawchemy/schema/mutation/input.py
  • tests/unit/test_event_registry.py

Comment on lines +170 to +190
_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)

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.

Comment on lines +192 to +203
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:

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

@gazorby
gazorby force-pushed the worktree-better-sqla-event-listeners branch from 76d6247 to f2a6c04 Compare July 16, 2026 17:40
@gazorby
gazorby merged commit c663958 into main Jul 16, 2026
97 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant