sqlalchemy-events-lib 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/PKG-INFO +36 -18
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/README.md +35 -17
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/pyproject.toml +1 -1
- sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/__init__.py +14 -0
- sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +69 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/core.py +5 -4
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/decorators.py +2 -2
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/events.py +1 -1
- sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/handlers.py +50 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +30 -16
- sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/types.py +26 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/utils.py +12 -2
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/PKG-INFO +36 -18
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/SOURCES.txt +2 -2
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/conftest.py +2 -2
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/test_autodiscover.py +7 -7
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/test_is_created_triggers.py +10 -6
- sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/__init__.py +0 -8
- sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +0 -28
- sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/enums.py +0 -15
- sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/handler.py +0 -52
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/LICENSE +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/setup.cfg +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/callbacks_strategies/__init__.py +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/callbacks_strategies/base.py +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/discovery.py +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/exceptions.py +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/init_triggers_strategies/__init__.py +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/init_triggers_strategies/base.py +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/registry.py +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/dependency_links.txt +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/requires.txt +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/top_level.txt +0 -0
- {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-events-lib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Event-driven extension for SQLAlchemy that enables listening to database CUD events. This library allows you to react to database changes in real time using a clean, declarative API.
|
|
5
5
|
Author-email: Alexey Kostarev <normjkeeewm@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,16 +61,35 @@ class UserModel(Base):
|
|
|
61
61
|
___
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
### Define event handlers using
|
|
64
|
+
### Define event handlers using decorators `@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler`
|
|
65
65
|
services/handlers.py
|
|
66
66
|
```python
|
|
67
67
|
from models import UserModel
|
|
68
|
-
from sqlalchemy_events import
|
|
68
|
+
from sqlalchemy_events import sa_insert_handler
|
|
69
69
|
|
|
70
|
-
@
|
|
70
|
+
@sa_insert_handler(UserModel)
|
|
71
71
|
async def handle_user_insert():
|
|
72
|
-
print(
|
|
72
|
+
print('User inserted!')
|
|
73
73
|
```
|
|
74
|
+
|
|
75
|
+
**Optional Argument**: `rows`
|
|
76
|
+
Handlers can optionally accept a rows argument.
|
|
77
|
+
|
|
78
|
+
If the parameter is declared in the function signature, it will automatically receive a list of affected `row` IDs.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from models import UserModel
|
|
82
|
+
from sqlalchemy_events import sa_insert_handler
|
|
83
|
+
|
|
84
|
+
@sa_insert_handler(UserModel)
|
|
85
|
+
async def handle_user_insert(rows: list[DB_ID]):
|
|
86
|
+
print('Users inserted!', rows)
|
|
87
|
+
```
|
|
88
|
+
**Output:**
|
|
89
|
+
```
|
|
90
|
+
Users inserted! ['3310aa38-f555-4c05-a57f-5acae32a0a7b', '6aeba1b0-8e80-4369-8adf-cf967ab7ba7a']
|
|
91
|
+
```
|
|
92
|
+
|
|
74
93
|
___
|
|
75
94
|
|
|
76
95
|
### Configure SQLAlchemy async engine
|
|
@@ -96,17 +115,13 @@ from session import engine
|
|
|
96
115
|
|
|
97
116
|
|
|
98
117
|
async def main():
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
autodiscover_paths=['services']
|
|
104
|
-
).init()
|
|
118
|
+
SQLAlchemyEvents(
|
|
119
|
+
base=Base,
|
|
120
|
+
engine=engine,
|
|
121
|
+
autodiscover_paths=['services']
|
|
105
122
|
)
|
|
106
|
-
|
|
123
|
+
while True:
|
|
107
124
|
await asyncio.sleep(9999)
|
|
108
|
-
except:
|
|
109
|
-
pass
|
|
110
125
|
|
|
111
126
|
if __name__ == '__main__':
|
|
112
127
|
asyncio.run(main())
|
|
@@ -129,7 +144,10 @@ Parameters:
|
|
|
129
144
|
* **base** - SQLAlchemy declarative base class used to discover mapped models.
|
|
130
145
|
* **engine** - SQLAlchemy Engine or AsyncEngine instance.
|
|
131
146
|
* **autodiscover_paths** - List of Python module paths where event handlers are defined.
|
|
132
|
-
These modules are automatically imported so that decorators such as
|
|
147
|
+
These modules are automatically imported so that decorators such as
|
|
148
|
+
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
149
|
+
**Example:**
|
|
150
|
+
`autodiscover_paths=["services", "app.handlers"]`
|
|
133
151
|
|
|
134
152
|
### Important:
|
|
135
153
|
|
|
@@ -149,13 +167,13 @@ If provided, the library will log internal lifecycle events such as:
|
|
|
149
167
|
```python
|
|
150
168
|
import logging
|
|
151
169
|
|
|
152
|
-
logger = logging.getLogger(
|
|
170
|
+
logger = logging.getLogger('sqlalchemy_events')
|
|
153
171
|
logger.setLevel(logging.INFO)
|
|
154
172
|
|
|
155
173
|
SQLAlchemyEvents(
|
|
156
174
|
base=Base,
|
|
157
175
|
engine=engine,
|
|
158
|
-
autodiscover_paths=[
|
|
176
|
+
autodiscover_paths=['services'],
|
|
159
177
|
logger=logger
|
|
160
178
|
)
|
|
161
179
|
```
|
|
@@ -173,7 +191,7 @@ SQLAlchemyEvents(
|
|
|
173
191
|
* Ensure handlers are imported via autodiscover_paths, otherwise they will not be registered
|
|
174
192
|
|
|
175
193
|
## Example flow
|
|
176
|
-
INSERT INTO {table} → DATABASE trigger fires →
|
|
194
|
+
INSERT INTO/UPDATE/DELETE FROM {table} → DATABASE trigger fires →
|
|
177
195
|
NOTIFY → Python listener receives event →
|
|
178
196
|
handler is executed
|
|
179
197
|
|
|
@@ -39,16 +39,35 @@ class UserModel(Base):
|
|
|
39
39
|
___
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
### Define event handlers using
|
|
42
|
+
### Define event handlers using decorators `@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler`
|
|
43
43
|
services/handlers.py
|
|
44
44
|
```python
|
|
45
45
|
from models import UserModel
|
|
46
|
-
from sqlalchemy_events import
|
|
46
|
+
from sqlalchemy_events import sa_insert_handler
|
|
47
47
|
|
|
48
|
-
@
|
|
48
|
+
@sa_insert_handler(UserModel)
|
|
49
49
|
async def handle_user_insert():
|
|
50
|
-
print(
|
|
50
|
+
print('User inserted!')
|
|
51
51
|
```
|
|
52
|
+
|
|
53
|
+
**Optional Argument**: `rows`
|
|
54
|
+
Handlers can optionally accept a rows argument.
|
|
55
|
+
|
|
56
|
+
If the parameter is declared in the function signature, it will automatically receive a list of affected `row` IDs.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from models import UserModel
|
|
60
|
+
from sqlalchemy_events import sa_insert_handler
|
|
61
|
+
|
|
62
|
+
@sa_insert_handler(UserModel)
|
|
63
|
+
async def handle_user_insert(rows: list[DB_ID]):
|
|
64
|
+
print('Users inserted!', rows)
|
|
65
|
+
```
|
|
66
|
+
**Output:**
|
|
67
|
+
```
|
|
68
|
+
Users inserted! ['3310aa38-f555-4c05-a57f-5acae32a0a7b', '6aeba1b0-8e80-4369-8adf-cf967ab7ba7a']
|
|
69
|
+
```
|
|
70
|
+
|
|
52
71
|
___
|
|
53
72
|
|
|
54
73
|
### Configure SQLAlchemy async engine
|
|
@@ -74,17 +93,13 @@ from session import engine
|
|
|
74
93
|
|
|
75
94
|
|
|
76
95
|
async def main():
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
autodiscover_paths=['services']
|
|
82
|
-
).init()
|
|
96
|
+
SQLAlchemyEvents(
|
|
97
|
+
base=Base,
|
|
98
|
+
engine=engine,
|
|
99
|
+
autodiscover_paths=['services']
|
|
83
100
|
)
|
|
84
|
-
|
|
101
|
+
while True:
|
|
85
102
|
await asyncio.sleep(9999)
|
|
86
|
-
except:
|
|
87
|
-
pass
|
|
88
103
|
|
|
89
104
|
if __name__ == '__main__':
|
|
90
105
|
asyncio.run(main())
|
|
@@ -107,7 +122,10 @@ Parameters:
|
|
|
107
122
|
* **base** - SQLAlchemy declarative base class used to discover mapped models.
|
|
108
123
|
* **engine** - SQLAlchemy Engine or AsyncEngine instance.
|
|
109
124
|
* **autodiscover_paths** - List of Python module paths where event handlers are defined.
|
|
110
|
-
These modules are automatically imported so that decorators such as
|
|
125
|
+
These modules are automatically imported so that decorators such as
|
|
126
|
+
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
127
|
+
**Example:**
|
|
128
|
+
`autodiscover_paths=["services", "app.handlers"]`
|
|
111
129
|
|
|
112
130
|
### Important:
|
|
113
131
|
|
|
@@ -127,13 +145,13 @@ If provided, the library will log internal lifecycle events such as:
|
|
|
127
145
|
```python
|
|
128
146
|
import logging
|
|
129
147
|
|
|
130
|
-
logger = logging.getLogger(
|
|
148
|
+
logger = logging.getLogger('sqlalchemy_events')
|
|
131
149
|
logger.setLevel(logging.INFO)
|
|
132
150
|
|
|
133
151
|
SQLAlchemyEvents(
|
|
134
152
|
base=Base,
|
|
135
153
|
engine=engine,
|
|
136
|
-
autodiscover_paths=[
|
|
154
|
+
autodiscover_paths=['services'],
|
|
137
155
|
logger=logger
|
|
138
156
|
)
|
|
139
157
|
```
|
|
@@ -151,7 +169,7 @@ SQLAlchemyEvents(
|
|
|
151
169
|
* Ensure handlers are imported via autodiscover_paths, otherwise they will not be registered
|
|
152
170
|
|
|
153
171
|
## Example flow
|
|
154
|
-
INSERT INTO {table} → DATABASE trigger fires →
|
|
172
|
+
INSERT INTO/UPDATE/DELETE FROM {table} → DATABASE trigger fires →
|
|
155
173
|
NOTIFY → Python listener receives event →
|
|
156
174
|
handler is executed
|
|
157
175
|
|
|
@@ -10,7 +10,7 @@ where = ["src"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "sqlalchemy-events-lib"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.2.0"
|
|
14
14
|
description = "Event-driven extension for SQLAlchemy that enables listening to database CUD events. This library allows you to react to database changes in real time using a clean, declarative API."
|
|
15
15
|
requires-python = ">=3.12"
|
|
16
16
|
authors = [
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .core import SQLAlchemyEvents
|
|
2
|
+
from .decorators import with_events
|
|
3
|
+
from .handlers import sa_insert_handler, sa_update_handler, sa_delete_handler
|
|
4
|
+
from .types import SaEvent
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'SQLAlchemyEvents',
|
|
8
|
+
'sa_insert_handler',
|
|
9
|
+
'sa_update_handler',
|
|
10
|
+
'sa_delete_handler',
|
|
11
|
+
'with_events',
|
|
12
|
+
'SaEvent'
|
|
13
|
+
]
|
|
14
|
+
__version__ = '0.2.0'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import inspect
|
|
4
|
+
import signal
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .base import SaEventsCallbacksStrategy
|
|
8
|
+
from ..registry import get_event_handlers
|
|
9
|
+
from ..types import Handler
|
|
10
|
+
from ..utils import _process_pool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
signal.signal(signal.SIGINT, lambda sig, frame: sys.exit(0))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PostgresCallback(SaEventsCallbacksStrategy):
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.__handlers = get_event_handlers()
|
|
20
|
+
|
|
21
|
+
async def handle(self, *args, **kwargs):
|
|
22
|
+
data = json.loads(args[3] or '{}')
|
|
23
|
+
trigger = data['op']
|
|
24
|
+
rows = data['rows']
|
|
25
|
+
trig_name = f'sa_{data['table']}_{trigger}_notify'
|
|
26
|
+
handlers: list[Handler] = self.__handlers.get(trig_name, [])
|
|
27
|
+
if not handlers:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
loop = asyncio.get_running_loop()
|
|
31
|
+
tasks = []
|
|
32
|
+
|
|
33
|
+
for handler in handlers:
|
|
34
|
+
func = handler.func
|
|
35
|
+
sig = inspect.signature(func)
|
|
36
|
+
|
|
37
|
+
params = list(sig.parameters.values())
|
|
38
|
+
rows_index = None
|
|
39
|
+
|
|
40
|
+
for i, p in enumerate(params):
|
|
41
|
+
if p.name == 'rows':
|
|
42
|
+
rows_index = i
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
def build_args():
|
|
46
|
+
args = []
|
|
47
|
+
kwargs = {}
|
|
48
|
+
|
|
49
|
+
if rows_index is not None:
|
|
50
|
+
if not any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params):
|
|
51
|
+
args.insert(rows_index, rows)
|
|
52
|
+
else:
|
|
53
|
+
kwargs['rows'] = rows
|
|
54
|
+
return args, kwargs
|
|
55
|
+
|
|
56
|
+
args, kwargs = build_args()
|
|
57
|
+
|
|
58
|
+
if asyncio.iscoroutinefunction(func):
|
|
59
|
+
tasks.append(func(*args, **kwargs))
|
|
60
|
+
else:
|
|
61
|
+
tasks.append(
|
|
62
|
+
loop.run_in_executor(
|
|
63
|
+
_process_pool,
|
|
64
|
+
func,
|
|
65
|
+
*args,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
await asyncio.gather(*tasks)
|
|
@@ -27,8 +27,9 @@ class SQLAlchemyEvents:
|
|
|
27
27
|
self.engine = engine
|
|
28
28
|
self.autodiscover_paths = autodiscover_paths
|
|
29
29
|
self.logger = logger
|
|
30
|
+
asyncio.create_task(self.__init())
|
|
30
31
|
|
|
31
|
-
async def
|
|
32
|
+
async def __init(self) -> None:
|
|
32
33
|
if not await self.__find_handlers():
|
|
33
34
|
return
|
|
34
35
|
|
|
@@ -53,18 +54,18 @@ class SQLAlchemyEvents:
|
|
|
53
54
|
filtered_handlers = []
|
|
54
55
|
handler_paths = set()
|
|
55
56
|
for handler in res_handlers:
|
|
56
|
-
file_path = inspect.getsourcefile(handler) or inspect.getfile(handler)
|
|
57
|
+
file_path = inspect.getsourcefile(handler.func) or inspect.getfile(handler.func)
|
|
57
58
|
|
|
58
59
|
file_func = Path(file_path)
|
|
59
60
|
file_name = file_func.name
|
|
60
|
-
handler_path = f'{file_func} {handler.__name__}'
|
|
61
|
+
handler_path = f'{file_func} {handler.func.__name__}'
|
|
61
62
|
if handler_path in handler_paths:
|
|
62
63
|
continue
|
|
63
64
|
|
|
64
65
|
handler_paths.add(handler_path)
|
|
65
66
|
filtered_handlers.append(handler)
|
|
66
67
|
if self.logger:
|
|
67
|
-
self.logger.info(f'[SQLAlchemyEvents] Registered handler {handler.__name__} '
|
|
68
|
+
self.logger.info(f'[SQLAlchemyEvents] Registered handler {handler.func.__name__} '
|
|
68
69
|
f'from {file_func.parent.name}/{file_name}')
|
|
69
70
|
|
|
70
71
|
return filtered_handlers
|
|
@@ -2,7 +2,7 @@ from dataclasses import dataclass
|
|
|
2
2
|
|
|
3
3
|
from .callbacks_strategies.base import SaEventsCallbacksStrategy
|
|
4
4
|
from .callbacks_strategies.postgres_callback import PostgresCallback
|
|
5
|
-
from .
|
|
5
|
+
from .types import Dialect, SaEvent
|
|
6
6
|
from .init_triggers_strategies.base import InitTriggersStrategy
|
|
7
7
|
from .init_triggers_strategies.postgres_init_triggers import PostgresInitTriggers
|
|
8
8
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Callable, Type
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
6
|
+
|
|
7
|
+
from .types import SaEvent
|
|
8
|
+
from .registry import get_event_handlers
|
|
9
|
+
from .types import Handler
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __inner(func: Callable, sa_event: SaEvent, model: Type[DeclarativeBase]):
|
|
13
|
+
event_handlers = get_event_handlers()
|
|
14
|
+
trig_name = f'sa_{model.__tablename__}_{sa_event.lower()}_notify'
|
|
15
|
+
func_path = inspect.getsourcefile(func) or inspect.getfile(func)
|
|
16
|
+
func_path = Path(func_path)
|
|
17
|
+
func_path_name = f'{func_path.parent.name}/{func_path.name}/{func.__name__}'
|
|
18
|
+
handlers: list[Handler] | None = event_handlers.get(trig_name)
|
|
19
|
+
obj_handler = Handler(func=func, args={'model': model})
|
|
20
|
+
if handlers:
|
|
21
|
+
for handler in handlers:
|
|
22
|
+
handler_path = inspect.getsourcefile(handler.func) or inspect.getfile(handler.func)
|
|
23
|
+
handler_path = Path(handler_path)
|
|
24
|
+
handler_path_name = f'{handler_path.parent.name}/{handler_path.name}/{handler.func.__name__}'
|
|
25
|
+
if func_path_name == handler_path_name:
|
|
26
|
+
continue
|
|
27
|
+
handlers.append(obj_handler)
|
|
28
|
+
else:
|
|
29
|
+
event_handlers[trig_name] = [obj_handler]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sa_insert_handler(model: Type[DeclarativeBase]):
|
|
33
|
+
def decorator(func: Callable):
|
|
34
|
+
__inner(func, SaEvent.INSERT, model)
|
|
35
|
+
return func
|
|
36
|
+
return decorator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sa_update_handler(model: Type[DeclarativeBase]):
|
|
40
|
+
def decorator(func: Callable):
|
|
41
|
+
__inner(func, SaEvent.UPDATE, model)
|
|
42
|
+
return func
|
|
43
|
+
return decorator
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sa_delete_handler(model: Type[DeclarativeBase]):
|
|
47
|
+
def decorator(func: Callable):
|
|
48
|
+
__inner(func, SaEvent.DELETE, model)
|
|
49
|
+
return func
|
|
50
|
+
return decorator
|
|
@@ -3,24 +3,35 @@ from typing import Type
|
|
|
3
3
|
from sqlalchemy import text
|
|
4
4
|
from sqlalchemy.orm import DeclarativeBase
|
|
5
5
|
|
|
6
|
-
from ..
|
|
6
|
+
from ..types import SaEvent
|
|
7
7
|
from .base import InitTriggersStrategy
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
class PostgresInitTriggers(InitTriggersStrategy):
|
|
11
11
|
|
|
12
12
|
async def __call__(self, model_list: list[Type[DeclarativeBase]], conn, logger):
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
for sa_event in SaEvent:
|
|
14
|
+
sa_event = sa_event.lower()
|
|
15
|
+
await conn.execute(
|
|
16
|
+
text(f"""
|
|
17
|
+
CREATE OR REPLACE FUNCTION sqlalchemy_events_notify_{sa_event}()
|
|
18
|
+
RETURNS trigger AS $$
|
|
19
|
+
DECLARE
|
|
20
|
+
payload json;
|
|
21
|
+
BEGIN
|
|
22
|
+
payload := json_build_object(
|
|
23
|
+
'op', '{sa_event}',
|
|
24
|
+
'table', TG_TABLE_NAME,
|
|
25
|
+
'rows', array_agg(n.id)
|
|
26
|
+
)
|
|
27
|
+
FROM {'old_rows' if sa_event.upper() == SaEvent.DELETE else 'new_rows'} n;
|
|
28
|
+
|
|
29
|
+
PERFORM pg_notify('sqlalchemy_events', payload::text);
|
|
30
|
+
RETURN NULL;
|
|
31
|
+
END;
|
|
32
|
+
$$ LANGUAGE plpgsql;
|
|
33
|
+
""")
|
|
34
|
+
)
|
|
24
35
|
|
|
25
36
|
for cls in model_list:
|
|
26
37
|
events = getattr(cls, '__events__', None)
|
|
@@ -69,14 +80,17 @@ class PostgresInitTriggers(InitTriggersStrategy):
|
|
|
69
80
|
|
|
70
81
|
added_events.append(event)
|
|
71
82
|
await conn.execute(
|
|
72
|
-
text(f
|
|
83
|
+
text(f'DROP TRIGGER IF EXISTS {trig_name} ON {table_name};')
|
|
73
84
|
)
|
|
74
85
|
|
|
75
86
|
await conn.execute(
|
|
76
87
|
text(f"""
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
CREATE TRIGGER {trig_name}
|
|
89
|
+
AFTER {event} ON {table_name}
|
|
90
|
+
REFERENCING {'OLD' if event == SaEvent.DELETE else 'NEW'}
|
|
91
|
+
TABLE AS {'old_rows' if event == SaEvent.DELETE else 'new_rows'}
|
|
92
|
+
FOR EACH STATEMENT
|
|
93
|
+
EXECUTE FUNCTION sqlalchemy_events_notify_{l_event}();
|
|
80
94
|
""")
|
|
81
95
|
)
|
|
82
96
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Any, Awaitable, Callable, TypeAlias, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SaEvent(StrEnum):
|
|
7
|
+
INSERT = 'INSERT'
|
|
8
|
+
UPDATE = 'UPDATE'
|
|
9
|
+
DELETE = 'DELETE'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Dialect(StrEnum):
|
|
13
|
+
POSTGRESQL = 'postgresql'
|
|
14
|
+
SQLITE = 'sqlite'
|
|
15
|
+
MYSQL = 'mysql'
|
|
16
|
+
MSSQL = 'mssql'
|
|
17
|
+
ORACLE = 'oracle'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Handler:
|
|
22
|
+
func: Union[Callable, Awaitable]
|
|
23
|
+
args: dict[str, Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DB_ID: TypeAlias = str | int
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import multiprocessing as mp
|
|
2
|
+
import os
|
|
3
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
1
4
|
from typing import Union
|
|
5
|
+
|
|
2
6
|
from sqlalchemy.engine import Engine
|
|
3
7
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
4
8
|
|
|
5
|
-
from .
|
|
9
|
+
from .types import Dialect
|
|
6
10
|
|
|
7
11
|
|
|
8
12
|
def dialect_resolver(engine: Union[AsyncEngine, Engine]) -> Dialect:
|
|
@@ -22,4 +26,10 @@ def dialect_resolver(engine: Union[AsyncEngine, Engine]) -> Dialect:
|
|
|
22
26
|
elif dialect_name.startswith('oracle'):
|
|
23
27
|
return Dialect.ORACLE
|
|
24
28
|
|
|
25
|
-
raise ValueError(f'Unsupported dialect: {dialect_name}')
|
|
29
|
+
raise ValueError(f'Unsupported dialect: {dialect_name}')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_process_pool = ProcessPoolExecutor(
|
|
33
|
+
max_workers=max(1, os.cpu_count() - 1),
|
|
34
|
+
mp_context=mp.get_context('spawn'),
|
|
35
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlalchemy-events-lib
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Event-driven extension for SQLAlchemy that enables listening to database CUD events. This library allows you to react to database changes in real time using a clean, declarative API.
|
|
5
5
|
Author-email: Alexey Kostarev <normjkeeewm@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,16 +61,35 @@ class UserModel(Base):
|
|
|
61
61
|
___
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
### Define event handlers using
|
|
64
|
+
### Define event handlers using decorators `@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler`
|
|
65
65
|
services/handlers.py
|
|
66
66
|
```python
|
|
67
67
|
from models import UserModel
|
|
68
|
-
from sqlalchemy_events import
|
|
68
|
+
from sqlalchemy_events import sa_insert_handler
|
|
69
69
|
|
|
70
|
-
@
|
|
70
|
+
@sa_insert_handler(UserModel)
|
|
71
71
|
async def handle_user_insert():
|
|
72
|
-
print(
|
|
72
|
+
print('User inserted!')
|
|
73
73
|
```
|
|
74
|
+
|
|
75
|
+
**Optional Argument**: `rows`
|
|
76
|
+
Handlers can optionally accept a rows argument.
|
|
77
|
+
|
|
78
|
+
If the parameter is declared in the function signature, it will automatically receive a list of affected `row` IDs.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from models import UserModel
|
|
82
|
+
from sqlalchemy_events import sa_insert_handler
|
|
83
|
+
|
|
84
|
+
@sa_insert_handler(UserModel)
|
|
85
|
+
async def handle_user_insert(rows: list[DB_ID]):
|
|
86
|
+
print('Users inserted!', rows)
|
|
87
|
+
```
|
|
88
|
+
**Output:**
|
|
89
|
+
```
|
|
90
|
+
Users inserted! ['3310aa38-f555-4c05-a57f-5acae32a0a7b', '6aeba1b0-8e80-4369-8adf-cf967ab7ba7a']
|
|
91
|
+
```
|
|
92
|
+
|
|
74
93
|
___
|
|
75
94
|
|
|
76
95
|
### Configure SQLAlchemy async engine
|
|
@@ -96,17 +115,13 @@ from session import engine
|
|
|
96
115
|
|
|
97
116
|
|
|
98
117
|
async def main():
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
autodiscover_paths=['services']
|
|
104
|
-
).init()
|
|
118
|
+
SQLAlchemyEvents(
|
|
119
|
+
base=Base,
|
|
120
|
+
engine=engine,
|
|
121
|
+
autodiscover_paths=['services']
|
|
105
122
|
)
|
|
106
|
-
|
|
123
|
+
while True:
|
|
107
124
|
await asyncio.sleep(9999)
|
|
108
|
-
except:
|
|
109
|
-
pass
|
|
110
125
|
|
|
111
126
|
if __name__ == '__main__':
|
|
112
127
|
asyncio.run(main())
|
|
@@ -129,7 +144,10 @@ Parameters:
|
|
|
129
144
|
* **base** - SQLAlchemy declarative base class used to discover mapped models.
|
|
130
145
|
* **engine** - SQLAlchemy Engine or AsyncEngine instance.
|
|
131
146
|
* **autodiscover_paths** - List of Python module paths where event handlers are defined.
|
|
132
|
-
These modules are automatically imported so that decorators such as
|
|
147
|
+
These modules are automatically imported so that decorators such as
|
|
148
|
+
`@sa_insert_handler`, `@sa_update_handler`, `@sa_delete_handler` are executed.
|
|
149
|
+
**Example:**
|
|
150
|
+
`autodiscover_paths=["services", "app.handlers"]`
|
|
133
151
|
|
|
134
152
|
### Important:
|
|
135
153
|
|
|
@@ -149,13 +167,13 @@ If provided, the library will log internal lifecycle events such as:
|
|
|
149
167
|
```python
|
|
150
168
|
import logging
|
|
151
169
|
|
|
152
|
-
logger = logging.getLogger(
|
|
170
|
+
logger = logging.getLogger('sqlalchemy_events')
|
|
153
171
|
logger.setLevel(logging.INFO)
|
|
154
172
|
|
|
155
173
|
SQLAlchemyEvents(
|
|
156
174
|
base=Base,
|
|
157
175
|
engine=engine,
|
|
158
|
-
autodiscover_paths=[
|
|
176
|
+
autodiscover_paths=['services'],
|
|
159
177
|
logger=logger
|
|
160
178
|
)
|
|
161
179
|
```
|
|
@@ -173,7 +191,7 @@ SQLAlchemyEvents(
|
|
|
173
191
|
* Ensure handlers are imported via autodiscover_paths, otherwise they will not be registered
|
|
174
192
|
|
|
175
193
|
## Example flow
|
|
176
|
-
INSERT INTO {table} → DATABASE trigger fires →
|
|
194
|
+
INSERT INTO/UPDATE/DELETE FROM {table} → DATABASE trigger fires →
|
|
177
195
|
NOTIFY → Python listener receives event →
|
|
178
196
|
handler is executed
|
|
179
197
|
|
|
@@ -5,11 +5,11 @@ src/sqlalchemy_events/__init__.py
|
|
|
5
5
|
src/sqlalchemy_events/core.py
|
|
6
6
|
src/sqlalchemy_events/decorators.py
|
|
7
7
|
src/sqlalchemy_events/discovery.py
|
|
8
|
-
src/sqlalchemy_events/enums.py
|
|
9
8
|
src/sqlalchemy_events/events.py
|
|
10
9
|
src/sqlalchemy_events/exceptions.py
|
|
11
|
-
src/sqlalchemy_events/
|
|
10
|
+
src/sqlalchemy_events/handlers.py
|
|
12
11
|
src/sqlalchemy_events/registry.py
|
|
12
|
+
src/sqlalchemy_events/types.py
|
|
13
13
|
src/sqlalchemy_events/utils.py
|
|
14
14
|
src/sqlalchemy_events/callbacks_strategies/__init__.py
|
|
15
15
|
src/sqlalchemy_events/callbacks_strategies/base.py
|
|
@@ -32,12 +32,12 @@ async def prepared_db(engine):
|
|
|
32
32
|
await conn.execute(text('CREATE SCHEMA public'))
|
|
33
33
|
await conn.run_sync(Base.metadata.create_all)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
SQLAlchemyEvents(
|
|
36
36
|
base=Base,
|
|
37
37
|
engine=engine,
|
|
38
38
|
autodiscover_paths=['tests']
|
|
39
39
|
)
|
|
40
|
-
await
|
|
40
|
+
await asyncio.sleep(0.1)
|
|
41
41
|
IS_DB = True
|
|
42
42
|
|
|
43
43
|
|
|
@@ -2,7 +2,7 @@ import sys
|
|
|
2
2
|
import types
|
|
3
3
|
|
|
4
4
|
from sqlalchemy_events.discovery import autodiscover
|
|
5
|
-
from sqlalchemy_events import
|
|
5
|
+
from sqlalchemy_events import sa_insert_handler, sa_update_handler, sa_delete_handler
|
|
6
6
|
from sqlalchemy_events.registry import get_event_handlers
|
|
7
7
|
from tests.test_models import UserModel
|
|
8
8
|
|
|
@@ -98,15 +98,15 @@ def test_module_without_submodules(monkeypatch):
|
|
|
98
98
|
cleanup_modules(module_name)
|
|
99
99
|
|
|
100
100
|
|
|
101
|
-
@
|
|
101
|
+
@sa_insert_handler(UserModel)
|
|
102
102
|
async def insert_handler(): ...
|
|
103
103
|
|
|
104
104
|
|
|
105
|
-
@
|
|
105
|
+
@sa_update_handler(UserModel)
|
|
106
106
|
async def update_handler(): ...
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
@
|
|
109
|
+
@sa_delete_handler(UserModel)
|
|
110
110
|
async def delete_handler(): ...
|
|
111
111
|
|
|
112
112
|
|
|
@@ -117,13 +117,13 @@ async def test_discover_handlers():
|
|
|
117
117
|
for key, handler_list in handlers.items():
|
|
118
118
|
assert len(handler_list) == 1
|
|
119
119
|
if 'insert' in key:
|
|
120
|
-
assert handler_list[0].__name__ == 'insert_handler'
|
|
120
|
+
assert handler_list[0].func.__name__ == 'insert_handler'
|
|
121
121
|
result_handlers.extend(handler_list)
|
|
122
122
|
if 'update' in key:
|
|
123
|
-
assert handler_list[0].__name__ == 'update_handler'
|
|
123
|
+
assert handler_list[0].func.__name__ == 'update_handler'
|
|
124
124
|
result_handlers.extend(handler_list)
|
|
125
125
|
if 'delete' in key:
|
|
126
|
-
assert handler_list[0].__name__ == 'delete_handler'
|
|
126
|
+
assert handler_list[0].func.__name__ == 'delete_handler'
|
|
127
127
|
result_handlers.extend(handler_list)
|
|
128
128
|
|
|
129
129
|
assert len(result_handlers) == len(handlers)
|
{sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/test_is_created_triggers.py
RENAMED
|
@@ -26,13 +26,17 @@ async def test_is_created_triggers(session):
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
async def test_is_created_function(session):
|
|
29
|
+
func_names = [
|
|
30
|
+
'sqlalchemy_events_notify_insert',
|
|
31
|
+
'sqlalchemy_events_notify_update',
|
|
32
|
+
'sqlalchemy_events_notify_delete'
|
|
33
|
+
]
|
|
29
34
|
result = await session.execute(
|
|
30
35
|
text("""
|
|
31
|
-
SELECT
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
""")
|
|
36
|
+
SELECT COUNT(*) FROM pg_proc WHERE proname = ANY (:func_names)
|
|
37
|
+
"""),
|
|
38
|
+
{'func_names': func_names}
|
|
35
39
|
)
|
|
36
|
-
|
|
40
|
+
count = result.scalar()
|
|
37
41
|
|
|
38
|
-
assert
|
|
42
|
+
assert count == len(func_names)
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json
|
|
3
|
-
|
|
4
|
-
from .base import SaEventsCallbacksStrategy
|
|
5
|
-
from ..registry import get_event_handlers
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class PostgresCallback(SaEventsCallbacksStrategy):
|
|
9
|
-
|
|
10
|
-
def __init__(self) -> None:
|
|
11
|
-
self.__handlers = get_event_handlers()
|
|
12
|
-
|
|
13
|
-
async def handle(self, *args, **kwargs):
|
|
14
|
-
data = json.loads(args[3] or '{}')
|
|
15
|
-
trig_name = data.get('trigger')
|
|
16
|
-
|
|
17
|
-
if not trig_name:
|
|
18
|
-
return
|
|
19
|
-
|
|
20
|
-
handlers = self.__handlers.get(trig_name, [])
|
|
21
|
-
if not handlers:
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
for handler in handlers:
|
|
25
|
-
if asyncio.iscoroutinefunction(handler):
|
|
26
|
-
await handler()
|
|
27
|
-
else:
|
|
28
|
-
handler()
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
from enum import StrEnum
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class SaEvent(StrEnum):
|
|
5
|
-
INSERT = 'INSERT'
|
|
6
|
-
UPDATE = 'UPDATE'
|
|
7
|
-
DELETE = 'DELETE'
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class Dialect(StrEnum):
|
|
11
|
-
POSTGRESQL = 'postgresql'
|
|
12
|
-
SQLITE = 'sqlite'
|
|
13
|
-
MYSQL = 'mysql'
|
|
14
|
-
MSSQL = 'mssql'
|
|
15
|
-
ORACLE = 'oracle'
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import inspect
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
from typing import Callable, Type
|
|
4
|
-
from sqlalchemy.orm import DeclarativeBase
|
|
5
|
-
|
|
6
|
-
from .enums import SaEvent
|
|
7
|
-
from .registry import get_event_handlers
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class SaEventHandler:
|
|
11
|
-
|
|
12
|
-
def __init__(self):
|
|
13
|
-
self.__handlers = get_event_handlers()
|
|
14
|
-
|
|
15
|
-
def __sa_event_handler(self, model: Type[DeclarativeBase], event: SaEvent):
|
|
16
|
-
if event not in getattr(model, '__events__', set()):
|
|
17
|
-
raise ValueError(
|
|
18
|
-
f'[SQLAlchemyEvents] Event {event} is not registered for model {model.__name__}'
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
def decorator(func: Callable):
|
|
22
|
-
trig_name = f'sa_{model.__tablename__}_{event.lower()}_notify'
|
|
23
|
-
func_path = inspect.getsourcefile(func) or inspect.getfile(func)
|
|
24
|
-
func_path = Path(func_path)
|
|
25
|
-
func_path_name = f'{func_path.parent.name}/{func_path.name}/{func.__name__}'
|
|
26
|
-
funcs = self.__handlers.get(trig_name)
|
|
27
|
-
if funcs:
|
|
28
|
-
for handler in funcs:
|
|
29
|
-
handler_path = inspect.getsourcefile(handler) or inspect.getfile(handler)
|
|
30
|
-
handler_path = Path(handler_path)
|
|
31
|
-
handler_path_name = f'{handler_path.parent.name}/{handler_path.name}/{handler.__name__}'
|
|
32
|
-
if func_path_name == handler_path_name:
|
|
33
|
-
continue
|
|
34
|
-
funcs[trig_name].append(handler)
|
|
35
|
-
else:
|
|
36
|
-
self.__handlers[trig_name] = [func]
|
|
37
|
-
|
|
38
|
-
return func
|
|
39
|
-
|
|
40
|
-
return decorator
|
|
41
|
-
|
|
42
|
-
def on_insert(self, model: Type[DeclarativeBase]):
|
|
43
|
-
return self.__sa_event_handler(model, SaEvent.INSERT)
|
|
44
|
-
|
|
45
|
-
def on_update(self, model: Type[DeclarativeBase]):
|
|
46
|
-
return self.__sa_event_handler(model, SaEvent.UPDATE)
|
|
47
|
-
|
|
48
|
-
def on_delete(self, model: Type[DeclarativeBase]):
|
|
49
|
-
return self.__sa_event_handler(model, SaEvent.DELETE)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
sa_event_handler = SaEventHandler()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/discovery.py
RENAMED
|
File without changes
|
{sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/exceptions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|