sqlnotify 0.1.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.
- sqlnotify-0.1.0/LICENSE +21 -0
- sqlnotify-0.1.0/PKG-INFO +610 -0
- sqlnotify-0.1.0/README.md +574 -0
- sqlnotify-0.1.0/pyproject.toml +122 -0
- sqlnotify-0.1.0/setup.cfg +4 -0
- sqlnotify-0.1.0/src/sqlnotify/__init__.py +14 -0
- sqlnotify-0.1.0/src/sqlnotify/adapters/__init__.py +0 -0
- sqlnotify-0.1.0/src/sqlnotify/adapters/asgi.py +40 -0
- sqlnotify-0.1.0/src/sqlnotify/constants.py +9 -0
- sqlnotify-0.1.0/src/sqlnotify/dialects/__init__.py +12 -0
- sqlnotify-0.1.0/src/sqlnotify/dialects/base.py +183 -0
- sqlnotify-0.1.0/src/sqlnotify/dialects/postgresql.py +778 -0
- sqlnotify-0.1.0/src/sqlnotify/dialects/sqlite.py +797 -0
- sqlnotify-0.1.0/src/sqlnotify/dialects/utils.py +74 -0
- sqlnotify-0.1.0/src/sqlnotify/exceptions.py +38 -0
- sqlnotify-0.1.0/src/sqlnotify/logger.py +32 -0
- sqlnotify-0.1.0/src/sqlnotify/notifiers/__init__.py +3 -0
- sqlnotify-0.1.0/src/sqlnotify/notifiers/base.py +240 -0
- sqlnotify-0.1.0/src/sqlnotify/notifiers/notifier.py +639 -0
- sqlnotify-0.1.0/src/sqlnotify/types.py +57 -0
- sqlnotify-0.1.0/src/sqlnotify/utils.py +165 -0
- sqlnotify-0.1.0/src/sqlnotify/watcher.py +72 -0
- sqlnotify-0.1.0/src/sqlnotify.egg-info/PKG-INFO +610 -0
- sqlnotify-0.1.0/src/sqlnotify.egg-info/SOURCES.txt +33 -0
- sqlnotify-0.1.0/src/sqlnotify.egg-info/dependency_links.txt +1 -0
- sqlnotify-0.1.0/src/sqlnotify.egg-info/entry_points.txt +2 -0
- sqlnotify-0.1.0/src/sqlnotify.egg-info/requires.txt +8 -0
- sqlnotify-0.1.0/src/sqlnotify.egg-info/top_level.txt +1 -0
- sqlnotify-0.1.0/tests/test_postgres_dialect.py +417 -0
- sqlnotify-0.1.0/tests/test_postgres_notifier.py +383 -0
- sqlnotify-0.1.0/tests/test_postgres_notifier_asgi_adapter.py +233 -0
- sqlnotify-0.1.0/tests/test_sqlite_dialect.py +304 -0
- sqlnotify-0.1.0/tests/test_sqlite_notifier.py +369 -0
- sqlnotify-0.1.0/tests/test_sqlite_notifier_asgi_adapter.py +202 -0
- sqlnotify-0.1.0/tests/test_watcher.py +588 -0
sqlnotify-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Brai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
sqlnotify-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlnotify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A near real-time SQL notification library for database changes, supporting PostgreSQL and SQLite.
|
|
5
|
+
Author-email: Daniel Brai <danielbrai.dev@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Daniel-Brai/SQLNotify
|
|
8
|
+
Project-URL: Repository, https://github.com/Daniel-Brai/SQLNotify
|
|
9
|
+
Project-URL: Source, https://github.com/Daniel-Brai/SQLNotify
|
|
10
|
+
Project-URL: Issues, https://github.com/Daniel-Brai/SQLNotify/issues
|
|
11
|
+
Keywords: sql,database,notification,asyncio,postgresql,sqlite,sqlalchemy
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
22
|
+
Classifier: Framework :: AsyncIO
|
|
23
|
+
Classifier: Topic :: Database
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
30
|
+
Requires-Dist: asyncpg>=0.31.0
|
|
31
|
+
Provides-Extra: all
|
|
32
|
+
Requires-Dist: aiosqlite>=0.22.1; extra == "all"
|
|
33
|
+
Provides-Extra: sqlite
|
|
34
|
+
Requires-Dist: aiosqlite>=0.22.1; extra == "sqlite"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# SQLNotify
|
|
38
|
+
|
|
39
|
+
[](https://www.python.org/downloads/)
|
|
40
|
+
[](https://github.com/Daniel-Brai/SQLNotify/actions/workflows/ci.yml)
|
|
41
|
+
[](https://codecov.io/gh/Daniel-Brai/SQLNotify)
|
|
42
|
+
[](https://opensource.org/licenses/MIT)
|
|
43
|
+
|
|
44
|
+
**React to database changes in near real-time.**
|
|
45
|
+
|
|
46
|
+
SQLNotify leverages database native notification mechanisms (like PostgreSQL's LISTEN/NOTIFY) to provide instant notifications when your database records change. It supports FastAPI, Starlette, and other ASGI frameworks.
|
|
47
|
+
|
|
48
|
+
## Motivation for SQLNotify?
|
|
49
|
+
|
|
50
|
+
I started SQLNotify as an experiment for my projects that require real-time updates based on database changes. I wanted a solution that was simple to integrate, efficient, and didn't require external dependencies like message queues or change data capture tools.
|
|
51
|
+
|
|
52
|
+
SQLNotify uses the underling database system to push notifications the moment data changes. This enables:
|
|
53
|
+
|
|
54
|
+
- **Near real-time updates** - React to changes almost instantly
|
|
55
|
+
- **Lower database load** - No repeated SELECT queries checking for changes
|
|
56
|
+
- **Simplified architecture** - No need for message queues or external pub/sub systems
|
|
57
|
+
- **Type-safe** - Full typing support with SQLAlchemy 2.0+ with support for SQLModel models too
|
|
58
|
+
- **Production-ready** - It handles large payloads with overflow tables and automatic cleanup
|
|
59
|
+
- **Extensible** - Pluggable dialect system allows support for different databases
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
By default, sqlnotify install with PostgreSQL support:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install sqlnotify
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
With SQLite support:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install "sqlnotify[sqlite]"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
With SQLModel support and all database drivers supported by SQLNotify:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install "sqlnotify[all]"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Using other package managers:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Using uv
|
|
85
|
+
uv add sqlnotify
|
|
86
|
+
# With SQLite support
|
|
87
|
+
uv add "sqlnotify[sqlite]"
|
|
88
|
+
|
|
89
|
+
# Using poetry
|
|
90
|
+
poetry add sqlnotify
|
|
91
|
+
# With SQLite support
|
|
92
|
+
poetry add "sqlnotify[sqlite]"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Quick Start
|
|
96
|
+
|
|
97
|
+
### Basic Usage with FastAPI
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from fastapi import FastAPI
|
|
101
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
102
|
+
from sqlmodel import SQLModel, Field
|
|
103
|
+
from sqlnotify import Notifier, Operation, ChangeEvent
|
|
104
|
+
from sqlnotify.adapters.asgi import sqlnotify_lifespan
|
|
105
|
+
from contextlib import asynccontextmanager
|
|
106
|
+
|
|
107
|
+
# Define your models
|
|
108
|
+
class User(SQLModel, table=True):
|
|
109
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
110
|
+
email: str
|
|
111
|
+
name: str
|
|
112
|
+
|
|
113
|
+
# Create async or syncengine
|
|
114
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
|
|
115
|
+
# engine = create_async_engine("sqlite+aiosqlite:///./myapp.db")
|
|
116
|
+
|
|
117
|
+
# Initialize notifier
|
|
118
|
+
notifier = Notifier(db_engine=engine)
|
|
119
|
+
|
|
120
|
+
# Register watchers for models and operations
|
|
121
|
+
# By default `extra_columns` is None and only primary key(s) (e.g., `id`) are returned in events.
|
|
122
|
+
notifier.watch(User, Operation.INSERT)
|
|
123
|
+
notifier.watch(User, Operation.UPDATE, extra_columns=["email"])
|
|
124
|
+
|
|
125
|
+
# Subscribe to changes
|
|
126
|
+
@notifier.subscribe(User, Operation.INSERT)
|
|
127
|
+
async def on_user_created(event: ChangeEvent):
|
|
128
|
+
print(f"New user created: {event.id}")
|
|
129
|
+
|
|
130
|
+
@notifier.subscribe(User, Operation.UPDATE)
|
|
131
|
+
# or @notifier.subscribe("User", Operation.UPDATE)
|
|
132
|
+
async def on_user_updated(event: ChangeEvent):
|
|
133
|
+
print(f"User {event.id} updated")
|
|
134
|
+
|
|
135
|
+
# You can also filter by specific column values
|
|
136
|
+
@notifier.subscribe(User, Operation.UPDATE, filters=[{"column": "id", "value": 42}])
|
|
137
|
+
async def on_specific_user_updated(event: ChangeEvent):
|
|
138
|
+
print(f"User 42 was updated")
|
|
139
|
+
|
|
140
|
+
# Setup lifespan
|
|
141
|
+
@asynccontextmanager
|
|
142
|
+
async def lifespan(app: FastAPI):
|
|
143
|
+
async with sqlnotify_lifespan(notifier):
|
|
144
|
+
yield
|
|
145
|
+
|
|
146
|
+
app = FastAPI(lifespan=lifespan)
|
|
147
|
+
|
|
148
|
+
@app.post("/users/")
|
|
149
|
+
async def create_user(email: str, name: str):
|
|
150
|
+
# Your user creation logic
|
|
151
|
+
# Notification will fire automatically when database triggers
|
|
152
|
+
return {"email": email, "name": name}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Without ASGI Lifespan Management
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from sqlalchemy import create_engine
|
|
159
|
+
from sqlnotify import Notifier, Operation
|
|
160
|
+
|
|
161
|
+
engine = create_engine("postgresql+psycopg2://user:pass@localhost/db")
|
|
162
|
+
|
|
163
|
+
notifier = Notifier(db_engine=engine)
|
|
164
|
+
|
|
165
|
+
# Register watchers
|
|
166
|
+
notifier.watch(User, Operation.INSERT, extra_columns=["email", "name"])
|
|
167
|
+
|
|
168
|
+
@notifier.subscribe(User, Operation.INSERT)
|
|
169
|
+
def on_user_created(event: ChangeEvent):
|
|
170
|
+
print(f"User {event.id} created")
|
|
171
|
+
|
|
172
|
+
# Start/stop manually (sync engine)
|
|
173
|
+
notifier.start()
|
|
174
|
+
# ... your application logic ...
|
|
175
|
+
notifier.stop()
|
|
176
|
+
|
|
177
|
+
# For async engine, use async methods:
|
|
178
|
+
# await notifier.astart()
|
|
179
|
+
# ... your application logic ...
|
|
180
|
+
# await notifier.astop()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Using SQLite
|
|
184
|
+
|
|
185
|
+
See [SQLITE_QUICKSTART.md](docs/SQLITE_QUICKSTART.md) for the SQLite quick start guide.
|
|
186
|
+
|
|
187
|
+
## Features
|
|
188
|
+
|
|
189
|
+
### Pluggable Dialect System
|
|
190
|
+
|
|
191
|
+
SQLNotify uses a dialect-based architecture to support different databases. The dialect is automatically detected from your SQLAlchemy engine:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
195
|
+
from sqlnotify import Notifier
|
|
196
|
+
|
|
197
|
+
# PostgreSQL dialect is automatically selected
|
|
198
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
|
|
199
|
+
notifier = Notifier(db_engine=engine)
|
|
200
|
+
|
|
201
|
+
# Access dialect information
|
|
202
|
+
print(notifier.dialect_name) # "postgresql"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Currently supported dialects:**
|
|
206
|
+
|
|
207
|
+
- **PostgreSQL** - Uses native LISTEN/NOTIFY mechanism (instant, <10ms latency)
|
|
208
|
+
- **SQLite** - Uses hybrid polling and in-memory queue (20-50ms latency, adaptive CPU usage)
|
|
209
|
+
|
|
210
|
+
### Automatic Trigger Management
|
|
211
|
+
|
|
212
|
+
SQLNotify automatically creates and manages database triggers for your models based on operations you want to listen for:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
notifier = Notifier(db_engine=engine)
|
|
216
|
+
|
|
217
|
+
# Register watchers for different models and operations
|
|
218
|
+
notifier.watch(User, Operation.INSERT, extra_columns=["email", "name"])
|
|
219
|
+
notifier.watch(User, Operation.UPDATE, extra_columns=["email"])
|
|
220
|
+
notifier.watch(Post, Operation.INSERT, extra_columns=["title"])
|
|
221
|
+
|
|
222
|
+
# No extra columns, just the primary key column which is usually "id"
|
|
223
|
+
notifier.watch(Comment, Operation.INSERT)
|
|
224
|
+
# if your model has a different primary key column name, specify it with primary_keys parameter
|
|
225
|
+
# notifier.watch(Comment, Operation.INSERT, primary_keys=["comment_id"])
|
|
226
|
+
# as so the `ChangeEvent.id` will be the value of the `comment_id` column instead of `id`
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
SQLNotify triggers are created on startup and optionally cleaned up when the notifier stops.
|
|
230
|
+
|
|
231
|
+
### Watch Specific Columns
|
|
232
|
+
|
|
233
|
+
Monitor only specific columns for changes:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
notifier.watch(
|
|
237
|
+
User,
|
|
238
|
+
Operation.UPDATE,
|
|
239
|
+
extra_columns=["email", "name"],
|
|
240
|
+
trigger_columns=["email"] # Only trigger on email changes
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@notifier.subscribe(User, Operation.UPDATE)
|
|
244
|
+
async def on_email_changed(event: ChangeEvent):
|
|
245
|
+
new_email = event.extra_columns.get("email")
|
|
246
|
+
print(f"User {event.id} changed email to {new_email}")
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Custom Primary Keys
|
|
250
|
+
|
|
251
|
+
Specify custom primary key column names (useful for composite keys or non-standard primary keys):
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# Single primary key with custom name
|
|
255
|
+
notifier.watch(
|
|
256
|
+
User,
|
|
257
|
+
Operation.INSERT,
|
|
258
|
+
extra_columns=["email"],
|
|
259
|
+
primary_keys=["user_id"] # Custom primary key column
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Composite primary key
|
|
263
|
+
notifier.watch(
|
|
264
|
+
OrderItem,
|
|
265
|
+
Operation.UPDATE,
|
|
266
|
+
extra_columns=["quantity"],
|
|
267
|
+
primary_keys=["order_id", "item_id"] # Composite primary key
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
@notifier.subscribe(OrderItem, Operation.UPDATE)
|
|
271
|
+
async def on_order_item_updated(event: ChangeEvent):
|
|
272
|
+
order_id, item_id = event.id
|
|
273
|
+
print(f"Order item ({order_id}, {item_id}) updated")
|
|
274
|
+
|
|
275
|
+
# Empty extra_columns - only primary key(s) will be in the payload
|
|
276
|
+
notifier.watch(
|
|
277
|
+
User,
|
|
278
|
+
Operation.DELETE,
|
|
279
|
+
extra_columns=None, # No extra columns needed which is the default
|
|
280
|
+
primary_keys=["id"]
|
|
281
|
+
)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Note**:
|
|
285
|
+
|
|
286
|
+
- The default is `primary_keys=["id"]`
|
|
287
|
+
- For single primary keys, `event.id` is the value directly
|
|
288
|
+
- For composite primary keys, `event.id` is a tuple of values in the order specified
|
|
289
|
+
- **All specified `primary_keys` must be actual primary key columns on the model** - the system validates this at runtime
|
|
290
|
+
- All columns in `extra_columns`, `trigger_columns`, and `primary_keys` are validated to ensure they exist on the model
|
|
291
|
+
- `extra_columns` can be an empty list if you only need the primary key(s)
|
|
292
|
+
|
|
293
|
+
### Watch Specific Records
|
|
294
|
+
|
|
295
|
+
Subscribe to changes for specific record IDs or column values:
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
# Filter by ID
|
|
299
|
+
@notifier.subscribe(User, Operation.UPDATE, filters=[{"column": "id", "value": 42}])
|
|
300
|
+
async def on_vip_user_updated(event: ChangeEvent):
|
|
301
|
+
print(f"VIP user {event.id} was updated")
|
|
302
|
+
|
|
303
|
+
# Filter by multiple columns
|
|
304
|
+
@notifier.subscribe(
|
|
305
|
+
User,
|
|
306
|
+
Operation.UPDATE,
|
|
307
|
+
filters=[
|
|
308
|
+
{"column": "status", "value": "active"},
|
|
309
|
+
{"column": "role", "value": "admin"}
|
|
310
|
+
]
|
|
311
|
+
)
|
|
312
|
+
async def on_active_admin_updated(event: ChangeEvent):
|
|
313
|
+
print(f"Active admin user {event.id} was updated")
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Overflow Tables for Large Payloads
|
|
317
|
+
|
|
318
|
+
SQLNotify uses overflow tables for large payloads. SQLNotify handles this automatically if you enable overflow tables per watcher basis:
|
|
319
|
+
|
|
320
|
+
```python
|
|
321
|
+
notifier.watch(
|
|
322
|
+
User,
|
|
323
|
+
Operation.INSERT,
|
|
324
|
+
extra_columns=["email", "name", "bio", "preferences"],
|
|
325
|
+
use_overflow_table=True # Large payloads stored in overflow table
|
|
326
|
+
)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Notifications
|
|
330
|
+
|
|
331
|
+
Send custom notifications through the same channel system:
|
|
332
|
+
|
|
333
|
+
```python
|
|
334
|
+
# Notify with a model instance (async engine)
|
|
335
|
+
await notifier.anotify(
|
|
336
|
+
User,
|
|
337
|
+
Operation.UPDATE,
|
|
338
|
+
payload={"id": 123, "email": "user@example.com"}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Notify with custom channel label
|
|
342
|
+
await notifier.anotify(
|
|
343
|
+
"User",
|
|
344
|
+
Operation.INSERT,
|
|
345
|
+
payload={"id": 456},
|
|
346
|
+
channel_label="custom_channel"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# Notify with large payload using overflow table
|
|
350
|
+
# This automatically handles payloads larger than 7999 bytes
|
|
351
|
+
await notifier.anotify(
|
|
352
|
+
User,
|
|
353
|
+
Operation.INSERT,
|
|
354
|
+
payload=large_payload_dict,
|
|
355
|
+
use_overflow_table=True
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# For sync engines, use notify() instead:
|
|
359
|
+
# notifier.notify(User, Operation.UPDATE, payload={"id": 123})
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**Note**: The `notify`/`anotify` methods validate payload size and can use overflow tables for large payloads. If `use_overflow_table=True`, payloads exceeding NOTIFY limit are automatically stored in the overflow table, and only an overflow ID is sent through the notification channel.
|
|
363
|
+
|
|
364
|
+
### Model Change Detection
|
|
365
|
+
|
|
366
|
+
Automatically recreate triggers when model schemas change:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
notifier = Notifier(
|
|
370
|
+
db_engine=engine,
|
|
371
|
+
revoke_on_model_change=True # Drop and recreate triggers on schema changes
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Register watchers
|
|
375
|
+
# By default `extra_columns` is None and only primary key(s) (e.g., `id`) are returned in events.
|
|
376
|
+
notifier.watch(User, Operation.INSERT)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### Custom Channel Labels
|
|
380
|
+
|
|
381
|
+
Use custom channel names for logical grouping:
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
notifier.watch(
|
|
385
|
+
User,
|
|
386
|
+
Operation.INSERT,
|
|
387
|
+
extra_columns=["email"],
|
|
388
|
+
channel_label="new_registrations"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
@notifier.subscribe("User", Operation.INSERT, channel_label="new_registrations")
|
|
392
|
+
async def on_new_registration(event: ChangeEvent):
|
|
393
|
+
print("New user registered")
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Real-World Example: WebSocket Notifications
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
400
|
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
401
|
+
from sqlmodel import SQLModel, Field, select
|
|
402
|
+
from sqlnotify import Notifier, Operation, ChangeEvent
|
|
403
|
+
from sqlnotify.adapters.asgi import sqlnotify_lifespan
|
|
404
|
+
from contextlib import asynccontextmanager
|
|
405
|
+
from typing import List
|
|
406
|
+
|
|
407
|
+
# Models
|
|
408
|
+
class User(SQLModel, table=True):
|
|
409
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
410
|
+
email: str
|
|
411
|
+
name: str
|
|
412
|
+
|
|
413
|
+
class Post(SQLModel, table=True):
|
|
414
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
415
|
+
title: str
|
|
416
|
+
content: str
|
|
417
|
+
user_id: int = Field(foreign_key="user.id")
|
|
418
|
+
|
|
419
|
+
# Database setup
|
|
420
|
+
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
|
|
421
|
+
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
422
|
+
|
|
423
|
+
# WebSocket connection manager
|
|
424
|
+
class ConnectionManager:
|
|
425
|
+
def __init__(self):
|
|
426
|
+
self.active_connections: List[WebSocket] = []
|
|
427
|
+
|
|
428
|
+
async def connect(self, websocket: WebSocket):
|
|
429
|
+
await websocket.accept()
|
|
430
|
+
self.active_connections.append(websocket)
|
|
431
|
+
|
|
432
|
+
def disconnect(self, websocket: WebSocket):
|
|
433
|
+
self.active_connections.remove(websocket)
|
|
434
|
+
|
|
435
|
+
async def broadcast(self, message: dict):
|
|
436
|
+
for connection in self.active_connections:
|
|
437
|
+
try:
|
|
438
|
+
await connection.send_json(message)
|
|
439
|
+
except:
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
manager = ConnectionManager()
|
|
443
|
+
|
|
444
|
+
# Notifier setup
|
|
445
|
+
notifier = Notifier(db_engine=engine)
|
|
446
|
+
|
|
447
|
+
# Register watchers
|
|
448
|
+
notifier.watch(User, Operation.INSERT, extra_columns=["email", "name"])
|
|
449
|
+
notifier.watch(Post, Operation.INSERT, extra_columns=["title", "user_id"])
|
|
450
|
+
|
|
451
|
+
@notifier.subscribe(User, Operation.INSERT)
|
|
452
|
+
async def broadcast_new_user(event: ChangeEvent):
|
|
453
|
+
await manager.broadcast({
|
|
454
|
+
"type": "user_created",
|
|
455
|
+
"id": event.id,
|
|
456
|
+
"email": event.extra_columns.get("email"),
|
|
457
|
+
"name": event.extra_columns.get("name")
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
@notifier.subscribe(Post, Operation.INSERT)
|
|
461
|
+
async def broadcast_new_post(event: ChangeEvent):
|
|
462
|
+
await manager.broadcast({
|
|
463
|
+
"type": "post_created",
|
|
464
|
+
"id": event.id,
|
|
465
|
+
"title": event.extra_columns.get("title"),
|
|
466
|
+
"user_id": event.extra_columns.get("user_id")
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
@notifier.subscribe(User, Operation.UPDATE)
|
|
470
|
+
async def broadcast_user_update(event: ChangeEvent):
|
|
471
|
+
await manager.broadcast({
|
|
472
|
+
"type": "user_updated",
|
|
473
|
+
"id": event.id
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
# Lifespan
|
|
477
|
+
@asynccontextmanager
|
|
478
|
+
async def lifespan(app: FastAPI):
|
|
479
|
+
async with sqlnotify_lifespan(notifier):
|
|
480
|
+
yield
|
|
481
|
+
|
|
482
|
+
app = FastAPI(lifespan=lifespan)
|
|
483
|
+
|
|
484
|
+
# WebSocket endpoint
|
|
485
|
+
@app.websocket("/ws")
|
|
486
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
487
|
+
await manager.connect(websocket)
|
|
488
|
+
async for _ in websocket.iter_text():
|
|
489
|
+
pass # Just keep connection alive for broadcasts
|
|
490
|
+
manager.disconnect(websocket)
|
|
491
|
+
|
|
492
|
+
# API endpoints
|
|
493
|
+
@app.post("/users/")
|
|
494
|
+
async def create_user(email: str, name: str):
|
|
495
|
+
async with async_session_maker() as session:
|
|
496
|
+
user = User(email=email, name=name)
|
|
497
|
+
session.add(user)
|
|
498
|
+
await session.commit()
|
|
499
|
+
await session.refresh(user)
|
|
500
|
+
return user
|
|
501
|
+
|
|
502
|
+
@app.post("/posts/")
|
|
503
|
+
async def create_post(title: str, content: str, user_id: int):
|
|
504
|
+
async with async_session_maker() as session:
|
|
505
|
+
post = Post(title=title, content=content, user_id=user_id)
|
|
506
|
+
session.add(post)
|
|
507
|
+
await session.commit()
|
|
508
|
+
await session.refresh(post)
|
|
509
|
+
return post
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## Configuration
|
|
513
|
+
|
|
514
|
+
```python
|
|
515
|
+
notifier = Notifier(
|
|
516
|
+
db_engine=engine, # Required: AsyncEngine or Engine
|
|
517
|
+
revoke_on_model_change=True, # Drop and recreate triggers when model schema changes
|
|
518
|
+
cleanup_on_start=False, # Remove existing triggers/functions on startup
|
|
519
|
+
use_logger=True # Enable internal logging
|
|
520
|
+
)
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
**Note**: Models and operations are registered individually using `notifier.watch()` method.
|
|
524
|
+
|
|
525
|
+
### Watch Method Options
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
notifier.watch(
|
|
529
|
+
model=User, # Model class to watch
|
|
530
|
+
operation=Operation.UPDATE, # INSERT, UPDATE, or DELETE
|
|
531
|
+
extra_columns=["email", "name"], # Columns to include in notifications (can be empty list)
|
|
532
|
+
trigger_columns=["email"], # Only trigger on these column changes (UPDATE only)
|
|
533
|
+
primary_keys=["id"], # Primary key columns (must be actual PKs on model)
|
|
534
|
+
channel_label="custom_name", # Custom channel identifier
|
|
535
|
+
use_overflow_table=False # Store large payloads and process them using the overflow table
|
|
536
|
+
)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Database Support
|
|
540
|
+
|
|
541
|
+
SQLNotify uses a pluggable dialect system for database support:
|
|
542
|
+
|
|
543
|
+
| Database | Dialect | Status | Mechanism | Latency |
|
|
544
|
+
|----------|---------|--------|-----------|----------|
|
|
545
|
+
| PostgreSQL 9.0+ | `postgresql` | ✅ Supported | LISTEN/NOTIFY | <10ms |
|
|
546
|
+
| SQLite 3.0+ | `sqlite` | ✅ Supported | Hybrid Polling and In-Memory Queue | 20-50ms |
|
|
547
|
+
| MySQL | `mysql` | 🔜 Planned | - | - |
|
|
548
|
+
|
|
549
|
+
### When to Use SQLite
|
|
550
|
+
|
|
551
|
+
✅ **Use SQLite when:**
|
|
552
|
+
|
|
553
|
+
- When you are building small to medium applications
|
|
554
|
+
- You are running in single-process or embedded environments
|
|
555
|
+
- You need a lightweight, serverless database
|
|
556
|
+
- You want a simple setup without external dependencies or separate database servers
|
|
557
|
+
- You can tolerate ~20-50ms notification latency
|
|
558
|
+
|
|
559
|
+
❌ **Don't use SQLite when:**
|
|
560
|
+
|
|
561
|
+
- Building large scale distributed systems
|
|
562
|
+
- You are running in multi-process or multi-server architecture
|
|
563
|
+
- You need almost instant notifications (<10ms latency)
|
|
564
|
+
- You need very high throughput requirements (>5000 msgs/sec) and cross server communication
|
|
565
|
+
|
|
566
|
+
## Framework Support
|
|
567
|
+
|
|
568
|
+
SQLNotify works with any ASGI framework:
|
|
569
|
+
|
|
570
|
+
- **FastAPI** - Use `sqlnotify_lifespan` helper
|
|
571
|
+
- **Starlette** - Use `sqlnotify_lifespan` helper
|
|
572
|
+
- **Other ASGI frameworks** - Implement lifespan management manually
|
|
573
|
+
|
|
574
|
+
For synchronous frameworks, use sync mode:
|
|
575
|
+
|
|
576
|
+
```python
|
|
577
|
+
notifier = Notifier(db_engine=engine, ...)
|
|
578
|
+
notifier.start() # Synchronous start
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
## How It Works
|
|
582
|
+
|
|
583
|
+
1. **Dialect Detection** - SQLNotify automatically detects the database dialect from your SQLAlchemy engine
|
|
584
|
+
2. **Trigger Creation** - The dialect creates database-specific triggers on your tables
|
|
585
|
+
3. **Change Detection** - When data changes, triggers fire and call notification functions
|
|
586
|
+
4. **NOTIFY** - Functions use database-native notification mechanisms (e.g., PostgreSQL's NOTIFY)
|
|
587
|
+
5. **LISTEN** - SQLNotify maintains a dedicated connection listening for notifications
|
|
588
|
+
6. **Event Distribution** - Incoming notifications are routed to subscribed callbacks
|
|
589
|
+
7. **Callback Execution** - Your subscriber functions are called with change events
|
|
590
|
+
|
|
591
|
+
## Performance Considerations
|
|
592
|
+
|
|
593
|
+
- **Dedicated Connection** - SQLNotify uses a separate connection for LISTEN to avoid blocking if the engine is PostgreSQL
|
|
594
|
+
- **Async by Default** - Built for asyncio, but supports sync mode when needed
|
|
595
|
+
- **Overflow Handling** - Large payloads automatically routed to overflow tables
|
|
596
|
+
- **Automatic Cleanup** - Consumed overflow records are cleaned up after 1 hour
|
|
597
|
+
- **Minimal Overhead** - Triggers are efficient as such notification delivery is near instant
|
|
598
|
+
|
|
599
|
+
## Contributing
|
|
600
|
+
|
|
601
|
+
I welcome any contribution. Please see the [Contributing Guide](CONTRIBUTING.md) for details.
|
|
602
|
+
|
|
603
|
+
## License
|
|
604
|
+
|
|
605
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
606
|
+
|
|
607
|
+
## Acknowledgments
|
|
608
|
+
|
|
609
|
+
- Built with [SQLAlchemy](https://www.sqlalchemy.org/)
|
|
610
|
+
- PostgreSQL [LISTEN/NOTIFY](https://www.postgresql.org/docs/current/sql-notify.html) documentation
|