sqlnotify 0.1.0__py3-none-any.whl
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.
- sqlnotify/__init__.py +14 -0
- sqlnotify/adapters/__init__.py +0 -0
- sqlnotify/adapters/asgi.py +40 -0
- sqlnotify/constants.py +9 -0
- sqlnotify/dialects/__init__.py +12 -0
- sqlnotify/dialects/base.py +183 -0
- sqlnotify/dialects/postgresql.py +778 -0
- sqlnotify/dialects/sqlite.py +797 -0
- sqlnotify/dialects/utils.py +74 -0
- sqlnotify/exceptions.py +38 -0
- sqlnotify/logger.py +32 -0
- sqlnotify/notifiers/__init__.py +3 -0
- sqlnotify/notifiers/base.py +240 -0
- sqlnotify/notifiers/notifier.py +639 -0
- sqlnotify/types.py +57 -0
- sqlnotify/utils.py +165 -0
- sqlnotify/watcher.py +72 -0
- sqlnotify-0.1.0.dist-info/METADATA +610 -0
- sqlnotify-0.1.0.dist-info/RECORD +23 -0
- sqlnotify-0.1.0.dist-info/WHEEL +5 -0
- sqlnotify-0.1.0.dist-info/entry_points.txt +2 -0
- sqlnotify-0.1.0.dist-info/licenses/LICENSE +21 -0
- sqlnotify-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from sqlalchemy.engine import Engine
|
|
4
|
+
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
5
|
+
|
|
6
|
+
from ..exceptions import SQLNotifyUnSupportedDatabaseProviderError
|
|
7
|
+
from .base import BaseDialect
|
|
8
|
+
from .postgresql import PostgreSQLDialect
|
|
9
|
+
from .sqlite import SQLiteDialect
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def detect_dialect_name(engine: AsyncEngine | Engine) -> str:
|
|
13
|
+
"""
|
|
14
|
+
Detect the database dialect from an SQLAlchemy engine
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
engine (Union[AsyncEngine, Engine]): SQLAlchemy Engine or AsyncEngine
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
str: The dialect name (e.g., 'postgresql', 'mysql', 'sqlite')
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
if isinstance(engine, AsyncEngine):
|
|
24
|
+
dialect_name = engine.dialect.name
|
|
25
|
+
else:
|
|
26
|
+
dialect_name = engine.dialect.name
|
|
27
|
+
|
|
28
|
+
return dialect_name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_dialect_for_engine(
|
|
32
|
+
engine: AsyncEngine | Engine,
|
|
33
|
+
async_engine: AsyncEngine | None = None,
|
|
34
|
+
sync_engine: Engine | None = None,
|
|
35
|
+
logger: logging.Logger | None = None,
|
|
36
|
+
revoke_on_model_change: bool = True,
|
|
37
|
+
) -> BaseDialect:
|
|
38
|
+
"""
|
|
39
|
+
Get the appropriate dialect implementation for the given engine
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
engine (Union[AsyncEngine, Engine]): SQLAlchemy Engine or AsyncEngine
|
|
43
|
+
async_engine (Optional[AsyncEngine]): Async engine if available
|
|
44
|
+
sync_engine (Optional[Engine]): Sync engine if available
|
|
45
|
+
logger (Optional[logging.Logger]): Logger instance
|
|
46
|
+
revoke_on_model_change (bool): Whether to revoke triggers on model change
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
BaseDialect: The appropriate dialect implementation
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
SQLNotifyUnSupportedDatabaseProviderError: If the dialect is not supported
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
dialect_name = detect_dialect_name(engine)
|
|
56
|
+
|
|
57
|
+
if dialect_name == "postgresql":
|
|
58
|
+
return PostgreSQLDialect(
|
|
59
|
+
async_engine=async_engine,
|
|
60
|
+
sync_engine=sync_engine,
|
|
61
|
+
logger=logger,
|
|
62
|
+
revoke_on_model_change=revoke_on_model_change,
|
|
63
|
+
)
|
|
64
|
+
elif dialect_name == "sqlite":
|
|
65
|
+
return SQLiteDialect(
|
|
66
|
+
async_engine=async_engine,
|
|
67
|
+
sync_engine=sync_engine,
|
|
68
|
+
logger=logger,
|
|
69
|
+
revoke_on_model_change=revoke_on_model_change,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
raise SQLNotifyUnSupportedDatabaseProviderError(
|
|
73
|
+
f"Database dialect '{dialect_name}' is not supported. " f"Currently supported dialects: PostgreSQL, SQLite."
|
|
74
|
+
)
|
sqlnotify/exceptions.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
class SQLNotifyException(Exception):
|
|
2
|
+
"""
|
|
3
|
+
Base exception for SQLNotify
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SQLNotifyConfigurationError(SQLNotifyException):
|
|
10
|
+
"""
|
|
11
|
+
Raised when there is a configuration error in SQLNotify
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SQLNotifyPayloadSizeError(SQLNotifyException):
|
|
18
|
+
"""
|
|
19
|
+
Raised when the maximum payload size for SQLNotify is exceeded
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SQLNotifyIdentifierSizeError(SQLNotifyException):
|
|
26
|
+
"""
|
|
27
|
+
Raised when the maximum size of an identifier for SQLNotify is exceeded
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SQLNotifyUnSupportedDatabaseProviderError(SQLNotifyException):
|
|
34
|
+
"""
|
|
35
|
+
Raised when an unsupported database provider is specified for SQLNotify
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
pass
|
sqlnotify/logger.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from .constants import PACKAGE_NAME
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_logger(name: str = PACKAGE_NAME, enabled: bool = True) -> logging.Logger:
|
|
7
|
+
"""
|
|
8
|
+
Get a logger for the SQLNotify package.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
name (str): The name of the logger. Defaults to the package name.
|
|
12
|
+
enable (bool): If False, adds a NullHandler to disable logging. Defaults to True.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
logging.Logger: Configured logger instance
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(name)
|
|
19
|
+
|
|
20
|
+
logger.handlers.clear()
|
|
21
|
+
|
|
22
|
+
if enabled:
|
|
23
|
+
handler = logging.StreamHandler()
|
|
24
|
+
formatter = logging.Formatter(fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
25
|
+
handler.setFormatter(formatter)
|
|
26
|
+
logger.addHandler(handler)
|
|
27
|
+
logger.setLevel(logging.INFO)
|
|
28
|
+
else:
|
|
29
|
+
logger.addHandler(logging.NullHandler())
|
|
30
|
+
logger.setLevel(logging.CRITICAL + 1)
|
|
31
|
+
|
|
32
|
+
return logger
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import inspect as sa_inspect
|
|
8
|
+
from sqlalchemy.orm import Mapper
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from ..constants import MAX_SQLNOTIFY_EXTRA_COLUMNS, MAX_SQLNOTIFY_PAYLOAD_BYTES
|
|
12
|
+
from ..exceptions import SQLNotifyConfigurationError
|
|
13
|
+
from ..types import FilterOnParams, Operation
|
|
14
|
+
from ..watcher import Watcher
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseNotifier:
|
|
18
|
+
"""
|
|
19
|
+
Base class for SQLNotify notifier
|
|
20
|
+
|
|
21
|
+
It basically acts as a constructor class for registration and validation of watchers and subscribers of the notifier, but does not implement the actual listening logic.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
SQLNotifyConfigurationError: If there are configuration issues with watchers or subscribers
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.watchers: list[Watcher] = []
|
|
29
|
+
self.subscribers: dict[str, list[Callable]] = {}
|
|
30
|
+
self._listening_task: asyncio.Task | None = None
|
|
31
|
+
self._running = False
|
|
32
|
+
self._logger = None
|
|
33
|
+
self._listener_ready: asyncio.Event | None = None
|
|
34
|
+
|
|
35
|
+
def watch(
|
|
36
|
+
self,
|
|
37
|
+
model: type,
|
|
38
|
+
operation: Operation,
|
|
39
|
+
extra_columns: list[str] | None = None,
|
|
40
|
+
trigger_columns: list[str] | None = None,
|
|
41
|
+
primary_keys: list[str] = ["id"], # noqa: B006
|
|
42
|
+
channel_label: str | None = None,
|
|
43
|
+
use_overflow_table: bool = False,
|
|
44
|
+
) -> Self:
|
|
45
|
+
"""
|
|
46
|
+
Register a watcher for a model and operation
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
model (Type): SQLModel or SQLAlchemy model class
|
|
50
|
+
operation (Operation | str): Operation to watch (insert, update, delete)
|
|
51
|
+
extra_columns (Optional[List[str]]): Additional columns to include in notifications. If None (the default),
|
|
52
|
+
only the primary key(s) will be returned in the notification event.
|
|
53
|
+
trigger_columns (Optional[List[str]]): For UPDATE, only trigger on these columns
|
|
54
|
+
primary_keys (List[str]): List of primary key column names. Must be actual primary keys on the model. Defaults to ["id"]
|
|
55
|
+
channel_label (Optional[str]): Custom label for the watcher
|
|
56
|
+
use_overflow_table (bool): If True, large payloads are stored in overflow table
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Notifier: The Notifier instance
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
SQLNotifyConfigurationError: If there are configuration issues with the watcher (e.g. invalid column names, primary keys not actually being primary keys, etc.)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
model_columns = self._get_model_columns(model)
|
|
66
|
+
model_primary_keys = self._get_model_primary_keys(model)
|
|
67
|
+
|
|
68
|
+
if not primary_keys:
|
|
69
|
+
raise SQLNotifyConfigurationError(f"primary_keys cannot be empty for model '{model.__name__}'")
|
|
70
|
+
|
|
71
|
+
invalid_primary_keys = [pk for pk in primary_keys if pk not in model_columns]
|
|
72
|
+
if invalid_primary_keys:
|
|
73
|
+
raise SQLNotifyConfigurationError(
|
|
74
|
+
f"Primary key column(s) {invalid_primary_keys} not found on model '{model.__name__}'. "
|
|
75
|
+
f"Available columns: {sorted(model_columns)}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
not_actual_pks = [pk for pk in primary_keys if pk not in model_primary_keys]
|
|
79
|
+
if not_actual_pks:
|
|
80
|
+
raise SQLNotifyConfigurationError(
|
|
81
|
+
f"Column(s) {not_actual_pks} exist on model '{model.__name__}' but are not primary keys. "
|
|
82
|
+
f"Actual primary keys: {sorted(model_primary_keys) if model_primary_keys else 'None defined'}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if extra_columns:
|
|
86
|
+
invalid_extra_cols = [col for col in extra_columns if col not in model_columns]
|
|
87
|
+
if invalid_extra_cols:
|
|
88
|
+
raise SQLNotifyConfigurationError(
|
|
89
|
+
f"Extra column(s) {invalid_extra_cols} not found on model '{model.__name__}'. "
|
|
90
|
+
f"Available columns: {sorted(model_columns)}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if trigger_columns:
|
|
94
|
+
invalid_trigger_cols = [col for col in trigger_columns if col not in model_columns]
|
|
95
|
+
if invalid_trigger_cols:
|
|
96
|
+
raise SQLNotifyConfigurationError(
|
|
97
|
+
f"Trigger column(s) {invalid_trigger_cols} not found on model '{model.__name__}'. "
|
|
98
|
+
f"Available columns: {sorted(model_columns)}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if extra_columns and len(extra_columns) > MAX_SQLNOTIFY_EXTRA_COLUMNS:
|
|
102
|
+
if self._logger:
|
|
103
|
+
self._logger.warning(
|
|
104
|
+
f"SQLNotify Watcher for {model.__name__}.{operation} has {len(extra_columns)} extra_columns. "
|
|
105
|
+
f"This may exceed SQLNotify {MAX_SQLNOTIFY_PAYLOAD_BYTES} byte limit. "
|
|
106
|
+
f"Consider setting use_overflow_table=True or reducing extra_columns."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
watcher = Watcher(
|
|
110
|
+
model=model,
|
|
111
|
+
operation=operation,
|
|
112
|
+
extra_columns=extra_columns,
|
|
113
|
+
trigger_columns=trigger_columns,
|
|
114
|
+
primary_keys=primary_keys,
|
|
115
|
+
channel_label=channel_label,
|
|
116
|
+
use_overflow_table=use_overflow_table,
|
|
117
|
+
logger=self._logger,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
self.watchers.append(watcher)
|
|
121
|
+
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
def subscribe(
|
|
125
|
+
self,
|
|
126
|
+
model: type | str,
|
|
127
|
+
operation: Operation,
|
|
128
|
+
filters: list[FilterOnParams] | None = None,
|
|
129
|
+
):
|
|
130
|
+
"""
|
|
131
|
+
Decorator to subscribe to change events
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
model (Union[Type, str]): Model class or class name as string to watch
|
|
135
|
+
operation (Operation | str): Operation to watch
|
|
136
|
+
filters (Optional[List[FilterOnParams]]): Optional list of column filters to watch specific records
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Callable: Decorator for subscriber function
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
SQLNotifyConfigurationError: If model is not registered as a watcher for the specified operation,
|
|
143
|
+
or if filter column names don't exist on the model
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
147
|
+
if inspect.iscoroutinefunction(func):
|
|
148
|
+
|
|
149
|
+
@wraps(func)
|
|
150
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
151
|
+
return await func(*args, **kwargs)
|
|
152
|
+
|
|
153
|
+
registered_func = async_wrapper
|
|
154
|
+
else:
|
|
155
|
+
|
|
156
|
+
@wraps(func)
|
|
157
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
158
|
+
return func(*args, **kwargs)
|
|
159
|
+
|
|
160
|
+
registered_func = sync_wrapper
|
|
161
|
+
|
|
162
|
+
matching_watcher = None
|
|
163
|
+
|
|
164
|
+
for watcher in self.watchers:
|
|
165
|
+
watcher_matches = False
|
|
166
|
+
|
|
167
|
+
if isinstance(model, str):
|
|
168
|
+
watcher_matches = watcher.model.__name__ == model and watcher.operation == operation
|
|
169
|
+
else:
|
|
170
|
+
watcher_matches = watcher.model == model and watcher.operation == operation
|
|
171
|
+
|
|
172
|
+
if watcher_matches:
|
|
173
|
+
matching_watcher = watcher
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
if not matching_watcher:
|
|
177
|
+
raise SQLNotifyConfigurationError(
|
|
178
|
+
f"Model {model if isinstance(model, str) else model.__name__} is not registered as a watcher for operation {operation}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if filters:
|
|
182
|
+
model_columns = self._get_model_columns(matching_watcher.model)
|
|
183
|
+
for filter_param in filters:
|
|
184
|
+
column_name = filter_param["column"]
|
|
185
|
+
|
|
186
|
+
if column_name not in model_columns:
|
|
187
|
+
raise SQLNotifyConfigurationError(
|
|
188
|
+
f"Column '{column_name}' does not exist on model '{matching_watcher.model.__name__}'. "
|
|
189
|
+
f"Available columns: {', '.join(model_columns)}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
channel = matching_watcher.channel_name
|
|
193
|
+
|
|
194
|
+
if filters:
|
|
195
|
+
sorted_filters = sorted(filters, key=lambda f: f["column"])
|
|
196
|
+
filter_parts = [f"{f['column']}:{f['value']}" for f in sorted_filters]
|
|
197
|
+
channel = f"{channel}:{'|'.join(filter_parts)}"
|
|
198
|
+
|
|
199
|
+
if channel not in self.subscribers:
|
|
200
|
+
self.subscribers[channel] = []
|
|
201
|
+
|
|
202
|
+
self.subscribers[channel].append(registered_func)
|
|
203
|
+
|
|
204
|
+
return registered_func
|
|
205
|
+
|
|
206
|
+
return decorator
|
|
207
|
+
|
|
208
|
+
def _get_model_columns(self, model: type) -> set[str]:
|
|
209
|
+
"""
|
|
210
|
+
Get all column names from a model.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
model (Type): SQLModel or SQLAlchemy model class
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
set[str]: Set of column names
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
mapper: Mapper[Any] = sa_inspect(model)
|
|
221
|
+
return {c.key for c in mapper.column_attrs}
|
|
222
|
+
except Exception:
|
|
223
|
+
return set()
|
|
224
|
+
|
|
225
|
+
def _get_model_primary_keys(self, model: type) -> set[str]:
|
|
226
|
+
"""
|
|
227
|
+
Get actual primary key column names from a model.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
model (Type): SQLModel or SQLAlchemy model class
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
set[str]: Set of primary key column names
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
mapper: Mapper[Any] = sa_inspect(model)
|
|
238
|
+
return {column.name for column in mapper.primary_key}
|
|
239
|
+
except Exception:
|
|
240
|
+
return set()
|