Skip to content

Commit 337665b

Browse files
authored
Merge pull request #139 from Waszker/fix/postgresql-trigger-identity
fix: support duplicate PostgreSQL trigger names
2 parents e820c4d + b81a9d3 commit 337665b

5 files changed

Lines changed: 95 additions & 11 deletions

File tree

src/sqlalchemy_declarative_extensions/dialects/postgresql/trigger.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ class Trigger(base.Trigger):
9494
old_table: str | None = None
9595
new_table: str | None = None
9696

97+
@property
98+
def identity(self) -> tuple[str, str]:
99+
return self.on, self.name
100+
97101
@classmethod
98102
def before(
99103
cls, *events: TriggerEvents | str, on: str, execute: str, name: str = ""

src/sqlalchemy_declarative_extensions/trigger/base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field, replace
4-
from typing import Iterable, Sequence
4+
from typing import Hashable, Iterable, Sequence
55

66
from sqlalchemy import MetaData
77
from sqlalchemy.engine import Connection
@@ -18,6 +18,11 @@ class Trigger:
1818
on: str
1919
execute: str
2020

21+
@property
22+
def identity(self) -> Hashable:
23+
"""Return the dialect-specific identity used to compare triggers."""
24+
return self.name
25+
2126
def named(self, name: str):
2227
return replace(self, name=name)
2328

src/sqlalchemy_declarative_extensions/trigger/compare.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,34 +51,36 @@ def to_sql(self, connection: Connection | None = None) -> list[str]:
5151
def compare_triggers(connection: Connection, triggers: Triggers) -> list[Operation]:
5252
result: list[Operation] = []
5353

54-
triggers_by_name = {r.name: r for r in triggers.triggers}
55-
expected_trigger_names = set(triggers_by_name)
54+
triggers_by_identity = {r.identity: r for r in triggers.triggers}
55+
expected_trigger_identities = set(triggers_by_identity)
5656

5757
raw_existing_triggers = get_triggers(connection)
5858
existing_triggers = filter_triggers(
5959
raw_existing_triggers, exclude=triggers.ignore, include=triggers.include
6060
)
6161

62-
existing_triggers_by_name = {r.name: r for r in existing_triggers}
63-
existing_trigger_names = set(existing_triggers_by_name)
62+
existing_triggers_by_identity = {r.identity: r for r in existing_triggers}
63+
existing_trigger_identities = set(existing_triggers_by_identity)
6464

65-
new_trigger_names = expected_trigger_names - existing_trigger_names
66-
removed_trigger_names = existing_trigger_names - expected_trigger_names
65+
new_trigger_identities = expected_trigger_identities - existing_trigger_identities
66+
removed_trigger_identities = (
67+
existing_trigger_identities - expected_trigger_identities
68+
)
6769

6870
for trigger in triggers:
69-
trigger_created = trigger.name in new_trigger_names
71+
trigger_created = trigger.identity in new_trigger_identities
7072

7173
if trigger_created:
7274
result.append(CreateTriggerOp(trigger))
7375
else:
74-
existing_trigger = existing_triggers_by_name[trigger.name]
76+
existing_trigger = existing_triggers_by_identity[trigger.identity]
7577

7678
if existing_trigger != trigger:
7779
result.append(UpdateTriggerOp(existing_trigger, trigger))
7880

7981
if not triggers.ignore_unspecified:
80-
for removed_trigger in removed_trigger_names:
81-
trigger = existing_triggers_by_name[removed_trigger]
82+
for removed_trigger in removed_trigger_identities:
83+
trigger = existing_triggers_by_identity[removed_trigger]
8284
result.append(DropTriggerOp(trigger))
8385

8486
return result

tests/trigger/test_create_postgres.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ class Base(_Base): # type: ignore
3737
.named("on_insert_foo")
3838
.when("pg_trigger_depth() < 1")
3939
.for_each_row(),
40+
Trigger.after("insert", on="bar", execute="gimme")
41+
.named("on_insert_foo")
42+
.when("pg_trigger_depth() < 1")
43+
.for_each_row(),
4044
)
4145

4246

@@ -46,6 +50,12 @@ class Foo(Base):
4650
id = Column(types.Integer(), primary_key=True)
4751

4852

53+
class Bar(Base):
54+
__tablename__ = "bar"
55+
56+
id = Column(types.Integer(), primary_key=True)
57+
58+
4959
register_sqlalchemy_events(Base.metadata, functions=True, triggers=True)
5060

5161
pg = create_postgres_fixture(engine_kwargs={"echo": True}, session=True)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from unittest.mock import Mock
2+
3+
from sqlalchemy_declarative_extensions import Triggers
4+
from sqlalchemy_declarative_extensions.dialects.postgresql import Trigger
5+
from sqlalchemy_declarative_extensions.trigger.compare import (
6+
CreateTriggerOp,
7+
DropTriggerOp,
8+
UpdateTriggerOp,
9+
compare_triggers,
10+
)
11+
12+
13+
def trigger(on: str, event: str = "insert"):
14+
return Trigger.after(event, on=on, execute="gimme").named("on_change")
15+
16+
17+
def test_same_name_on_different_tables_matches_existing(monkeypatch):
18+
expected = Triggers().are(trigger("foo"), trigger("bar"))
19+
monkeypatch.setattr(
20+
"sqlalchemy_declarative_extensions.trigger.compare.get_triggers",
21+
Mock(return_value=list(expected)),
22+
)
23+
24+
assert compare_triggers(Mock(), expected) == []
25+
26+
27+
def test_same_name_on_different_table_is_created(monkeypatch):
28+
foo_trigger = trigger("foo")
29+
bar_trigger = trigger("bar")
30+
expected = Triggers().are(foo_trigger, bar_trigger)
31+
monkeypatch.setattr(
32+
"sqlalchemy_declarative_extensions.trigger.compare.get_triggers",
33+
Mock(return_value=[foo_trigger]),
34+
)
35+
36+
assert compare_triggers(Mock(), expected) == [CreateTriggerOp(bar_trigger)]
37+
38+
39+
def test_same_name_updates_only_trigger_on_matching_table(monkeypatch):
40+
foo_trigger = trigger("foo")
41+
old_bar_trigger = trigger("bar")
42+
new_bar_trigger = trigger("bar", event="update")
43+
expected = Triggers().are(foo_trigger, new_bar_trigger)
44+
monkeypatch.setattr(
45+
"sqlalchemy_declarative_extensions.trigger.compare.get_triggers",
46+
Mock(return_value=[foo_trigger, old_bar_trigger]),
47+
)
48+
49+
assert compare_triggers(Mock(), expected) == [
50+
UpdateTriggerOp(old_bar_trigger, new_bar_trigger)
51+
]
52+
53+
54+
def test_same_name_drops_only_trigger_on_unspecified_table(monkeypatch):
55+
foo_trigger = trigger("foo")
56+
bar_trigger = trigger("bar")
57+
expected = Triggers().are(foo_trigger)
58+
monkeypatch.setattr(
59+
"sqlalchemy_declarative_extensions.trigger.compare.get_triggers",
60+
Mock(return_value=[foo_trigger, bar_trigger]),
61+
)
62+
63+
assert compare_triggers(Mock(), expected) == [DropTriggerOp(bar_trigger)]

0 commit comments

Comments
 (0)