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.
Files changed (34) hide show
  1. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/PKG-INFO +36 -18
  2. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/README.md +35 -17
  3. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/pyproject.toml +1 -1
  4. sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/__init__.py +14 -0
  5. sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +69 -0
  6. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/core.py +5 -4
  7. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/decorators.py +2 -2
  8. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/events.py +1 -1
  9. sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/handlers.py +50 -0
  10. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/init_triggers_strategies/postgres_init_triggers.py +30 -16
  11. sqlalchemy_events_lib-0.2.0/src/sqlalchemy_events/types.py +26 -0
  12. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/utils.py +12 -2
  13. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/PKG-INFO +36 -18
  14. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/SOURCES.txt +2 -2
  15. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/conftest.py +2 -2
  16. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/test_autodiscover.py +7 -7
  17. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/tests/test_is_created_triggers.py +10 -6
  18. sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/__init__.py +0 -8
  19. sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/callbacks_strategies/postgres_callback.py +0 -28
  20. sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/enums.py +0 -15
  21. sqlalchemy_events_lib-0.1.1/src/sqlalchemy_events/handler.py +0 -52
  22. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/LICENSE +0 -0
  23. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/setup.cfg +0 -0
  24. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/callbacks_strategies/__init__.py +0 -0
  25. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/callbacks_strategies/base.py +0 -0
  26. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/discovery.py +0 -0
  27. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/exceptions.py +0 -0
  28. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/init_triggers_strategies/__init__.py +0 -0
  29. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/init_triggers_strategies/base.py +0 -0
  30. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events/registry.py +0 -0
  31. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/dependency_links.txt +0 -0
  32. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/requires.txt +0 -0
  33. {sqlalchemy_events_lib-0.1.1 → sqlalchemy_events_lib-0.2.0}/src/sqlalchemy_events_lib.egg-info/top_level.txt +0 -0
  34. {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.1.1
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 decorator `@sa_event_handler`
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 sa_event_handler
68
+ from sqlalchemy_events import sa_insert_handler
69
69
 
70
- @sa_event_handler.on_insert(UserModel)
70
+ @sa_insert_handler(UserModel)
71
71
  async def handle_user_insert():
72
- print("User inserted!")
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
- asyncio.create_task(
100
- SQLAlchemyEvents(
101
- base=Base,
102
- engine=engine,
103
- autodiscover_paths=['services']
104
- ).init()
118
+ SQLAlchemyEvents(
119
+ base=Base,
120
+ engine=engine,
121
+ autodiscover_paths=['services']
105
122
  )
106
- try:
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 `@sa_event_handler` are executed. Example:`autodiscover_paths=["services", "app.handlers"]`
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("sqlalchemy_events")
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=["services"],
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 decorator `@sa_event_handler`
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 sa_event_handler
46
+ from sqlalchemy_events import sa_insert_handler
47
47
 
48
- @sa_event_handler.on_insert(UserModel)
48
+ @sa_insert_handler(UserModel)
49
49
  async def handle_user_insert():
50
- print("User inserted!")
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
- asyncio.create_task(
78
- SQLAlchemyEvents(
79
- base=Base,
80
- engine=engine,
81
- autodiscover_paths=['services']
82
- ).init()
96
+ SQLAlchemyEvents(
97
+ base=Base,
98
+ engine=engine,
99
+ autodiscover_paths=['services']
83
100
  )
84
- try:
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 `@sa_event_handler` are executed. Example:`autodiscover_paths=["services", "app.handlers"]`
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("sqlalchemy_events")
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=["services"],
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.1.1"
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 init(self) -> None:
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
@@ -1,4 +1,4 @@
1
- from .enums import SaEvent
1
+ from .types import SaEvent
2
2
 
3
3
 
4
4
  def with_events(events: list[SaEvent]):
@@ -11,4 +11,4 @@ def with_events(events: list[SaEvent]):
11
11
 
12
12
  cls.events = Events
13
13
  return cls
14
- return wrapper
14
+ return wrapper
@@ -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 .enums import Dialect, SaEvent
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 ..enums import SaEvent
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
- await conn.execute(
14
- text("""
15
- CREATE OR REPLACE FUNCTION sqlalchemy_events()
16
- RETURNS trigger AS $$
17
- BEGIN PERFORM pg_notify(
18
- 'sqlalchemy_events', json_build_object(
19
- 'table', TG_TABLE_NAME, 'event', TG_OP, 'trigger', TG_NAME
20
- )::text);
21
- RETURN NEW; END; $$ LANGUAGE plpgsql;
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"DROP TRIGGER IF EXISTS {trig_name} ON {table_name};")
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
- CREATE TRIGGER {trig_name}
78
- AFTER {event} ON {table_name}
79
- FOR EACH ROW EXECUTE FUNCTION sqlalchemy_events();
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 .enums import Dialect
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.1.1
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 decorator `@sa_event_handler`
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 sa_event_handler
68
+ from sqlalchemy_events import sa_insert_handler
69
69
 
70
- @sa_event_handler.on_insert(UserModel)
70
+ @sa_insert_handler(UserModel)
71
71
  async def handle_user_insert():
72
- print("User inserted!")
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
- asyncio.create_task(
100
- SQLAlchemyEvents(
101
- base=Base,
102
- engine=engine,
103
- autodiscover_paths=['services']
104
- ).init()
118
+ SQLAlchemyEvents(
119
+ base=Base,
120
+ engine=engine,
121
+ autodiscover_paths=['services']
105
122
  )
106
- try:
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 `@sa_event_handler` are executed. Example:`autodiscover_paths=["services", "app.handlers"]`
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("sqlalchemy_events")
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=["services"],
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/handler.py
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
- listener = SQLAlchemyEvents(
35
+ SQLAlchemyEvents(
36
36
  base=Base,
37
37
  engine=engine,
38
38
  autodiscover_paths=['tests']
39
39
  )
40
- await listener.init()
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 sa_event_handler
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
- @sa_event_handler.on_insert(UserModel)
101
+ @sa_insert_handler(UserModel)
102
102
  async def insert_handler(): ...
103
103
 
104
104
 
105
- @sa_event_handler.on_update(UserModel)
105
+ @sa_update_handler(UserModel)
106
106
  async def update_handler(): ...
107
107
 
108
108
 
109
- @sa_event_handler.on_delete(UserModel)
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)
@@ -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 EXISTS (SELECT 1
32
- FROM pg_proc
33
- WHERE proname = 'sqlalchemy_events')
34
- """)
36
+ SELECT COUNT(*) FROM pg_proc WHERE proname = ANY (:func_names)
37
+ """),
38
+ {'func_names': func_names}
35
39
  )
36
- exists = result.scalar()
40
+ count = result.scalar()
37
41
 
38
- assert exists is True
42
+ assert count == len(func_names)
@@ -1,8 +0,0 @@
1
- from .core import SQLAlchemyEvents
2
- from .decorators import with_events
3
- from .enums import SaEvent
4
- from .handler import sa_event_handler
5
-
6
- __all__ = ['SQLAlchemyEvents', 'sa_event_handler', 'with_events', 'SaEvent']
7
- __version__ = '0.1.0'
8
-
@@ -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()