surreal-orm-lite 0.2.2__tar.gz → 0.4.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 (21) hide show
  1. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/CHANGELOG.md +58 -0
  2. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/PKG-INFO +76 -1
  3. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/README.md +75 -0
  4. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/pyproject.toml +1 -1
  5. surreal_orm_lite-0.4.0/src/surreal_orm_lite/__init__.py +64 -0
  6. surreal_orm_lite-0.4.0/src/surreal_orm_lite/aggregations.py +279 -0
  7. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/model_base.py +161 -14
  8. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/query_set.py +379 -12
  9. surreal_orm_lite-0.4.0/src/surreal_orm_lite/signals.py +286 -0
  10. surreal_orm_lite-0.4.0/src/surreal_orm_lite/utils.py +53 -0
  11. surreal_orm_lite-0.2.2/src/surreal_orm_lite/__init__.py +0 -27
  12. surreal_orm_lite-0.2.2/src/surreal_orm_lite/utils.py +0 -6
  13. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/.gitignore +0 -0
  14. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/LICENSE +0 -0
  15. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/Makefile +0 -0
  16. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/__init__.py +0 -0
  17. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/connection_manager.py +0 -0
  18. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/constants.py +0 -0
  19. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/enum.py +0 -0
  20. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/exceptions.py +0 -0
  21. {surreal_orm_lite-0.2.2 → surreal_orm_lite-0.4.0}/src/surreal_orm_lite/py.typed +0 -0
@@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-02-07
9
+
10
+ ### Added
11
+
12
+ - **Model Signals**: Django-style event system for model lifecycle
13
+ - `Signal` class for pre/post event handlers
14
+ - `AroundSignal` class for context manager-style wrapping signals with `yield`
15
+ - `pre_save` / `post_save` - Fired before/after `save()` operations
16
+ - `pre_update` / `post_update` - Fired before/after `update()` and `merge()` operations
17
+ - `pre_delete` / `post_delete` - Fired before/after `delete()` operations
18
+ - `around_save` / `around_update` / `around_delete` - Wrap operations for timing, logging, etc.
19
+ - `connect(model_class)` decorator for registering handlers
20
+ - `disconnect(handler, model_class)` for removing handlers
21
+ - `clear()` for removing all handlers
22
+ - `has_handlers()` for checking if handlers are registered
23
+
24
+ - `post_save` signal includes `created` flag to distinguish new records
25
+ - `pre_update` / `post_update` signals include `update_fields` list
26
+ - New test file `tests/test_signals.py` with unit and e2e tests
27
+
28
+ ### Changed
29
+
30
+ - `save()`, `update()`, `merge()`, `delete()` now emit signals when handlers are registered
31
+ - Internal `_do_save()` method extracted from `save()` for signal integration
32
+
33
+ ## [0.3.0] - 2026-02-05
34
+
35
+ ### Added
36
+
37
+ - **Aggregation Functions**: New aggregation classes for database calculations
38
+ - `Count()` - Count records
39
+ - `Sum(field)` - Sum numeric field values
40
+ - `Avg(field)` - Calculate average of numeric field
41
+ - `Min(field)` - Find minimum value
42
+ - `Max(field)` - Find maximum value
43
+
44
+ - **QuerySet Aggregation Methods**: Shortcut methods for common aggregations
45
+ - `count()` - Returns count as integer directly
46
+ - `sum(field)` - Returns sum as float/int
47
+ - `avg(field)` - Returns average as float
48
+ - `min(field)` - Returns minimum value
49
+ - `max(field)` - Returns maximum value
50
+
51
+ - **GROUP BY Support**: Django-style grouping with annotations
52
+ - `values(*fields)` - Specify fields for GROUP BY
53
+ - `annotate(**aggregations)` - Add aggregation annotations
54
+
55
+ - **exists() Method**: Efficiently check if records exist
56
+
57
+ - **raw_query() Class Method**: Execute arbitrary SurrealQL queries with variables
58
+
59
+ - New test file `tests/test_aggregations.py` with comprehensive unit and e2e tests
60
+
61
+ ### Changed
62
+
63
+ - QuerySet now tracks `_group_by_fields` and `_annotations` for GROUP BY queries
64
+ - `exec()` method now handles GROUP BY queries differently, returning dicts instead of model instances
65
+
8
66
  ## [0.2.2] - 2026-02-05
9
67
 
10
68
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: surreal-orm-lite
3
- Version: 0.2.2
3
+ Version: 0.4.0
4
4
  Summary: Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation.
5
5
  Project-URL: Homepage, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
6
6
  Project-URL: Documentation, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
@@ -191,6 +191,9 @@ results = await User.objects().query(
191
191
  | Custom primary keys | ✅ |
192
192
  | HTTP connections | ✅ |
193
193
  | WebSocket connections | ✅ |
194
+ | Aggregations | ✅ |
195
+ | GROUP BY | ✅ |
196
+ | Model Signals | ✅ |
194
197
 
195
198
  ### Supported Filter Lookups
196
199
 
@@ -201,6 +204,78 @@ results = await User.objects().query(
201
204
  - `startswith`, `istartswith`
202
205
  - `endswith`, `iendswith`
203
206
 
207
+ ### 5. Aggregations
208
+
209
+ ```python
210
+ from surreal_orm_lite import Count, Sum, Avg, Min, Max
211
+
212
+ # Simple aggregations
213
+ count = await User.objects().count()
214
+ total = await Order.objects().sum("amount")
215
+ avg_age = await User.objects().avg("age")
216
+ max_price = await Product.objects().max("price")
217
+ min_price = await Product.objects().min("price")
218
+
219
+ # Check existence
220
+ has_admins = await User.objects().filter(role="admin").exists()
221
+
222
+ # GROUP BY with annotations
223
+ results = await User.objects().values("status").annotate(count=Count()).exec()
224
+ # [{"status": "active", "count": 42}, {"status": "inactive", "count": 8}]
225
+
226
+ # Raw SurrealQL queries
227
+ results = await User.raw_query(
228
+ "SELECT * FROM User WHERE age > $min_age",
229
+ variables={"min_age": 18}
230
+ )
231
+ ```
232
+
233
+ ### 6. Model Signals
234
+
235
+ ```python
236
+ from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete
237
+
238
+ @post_save.connect(User)
239
+ async def on_user_saved(sender, instance, created, **kwargs):
240
+ """Called after every User save."""
241
+ if created:
242
+ await send_welcome_email(instance.email)
243
+ await invalidate_cache(f"user:{instance.id}")
244
+
245
+ @pre_delete.connect(User)
246
+ async def on_user_deleting(sender, instance, **kwargs):
247
+ """Called before User deletion."""
248
+ await archive_user_data(instance.id)
249
+ ```
250
+
251
+ **Available signals:**
252
+
253
+ | Signal | When | Extra kwargs |
254
+ | --------------- | --------------------------- | ---------------- |
255
+ | `pre_save` | Before `save()` | |
256
+ | `post_save` | After `save()` | `created` |
257
+ | `pre_update` | Before `update()`/`merge()` | `update_fields` |
258
+ | `post_update` | After `update()`/`merge()` | `update_fields` |
259
+ | `pre_delete` | Before `delete()` | |
260
+ | `post_delete` | After `delete()` | |
261
+ | `around_save` | Wraps `save()` | |
262
+ | `around_update` | Wraps `update()`/`merge()` | `update_fields` |
263
+ | `around_delete` | Wraps `delete()` | |
264
+
265
+ **Around signals** use async generators to wrap operations:
266
+
267
+ ```python
268
+ from surreal_orm_lite import around_save
269
+
270
+ @around_save.connect(User)
271
+ async def time_user_save(sender, instance, **kwargs):
272
+ import time
273
+ start = time.time()
274
+ yield # save() executes here
275
+ duration = time.time() - start
276
+ print(f"Save took {duration:.3f}s")
277
+ ```
278
+
204
279
  ---
205
280
 
206
281
  ## Configuration Options
@@ -141,6 +141,9 @@ results = await User.objects().query(
141
141
  | Custom primary keys | ✅ |
142
142
  | HTTP connections | ✅ |
143
143
  | WebSocket connections | ✅ |
144
+ | Aggregations | ✅ |
145
+ | GROUP BY | ✅ |
146
+ | Model Signals | ✅ |
144
147
 
145
148
  ### Supported Filter Lookups
146
149
 
@@ -151,6 +154,78 @@ results = await User.objects().query(
151
154
  - `startswith`, `istartswith`
152
155
  - `endswith`, `iendswith`
153
156
 
157
+ ### 5. Aggregations
158
+
159
+ ```python
160
+ from surreal_orm_lite import Count, Sum, Avg, Min, Max
161
+
162
+ # Simple aggregations
163
+ count = await User.objects().count()
164
+ total = await Order.objects().sum("amount")
165
+ avg_age = await User.objects().avg("age")
166
+ max_price = await Product.objects().max("price")
167
+ min_price = await Product.objects().min("price")
168
+
169
+ # Check existence
170
+ has_admins = await User.objects().filter(role="admin").exists()
171
+
172
+ # GROUP BY with annotations
173
+ results = await User.objects().values("status").annotate(count=Count()).exec()
174
+ # [{"status": "active", "count": 42}, {"status": "inactive", "count": 8}]
175
+
176
+ # Raw SurrealQL queries
177
+ results = await User.raw_query(
178
+ "SELECT * FROM User WHERE age > $min_age",
179
+ variables={"min_age": 18}
180
+ )
181
+ ```
182
+
183
+ ### 6. Model Signals
184
+
185
+ ```python
186
+ from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete
187
+
188
+ @post_save.connect(User)
189
+ async def on_user_saved(sender, instance, created, **kwargs):
190
+ """Called after every User save."""
191
+ if created:
192
+ await send_welcome_email(instance.email)
193
+ await invalidate_cache(f"user:{instance.id}")
194
+
195
+ @pre_delete.connect(User)
196
+ async def on_user_deleting(sender, instance, **kwargs):
197
+ """Called before User deletion."""
198
+ await archive_user_data(instance.id)
199
+ ```
200
+
201
+ **Available signals:**
202
+
203
+ | Signal | When | Extra kwargs |
204
+ | --------------- | --------------------------- | ---------------- |
205
+ | `pre_save` | Before `save()` | |
206
+ | `post_save` | After `save()` | `created` |
207
+ | `pre_update` | Before `update()`/`merge()` | `update_fields` |
208
+ | `post_update` | After `update()`/`merge()` | `update_fields` |
209
+ | `pre_delete` | Before `delete()` | |
210
+ | `post_delete` | After `delete()` | |
211
+ | `around_save` | Wraps `save()` | |
212
+ | `around_update` | Wraps `update()`/`merge()` | `update_fields` |
213
+ | `around_delete` | Wraps `delete()` | |
214
+
215
+ **Around signals** use async generators to wrap operations:
216
+
217
+ ```python
218
+ from surreal_orm_lite import around_save
219
+
220
+ @around_save.connect(User)
221
+ async def time_user_save(sender, instance, **kwargs):
222
+ import time
223
+ start = time.time()
224
+ yield # save() executes here
225
+ duration = time.time() - start
226
+ print(f"Save took {duration:.3f}s")
227
+ ```
228
+
154
229
  ---
155
230
 
156
231
  ## Configuration Options
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "surreal-orm-lite"
3
- version = "0.2.2"
3
+ version = "0.4.0"
4
4
  description = "Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,64 @@
1
+ __version__ = "0.4.0"
2
+
3
+ from .aggregations import Aggregation, Avg, Count, Max, Min, Sum
4
+ from .connection_manager import SurrealDBConnectionManager
5
+ from .enum import OrderBy
6
+ from .exceptions import (
7
+ SurrealDbConnectionError,
8
+ SurrealDbError,
9
+ SurrealDbNotFoundError,
10
+ SurrealDbValidationError,
11
+ SurrealORMError,
12
+ )
13
+ from .model_base import BaseSurrealModel, SurrealConfigDict
14
+ from .query_set import QuerySet
15
+ from .signals import (
16
+ AroundSignal,
17
+ Signal,
18
+ around_delete,
19
+ around_save,
20
+ around_update,
21
+ post_delete,
22
+ post_save,
23
+ post_update,
24
+ pre_delete,
25
+ pre_save,
26
+ pre_update,
27
+ )
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ # Connection
32
+ "SurrealDBConnectionManager",
33
+ # Model
34
+ "BaseSurrealModel",
35
+ "SurrealConfigDict",
36
+ # QuerySet
37
+ "QuerySet",
38
+ "OrderBy",
39
+ # Aggregations
40
+ "Aggregation",
41
+ "Count",
42
+ "Sum",
43
+ "Avg",
44
+ "Min",
45
+ "Max",
46
+ # Signals
47
+ "Signal",
48
+ "AroundSignal",
49
+ "pre_save",
50
+ "post_save",
51
+ "pre_update",
52
+ "post_update",
53
+ "pre_delete",
54
+ "post_delete",
55
+ "around_save",
56
+ "around_update",
57
+ "around_delete",
58
+ # Exceptions
59
+ "SurrealORMError",
60
+ "SurrealDbError",
61
+ "SurrealDbConnectionError",
62
+ "SurrealDbValidationError",
63
+ "SurrealDbNotFoundError",
64
+ ]
@@ -0,0 +1,279 @@
1
+ """
2
+ Aggregation classes for SurrealDB-ORM-lite.
3
+
4
+ This module provides Django-style aggregation functions that can be used
5
+ with QuerySet to perform aggregate calculations on database fields.
6
+
7
+ Example:
8
+ ```python
9
+ from surreal_orm_lite import Count, Sum, Avg, Min, Max
10
+
11
+ # Simple aggregations
12
+ count = await User.objects().count()
13
+ total = await Order.objects().sum("amount")
14
+
15
+ # With GROUP BY
16
+ results = await User.objects().values("status").annotate(count=Count()).exec()
17
+ ```
18
+ """
19
+
20
+ from abc import ABC, abstractmethod
21
+
22
+ from .utils import validate_field_name
23
+
24
+
25
+ class Aggregation(ABC):
26
+ """
27
+ Base class for all aggregation functions.
28
+
29
+ Aggregations are used to compute summary values from a set of records.
30
+ Each aggregation must implement the `to_sql()` method that returns
31
+ the SurrealDB SQL expression for the aggregation.
32
+ """
33
+
34
+ def __init__(self, field: str | None = None, alias: str | None = None) -> None:
35
+ """
36
+ Initialize an aggregation.
37
+
38
+ Args:
39
+ field: The field name to aggregate. Some aggregations (like Count)
40
+ don't require a field.
41
+ alias: Optional alias for the result. If not provided, a default
42
+ alias will be generated.
43
+ """
44
+ self.field = field
45
+ self.alias = alias
46
+
47
+ @abstractmethod
48
+ def to_sql(self) -> str:
49
+ """
50
+ Convert the aggregation to a SurrealDB SQL expression.
51
+
52
+ Returns:
53
+ str: The SQL expression for this aggregation.
54
+ """
55
+ pass # pragma: no cover
56
+
57
+ def get_alias(self) -> str:
58
+ """
59
+ Get the alias for this aggregation result.
60
+
61
+ Returns:
62
+ str: The alias name for the aggregation result.
63
+ """
64
+ if self.alias:
65
+ return self.alias
66
+ if self.field:
67
+ return f"{self.__class__.__name__.lower()}_{self.field}"
68
+ return self.__class__.__name__.lower()
69
+
70
+
71
+ class Count(Aggregation):
72
+ """
73
+ Count aggregation function.
74
+
75
+ Counts the number of records in a query result.
76
+
77
+ Example:
78
+ ```python
79
+ # Count all users
80
+ count = await User.objects().count()
81
+
82
+ # Count with filter
83
+ active_count = await User.objects().filter(status="active").count()
84
+
85
+ # Count with GROUP BY
86
+ results = await User.objects().values("status").annotate(count=Count()).exec()
87
+ ```
88
+ """
89
+
90
+ def __init__(self, field: str | None = None, alias: str | None = None) -> None:
91
+ """
92
+ Initialize a Count aggregation.
93
+
94
+ Args:
95
+ field: Optional field name. If not provided, counts all records.
96
+ alias: Optional alias for the result.
97
+ """
98
+ if field is not None:
99
+ validate_field_name(field, "Count field")
100
+ super().__init__(field, alias)
101
+
102
+ def to_sql(self) -> str:
103
+ """
104
+ Convert to SurrealDB SQL.
105
+
106
+ Returns:
107
+ str: "count()" or "count(field)" expression.
108
+ """
109
+ if self.field:
110
+ return f"count({self.field})"
111
+ return "count()"
112
+
113
+
114
+ class Sum(Aggregation):
115
+ """
116
+ Sum aggregation function.
117
+
118
+ Calculates the sum of a numeric field.
119
+
120
+ Example:
121
+ ```python
122
+ # Sum of all order amounts
123
+ total = await Order.objects().sum("amount")
124
+
125
+ # Sum with filter
126
+ total_completed = await Order.objects().filter(status="completed").sum("amount")
127
+
128
+ # Sum with GROUP BY
129
+ results = await Order.objects().values("customer_id").annotate(total=Sum("amount")).exec()
130
+ ```
131
+ """
132
+
133
+ def __init__(self, field: str, alias: str | None = None) -> None:
134
+ """
135
+ Initialize a Sum aggregation.
136
+
137
+ Args:
138
+ field: The numeric field to sum.
139
+ alias: Optional alias for the result.
140
+ """
141
+ if not field or not field.strip():
142
+ raise ValueError("Sum requires a field name")
143
+ validate_field_name(field, "Sum field")
144
+ super().__init__(field, alias)
145
+
146
+ def to_sql(self) -> str:
147
+ """
148
+ Convert to SurrealDB SQL.
149
+
150
+ Returns:
151
+ str: "math::sum(field)" expression.
152
+ """
153
+ return f"math::sum({self.field})"
154
+
155
+
156
+ class Avg(Aggregation):
157
+ """
158
+ Average aggregation function.
159
+
160
+ Calculates the average of a numeric field.
161
+
162
+ Example:
163
+ ```python
164
+ # Average age of all users
165
+ avg_age = await User.objects().avg("age")
166
+
167
+ # Average with filter
168
+ avg_active = await User.objects().filter(status="active").avg("age")
169
+
170
+ # Average with GROUP BY
171
+ results = await User.objects().values("department").annotate(avg_salary=Avg("salary")).exec()
172
+ ```
173
+ """
174
+
175
+ def __init__(self, field: str, alias: str | None = None) -> None:
176
+ """
177
+ Initialize an Avg aggregation.
178
+
179
+ Args:
180
+ field: The numeric field to average.
181
+ alias: Optional alias for the result.
182
+ """
183
+ if not field or not field.strip():
184
+ raise ValueError("Avg requires a field name")
185
+ validate_field_name(field, "Avg field")
186
+ super().__init__(field, alias)
187
+
188
+ def to_sql(self) -> str:
189
+ """
190
+ Convert to SurrealDB SQL.
191
+
192
+ Returns:
193
+ str: "math::mean(field)" expression.
194
+ """
195
+ return f"math::mean({self.field})"
196
+
197
+
198
+ class Min(Aggregation):
199
+ """
200
+ Minimum aggregation function.
201
+
202
+ Finds the minimum value of a field.
203
+
204
+ Example:
205
+ ```python
206
+ # Minimum price
207
+ min_price = await Product.objects().min("price")
208
+
209
+ # Minimum with filter
210
+ min_active = await Product.objects().filter(active=True).min("price")
211
+
212
+ # Minimum with GROUP BY
213
+ results = await Product.objects().values("category").annotate(min_price=Min("price")).exec()
214
+ ```
215
+ """
216
+
217
+ def __init__(self, field: str, alias: str | None = None) -> None:
218
+ """
219
+ Initialize a Min aggregation.
220
+
221
+ Args:
222
+ field: The field to find the minimum value of.
223
+ alias: Optional alias for the result.
224
+ """
225
+ if not field or not field.strip():
226
+ raise ValueError("Min requires a field name")
227
+ validate_field_name(field, "Min field")
228
+ super().__init__(field, alias)
229
+
230
+ def to_sql(self) -> str:
231
+ """
232
+ Convert to SurrealDB SQL.
233
+
234
+ Returns:
235
+ str: "math::min(field)" expression.
236
+ """
237
+ return f"math::min({self.field})"
238
+
239
+
240
+ class Max(Aggregation):
241
+ """
242
+ Maximum aggregation function.
243
+
244
+ Finds the maximum value of a field.
245
+
246
+ Example:
247
+ ```python
248
+ # Maximum price
249
+ max_price = await Product.objects().max("price")
250
+
251
+ # Maximum with filter
252
+ max_active = await Product.objects().filter(active=True).max("price")
253
+
254
+ # Maximum with GROUP BY
255
+ results = await Product.objects().values("category").annotate(max_price=Max("price")).exec()
256
+ ```
257
+ """
258
+
259
+ def __init__(self, field: str, alias: str | None = None) -> None:
260
+ """
261
+ Initialize a Max aggregation.
262
+
263
+ Args:
264
+ field: The field to find the maximum value of.
265
+ alias: Optional alias for the result.
266
+ """
267
+ if not field or not field.strip():
268
+ raise ValueError("Max requires a field name")
269
+ validate_field_name(field, "Max field")
270
+ super().__init__(field, alias)
271
+
272
+ def to_sql(self) -> str:
273
+ """
274
+ Convert to SurrealDB SQL.
275
+
276
+ Returns:
277
+ str: "math::max(field)" expression.
278
+ """
279
+ return f"math::max({self.field})"