surreal-orm-lite 0.5.0__tar.gz → 0.6.2__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.
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/CHANGELOG.md +42 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/PKG-INFO +55 -17
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/README.md +54 -16
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/pyproject.toml +1 -1
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/__init__.py +1 -1
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/constants.py +0 -4
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/model_base.py +219 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/query_set.py +27 -1
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/utils.py +72 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/.gitignore +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/LICENSE +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/Makefile +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/__init__.py +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/aggregations.py +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/connection_manager.py +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/enum.py +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/exceptions.py +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/py.typed +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/q.py +0 -0
- {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/signals.py +0 -0
|
@@ -5,6 +5,48 @@ 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.6.0] - 2026-03-10
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Relations & Graph**: Full SurrealDB graph relation support on `BaseSurrealModel`
|
|
13
|
+
- `relate(edge, target, data=)` — Create graph relations (`RELATE source->edge->target`)
|
|
14
|
+
- `remove_relation(edge, target)` — Remove a specific relation
|
|
15
|
+
- `remove_all_relations(edge, direction=)` — Remove all relations of a type (`out`, `in`, `both`)
|
|
16
|
+
- `get_related(edge, direction=, model_class=)` — Retrieve related records through an edge
|
|
17
|
+
- `traverse(path)` — Graph traversal via SurrealQL path syntax (e.g. `->follows->User->follows->User`)
|
|
18
|
+
|
|
19
|
+
- **FETCH Clause**: `QuerySet.fetch(*fields)` resolves record links inline, preventing N+1 queries
|
|
20
|
+
- `Post.objects().fetch("author", "tags").exec()` generates `SELECT * FROM Post FETCH author, tags;`
|
|
21
|
+
|
|
22
|
+
- **Validation Utilities**: New security validators in `utils.py`
|
|
23
|
+
- `validate_edge_name()` — Validates edge/relation table names
|
|
24
|
+
- `validate_graph_path()` — Validates graph traversal paths (strict arrow-segment structure)
|
|
25
|
+
- `validate_thing()` — Validates `table:id` record identifiers against injection
|
|
26
|
+
|
|
27
|
+
- New test file `tests/test_relations.py` with unit + E2E tests
|
|
28
|
+
|
|
29
|
+
- **CI/CD Workflows**:
|
|
30
|
+
- `.surrealdb-version` file to pin the tested SurrealDB version (2.6.0)
|
|
31
|
+
- `surrealdb-security.yml` — Daily SurrealDB 2.X version monitor (test, auto-PR, auto-issue)
|
|
32
|
+
- `dependabot-automerge.yml` — Auto-merge Dependabot PRs with test validation and version bump
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `QuerySet._compile_query()` now appends `FETCH` clause when `fetch()` is used
|
|
37
|
+
- `QuerySet.fetch()` now accumulates fields across chained calls instead of overwriting
|
|
38
|
+
- `QuerySet.variables()` now merges variables across chained calls instead of overwriting
|
|
39
|
+
- Coverage: 92.80%
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **Security**: `_resolve_target_thing()` now validates string targets with `validate_thing()` to prevent SurrealQL injection
|
|
44
|
+
- **Security**: `_get_thing()` now validates the generated `table:id` string to prevent injection via malicious model IDs
|
|
45
|
+
- **Security**: `get_related()` now validates RecordIDs with `validate_thing()` before interpolation
|
|
46
|
+
- **Security**: `$` variable references in filters are now validated against `^\$[a-zA-Z_][a-zA-Z0-9_]*$` pattern
|
|
47
|
+
- **Security**: `validate_graph_path()` regex tightened to require arrow-separated segments (`->` or `<-`) — rejects arbitrary `<>-` combinations
|
|
48
|
+
- Removed misleading case-insensitive lookup aliases (`icontains`, `istartswith`, `iendswith`, `iregex`) that were mapped to case-sensitive SurrealDB operators
|
|
49
|
+
|
|
8
50
|
## [0.5.0] - 2026-02-11
|
|
9
51
|
|
|
10
52
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: surreal-orm-lite
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.2
|
|
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
|
|
@@ -51,7 +51,7 @@ Description-Content-Type: text/markdown
|
|
|
51
51
|
# Surreal ORM Lite
|
|
52
52
|
|
|
53
53
|

|
|
54
|
-

|
|
55
55
|

|
|
56
56
|

|
|
57
57
|
[](https://codecov.io/gh/EulogySnowfall/SurrealDB-ORM-lite)
|
|
@@ -74,10 +74,12 @@ This ORM is designed to:
|
|
|
74
74
|
| Dependency | Version |
|
|
75
75
|
| ------------ | ---------------- |
|
|
76
76
|
| Python | 3.11+ |
|
|
77
|
-
| SurrealDB | 2.6.0
|
|
77
|
+
| SurrealDB | >=2.6.x, <3.0 |
|
|
78
78
|
| Official SDK | surrealdb>=1.0.8 |
|
|
79
79
|
| Pydantic | >=2.12.5 |
|
|
80
80
|
|
|
81
|
+
> **Note**: The official SurrealDB Python SDK (`surrealdb>=1.0.8`) only supports SurrealDB 2.X. For SurrealDB 3.X support, use the full [SurrealDB-ORM](https://github.com/EulogySnowfall/SurrealDB-ORM/) (v0.30.0+).
|
|
82
|
+
|
|
81
83
|
---
|
|
82
84
|
|
|
83
85
|
## Installation
|
|
@@ -199,18 +201,19 @@ results = await User.objects().query(
|
|
|
199
201
|
| Parameterized filters | ✅ |
|
|
200
202
|
| Bulk operations | ✅ |
|
|
201
203
|
| `-field` ordering | ✅ |
|
|
204
|
+
| Relations & Graph | ✅ |
|
|
205
|
+
| FETCH clause | ✅ |
|
|
202
206
|
|
|
203
207
|
### Supported Filter Lookups
|
|
204
208
|
|
|
205
209
|
- `exact` (default)
|
|
206
210
|
- `gt`, `gte`, `lt`, `lte`
|
|
207
211
|
- `in`, `not_in`
|
|
208
|
-
- `contains`, `
|
|
212
|
+
- `contains`, `not_contains`
|
|
209
213
|
- `containsall`, `containsany`
|
|
210
|
-
- `startswith`, `
|
|
211
|
-
- `endswith`, `iendswith`
|
|
214
|
+
- `startswith`, `endswith`
|
|
212
215
|
- `like`, `ilike`
|
|
213
|
-
- `match`, `regex
|
|
216
|
+
- `match`, `regex`
|
|
214
217
|
- `isnull`
|
|
215
218
|
|
|
216
219
|
### 5. Q Objects (Complex Queries)
|
|
@@ -250,7 +253,40 @@ count = await User.objects().filter(status="pending").bulk_update(status="active
|
|
|
250
253
|
count = await User.objects().filter(status="inactive").bulk_delete()
|
|
251
254
|
```
|
|
252
255
|
|
|
253
|
-
### 7.
|
|
256
|
+
### 7. Relations & Graph
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
# Create a relation
|
|
260
|
+
await user.relate("follows", other_user)
|
|
261
|
+
|
|
262
|
+
# With data on the edge
|
|
263
|
+
await user.relate("purchased", product, data={"quantity": 2, "price": 29.99})
|
|
264
|
+
|
|
265
|
+
# Get related records (outgoing)
|
|
266
|
+
following = await user.get_related("follows", direction="out", model_class=User)
|
|
267
|
+
|
|
268
|
+
# Get related records (incoming)
|
|
269
|
+
followers = await user.get_related("follows", direction="in", model_class=User)
|
|
270
|
+
|
|
271
|
+
# Remove a specific relation
|
|
272
|
+
await user.remove_relation("follows", other_user)
|
|
273
|
+
|
|
274
|
+
# Remove all outgoing relations of a type
|
|
275
|
+
await user.remove_all_relations("follows", direction="out")
|
|
276
|
+
|
|
277
|
+
# Graph traversal
|
|
278
|
+
friends_of_friends = await user.traverse("->follows->User->follows->User")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### 8. FETCH Clause
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
# Resolve record links inline (prevents N+1 queries)
|
|
285
|
+
posts = await Post.objects().fetch("author", "tags").exec()
|
|
286
|
+
# Generates: SELECT * FROM Post FETCH author, tags;
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### 9. Aggregations
|
|
254
290
|
|
|
255
291
|
```python
|
|
256
292
|
from surreal_orm_lite import Count, Sum, Avg, Min, Max
|
|
@@ -276,7 +312,7 @@ results = await User.raw_query(
|
|
|
276
312
|
)
|
|
277
313
|
```
|
|
278
314
|
|
|
279
|
-
###
|
|
315
|
+
### 10. Model Signals
|
|
280
316
|
|
|
281
317
|
```python
|
|
282
318
|
from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete
|
|
@@ -351,12 +387,14 @@ async with SurrealDBConnectionManager():
|
|
|
351
387
|
|
|
352
388
|
## Compatibility
|
|
353
389
|
|
|
354
|
-
This ORM is tested and compatible with
|
|
390
|
+
This ORM is tested and compatible with SurrealDB 2.X only (SDK limitation). For SurrealDB 3.X, use [SurrealDB-ORM](https://github.com/EulogySnowfall/SurrealDB-ORM/) v0.30.0+.
|
|
355
391
|
|
|
356
|
-
| SurrealDB Version | SDK Version | Status
|
|
357
|
-
| ----------------- | ----------- |
|
|
358
|
-
| 2.6.
|
|
359
|
-
| 2.
|
|
392
|
+
| SurrealDB Version | SDK Version | Status |
|
|
393
|
+
| ----------------- | ----------- | ------------------ |
|
|
394
|
+
| 2.6.3 | 1.0.8 | ✅ Tested |
|
|
395
|
+
| 2.6.x | 1.0.8 | ✅ Compatible |
|
|
396
|
+
| 2.5.x | 1.0.8 | ✅ Compatible |
|
|
397
|
+
| 3.x | — | ❌ Not supported |
|
|
360
398
|
|
|
361
399
|
---
|
|
362
400
|
|
|
@@ -380,7 +418,7 @@ Contributions are welcome! Please:
|
|
|
380
418
|
| v0.3.0 | Aggregations & Utilities | ✅ Released |
|
|
381
419
|
| v0.4.0 | Model Signals | ✅ Released |
|
|
382
420
|
| v0.5.0 | Bulk Operations & Q Objects | ✅ Released |
|
|
383
|
-
| v0.6.0 | Relations & Graph |
|
|
421
|
+
| v0.6.0 | Relations & Graph | ✅ Released |
|
|
384
422
|
| v0.7.0 | Transactions ORM | 📋 Planned |
|
|
385
423
|
| v0.8.0 | SurrealFunc & Computed Fields | 📋 Planned |
|
|
386
424
|
| v0.9.0 | Field Aliases & DX | 📋 Planned |
|
|
@@ -402,8 +440,8 @@ This project prioritizes **stability and compatibility** with the official Surre
|
|
|
402
440
|
| Bulk Operations | ✅ | ✅ |
|
|
403
441
|
| Q Objects (OR/AND/NOT) | ✅ | ✅ |
|
|
404
442
|
| Parameterized Filters | ✅ | ✅ |
|
|
405
|
-
| Relations & Graph |
|
|
406
|
-
| FETCH clause |
|
|
443
|
+
| Relations & Graph | ✅ | ✅ |
|
|
444
|
+
| FETCH clause | ✅ | ✅ |
|
|
407
445
|
| Transactions (tx=) | v0.7.0 | ✅ |
|
|
408
446
|
| SurrealFunc & Computed | v0.8.0 | ✅ |
|
|
409
447
|
| Field Aliases | v0.9.0 | ✅ |
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Surreal ORM Lite
|
|
2
2
|
|
|
3
3
|

|
|
4
|
-

|
|
5
5
|

|
|
6
6
|

|
|
7
7
|
[](https://codecov.io/gh/EulogySnowfall/SurrealDB-ORM-lite)
|
|
@@ -24,10 +24,12 @@ This ORM is designed to:
|
|
|
24
24
|
| Dependency | Version |
|
|
25
25
|
| ------------ | ---------------- |
|
|
26
26
|
| Python | 3.11+ |
|
|
27
|
-
| SurrealDB | 2.6.0
|
|
27
|
+
| SurrealDB | >=2.6.x, <3.0 |
|
|
28
28
|
| Official SDK | surrealdb>=1.0.8 |
|
|
29
29
|
| Pydantic | >=2.12.5 |
|
|
30
30
|
|
|
31
|
+
> **Note**: The official SurrealDB Python SDK (`surrealdb>=1.0.8`) only supports SurrealDB 2.X. For SurrealDB 3.X support, use the full [SurrealDB-ORM](https://github.com/EulogySnowfall/SurrealDB-ORM/) (v0.30.0+).
|
|
32
|
+
|
|
31
33
|
---
|
|
32
34
|
|
|
33
35
|
## Installation
|
|
@@ -149,18 +151,19 @@ results = await User.objects().query(
|
|
|
149
151
|
| Parameterized filters | ✅ |
|
|
150
152
|
| Bulk operations | ✅ |
|
|
151
153
|
| `-field` ordering | ✅ |
|
|
154
|
+
| Relations & Graph | ✅ |
|
|
155
|
+
| FETCH clause | ✅ |
|
|
152
156
|
|
|
153
157
|
### Supported Filter Lookups
|
|
154
158
|
|
|
155
159
|
- `exact` (default)
|
|
156
160
|
- `gt`, `gte`, `lt`, `lte`
|
|
157
161
|
- `in`, `not_in`
|
|
158
|
-
- `contains`, `
|
|
162
|
+
- `contains`, `not_contains`
|
|
159
163
|
- `containsall`, `containsany`
|
|
160
|
-
- `startswith`, `
|
|
161
|
-
- `endswith`, `iendswith`
|
|
164
|
+
- `startswith`, `endswith`
|
|
162
165
|
- `like`, `ilike`
|
|
163
|
-
- `match`, `regex
|
|
166
|
+
- `match`, `regex`
|
|
164
167
|
- `isnull`
|
|
165
168
|
|
|
166
169
|
### 5. Q Objects (Complex Queries)
|
|
@@ -200,7 +203,40 @@ count = await User.objects().filter(status="pending").bulk_update(status="active
|
|
|
200
203
|
count = await User.objects().filter(status="inactive").bulk_delete()
|
|
201
204
|
```
|
|
202
205
|
|
|
203
|
-
### 7.
|
|
206
|
+
### 7. Relations & Graph
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# Create a relation
|
|
210
|
+
await user.relate("follows", other_user)
|
|
211
|
+
|
|
212
|
+
# With data on the edge
|
|
213
|
+
await user.relate("purchased", product, data={"quantity": 2, "price": 29.99})
|
|
214
|
+
|
|
215
|
+
# Get related records (outgoing)
|
|
216
|
+
following = await user.get_related("follows", direction="out", model_class=User)
|
|
217
|
+
|
|
218
|
+
# Get related records (incoming)
|
|
219
|
+
followers = await user.get_related("follows", direction="in", model_class=User)
|
|
220
|
+
|
|
221
|
+
# Remove a specific relation
|
|
222
|
+
await user.remove_relation("follows", other_user)
|
|
223
|
+
|
|
224
|
+
# Remove all outgoing relations of a type
|
|
225
|
+
await user.remove_all_relations("follows", direction="out")
|
|
226
|
+
|
|
227
|
+
# Graph traversal
|
|
228
|
+
friends_of_friends = await user.traverse("->follows->User->follows->User")
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### 8. FETCH Clause
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
# Resolve record links inline (prevents N+1 queries)
|
|
235
|
+
posts = await Post.objects().fetch("author", "tags").exec()
|
|
236
|
+
# Generates: SELECT * FROM Post FETCH author, tags;
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### 9. Aggregations
|
|
204
240
|
|
|
205
241
|
```python
|
|
206
242
|
from surreal_orm_lite import Count, Sum, Avg, Min, Max
|
|
@@ -226,7 +262,7 @@ results = await User.raw_query(
|
|
|
226
262
|
)
|
|
227
263
|
```
|
|
228
264
|
|
|
229
|
-
###
|
|
265
|
+
### 10. Model Signals
|
|
230
266
|
|
|
231
267
|
```python
|
|
232
268
|
from surreal_orm_lite import pre_save, post_save, pre_delete, post_delete
|
|
@@ -301,12 +337,14 @@ async with SurrealDBConnectionManager():
|
|
|
301
337
|
|
|
302
338
|
## Compatibility
|
|
303
339
|
|
|
304
|
-
This ORM is tested and compatible with
|
|
340
|
+
This ORM is tested and compatible with SurrealDB 2.X only (SDK limitation). For SurrealDB 3.X, use [SurrealDB-ORM](https://github.com/EulogySnowfall/SurrealDB-ORM/) v0.30.0+.
|
|
305
341
|
|
|
306
|
-
| SurrealDB Version | SDK Version | Status
|
|
307
|
-
| ----------------- | ----------- |
|
|
308
|
-
| 2.6.
|
|
309
|
-
| 2.
|
|
342
|
+
| SurrealDB Version | SDK Version | Status |
|
|
343
|
+
| ----------------- | ----------- | ------------------ |
|
|
344
|
+
| 2.6.3 | 1.0.8 | ✅ Tested |
|
|
345
|
+
| 2.6.x | 1.0.8 | ✅ Compatible |
|
|
346
|
+
| 2.5.x | 1.0.8 | ✅ Compatible |
|
|
347
|
+
| 3.x | — | ❌ Not supported |
|
|
310
348
|
|
|
311
349
|
---
|
|
312
350
|
|
|
@@ -330,7 +368,7 @@ Contributions are welcome! Please:
|
|
|
330
368
|
| v0.3.0 | Aggregations & Utilities | ✅ Released |
|
|
331
369
|
| v0.4.0 | Model Signals | ✅ Released |
|
|
332
370
|
| v0.5.0 | Bulk Operations & Q Objects | ✅ Released |
|
|
333
|
-
| v0.6.0 | Relations & Graph |
|
|
371
|
+
| v0.6.0 | Relations & Graph | ✅ Released |
|
|
334
372
|
| v0.7.0 | Transactions ORM | 📋 Planned |
|
|
335
373
|
| v0.8.0 | SurrealFunc & Computed Fields | 📋 Planned |
|
|
336
374
|
| v0.9.0 | Field Aliases & DX | 📋 Planned |
|
|
@@ -352,8 +390,8 @@ This project prioritizes **stability and compatibility** with the official Surre
|
|
|
352
390
|
| Bulk Operations | ✅ | ✅ |
|
|
353
391
|
| Q Objects (OR/AND/NOT) | ✅ | ✅ |
|
|
354
392
|
| Parameterized Filters | ✅ | ✅ |
|
|
355
|
-
| Relations & Graph |
|
|
356
|
-
| FETCH clause |
|
|
393
|
+
| Relations & Graph | ✅ | ✅ |
|
|
394
|
+
| FETCH clause | ✅ | ✅ |
|
|
357
395
|
| Transactions (tx=) | v0.7.0 | ✅ |
|
|
358
396
|
| SurrealFunc & Computed | v0.8.0 | ✅ |
|
|
359
397
|
| Field Aliases | v0.9.0 | ✅ |
|
|
@@ -9,16 +9,12 @@ LOOKUP_OPERATORS = {
|
|
|
9
9
|
"like": "LIKE",
|
|
10
10
|
"ilike": "ILIKE",
|
|
11
11
|
"contains": "CONTAINS",
|
|
12
|
-
"icontains": "CONTAINS",
|
|
13
12
|
"not_contains": "CONTAINSNOT",
|
|
14
13
|
"containsall": "CONTAINSALL",
|
|
15
14
|
"containsany": "CONTAINSANY",
|
|
16
15
|
"startswith": "STARTSWITH",
|
|
17
|
-
"istartswith": "STARTSWITH",
|
|
18
16
|
"endswith": "ENDSWITH",
|
|
19
|
-
"iendswith": "ENDSWITH",
|
|
20
17
|
"match": "MATCH",
|
|
21
18
|
"regex": "REGEX",
|
|
22
|
-
"iregex": "REGEX",
|
|
23
19
|
"isnull": "IS",
|
|
24
20
|
}
|
|
@@ -18,6 +18,7 @@ from .signals import (
|
|
|
18
18
|
pre_save,
|
|
19
19
|
pre_update,
|
|
20
20
|
)
|
|
21
|
+
from .utils import remove_quotes_for_variables, validate_edge_name, validate_field_name, validate_graph_path, validate_thing
|
|
21
22
|
|
|
22
23
|
logger = logging.getLogger(__name__)
|
|
23
24
|
|
|
@@ -304,6 +305,224 @@ class BaseSurrealModel(BaseModel):
|
|
|
304
305
|
|
|
305
306
|
return self
|
|
306
307
|
|
|
308
|
+
# ==================== Relations & Graph ====================
|
|
309
|
+
|
|
310
|
+
def _get_thing(self) -> str:
|
|
311
|
+
"""Return ``table:id`` string for this instance."""
|
|
312
|
+
id_val = self.get_id()
|
|
313
|
+
if id_val is None:
|
|
314
|
+
raise SurrealDbError("Cannot use relations on an unsaved model (no id).")
|
|
315
|
+
thing = f"{self.get_table_name()}:{id_val}"
|
|
316
|
+
validate_thing(thing)
|
|
317
|
+
return thing
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _resolve_target_thing(target: "BaseSurrealModel | str") -> str:
|
|
321
|
+
"""Resolve a target to ``table:id`` string."""
|
|
322
|
+
if isinstance(target, str):
|
|
323
|
+
validate_thing(target)
|
|
324
|
+
return target
|
|
325
|
+
if isinstance(target, BaseSurrealModel):
|
|
326
|
+
return target._get_thing()
|
|
327
|
+
raise TypeError(f"target must be a BaseSurrealModel instance or 'table:id' string, got {type(target).__name__}")
|
|
328
|
+
|
|
329
|
+
async def relate(
|
|
330
|
+
self,
|
|
331
|
+
edge: str,
|
|
332
|
+
target: "BaseSurrealModel | str",
|
|
333
|
+
*,
|
|
334
|
+
data: dict[str, Any] | None = None,
|
|
335
|
+
) -> list[dict[str, Any]]:
|
|
336
|
+
"""
|
|
337
|
+
Create a graph relation from this record to the target.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
edge: The relation/edge table name (e.g. ``"follows"``).
|
|
341
|
+
target: The target model instance or ``"table:id"`` string.
|
|
342
|
+
data: Optional data to store on the relation edge.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
The created relation record(s) as returned by the database.
|
|
346
|
+
|
|
347
|
+
Example::
|
|
348
|
+
|
|
349
|
+
await user.relate("follows", other_user)
|
|
350
|
+
await user.relate("purchased", product, data={"quantity": 2})
|
|
351
|
+
"""
|
|
352
|
+
validate_edge_name(edge)
|
|
353
|
+
source = self._get_thing()
|
|
354
|
+
target_thing = self._resolve_target_thing(target)
|
|
355
|
+
|
|
356
|
+
if data:
|
|
357
|
+
set_parts: list[str] = []
|
|
358
|
+
variables: dict[str, Any] = {}
|
|
359
|
+
for i, (key, value) in enumerate(data.items()):
|
|
360
|
+
validate_field_name(key, "relation data field")
|
|
361
|
+
var_name = f"_rd{i}"
|
|
362
|
+
set_parts.append(f"{key} = ${var_name}")
|
|
363
|
+
variables[var_name] = value
|
|
364
|
+
set_clause = " SET " + ", ".join(set_parts)
|
|
365
|
+
query = f"RELATE {source}->{edge}->{target_thing}{set_clause};"
|
|
366
|
+
else:
|
|
367
|
+
variables = {}
|
|
368
|
+
query = f"RELATE {source}->{edge}->{target_thing};"
|
|
369
|
+
|
|
370
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
371
|
+
result = await client.query(remove_quotes_for_variables(query), variables)
|
|
372
|
+
return result if isinstance(result, list) else []
|
|
373
|
+
|
|
374
|
+
async def remove_relation(
|
|
375
|
+
self,
|
|
376
|
+
edge: str,
|
|
377
|
+
target: "BaseSurrealModel | str",
|
|
378
|
+
) -> None:
|
|
379
|
+
"""
|
|
380
|
+
Remove a specific relation between this record and the target.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
edge: The relation/edge table name.
|
|
384
|
+
target: The target model instance or ``"table:id"`` string.
|
|
385
|
+
|
|
386
|
+
Example::
|
|
387
|
+
|
|
388
|
+
await user.remove_relation("follows", other_user)
|
|
389
|
+
await user.remove_relation("follows", "User:bob")
|
|
390
|
+
"""
|
|
391
|
+
validate_edge_name(edge)
|
|
392
|
+
source = self._get_thing()
|
|
393
|
+
target_thing = self._resolve_target_thing(target)
|
|
394
|
+
|
|
395
|
+
query = f"DELETE {edge} WHERE in = {source} AND out = {target_thing};"
|
|
396
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
397
|
+
await client.query(query, {})
|
|
398
|
+
|
|
399
|
+
async def remove_all_relations(
|
|
400
|
+
self,
|
|
401
|
+
edge: str,
|
|
402
|
+
*,
|
|
403
|
+
direction: str = "out",
|
|
404
|
+
) -> None:
|
|
405
|
+
"""
|
|
406
|
+
Remove all relations of a given type from or to this record.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
edge: The relation/edge table name.
|
|
410
|
+
direction: ``"out"`` removes outgoing relations (default),
|
|
411
|
+
``"in"`` removes incoming relations,
|
|
412
|
+
``"both"`` removes all relations involving this record.
|
|
413
|
+
|
|
414
|
+
Example::
|
|
415
|
+
|
|
416
|
+
await user.remove_all_relations("follows", direction="out")
|
|
417
|
+
await user.remove_all_relations("follows", direction="both")
|
|
418
|
+
"""
|
|
419
|
+
validate_edge_name(edge)
|
|
420
|
+
thing = self._get_thing()
|
|
421
|
+
|
|
422
|
+
if direction == "out":
|
|
423
|
+
query = f"DELETE {edge} WHERE in = {thing};"
|
|
424
|
+
elif direction == "in":
|
|
425
|
+
query = f"DELETE {edge} WHERE out = {thing};"
|
|
426
|
+
elif direction == "both":
|
|
427
|
+
query = f"DELETE {edge} WHERE in = {thing} OR out = {thing};"
|
|
428
|
+
else:
|
|
429
|
+
raise ValueError(f"direction must be 'out', 'in', or 'both', got '{direction}'")
|
|
430
|
+
|
|
431
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
432
|
+
await client.query(query, {})
|
|
433
|
+
|
|
434
|
+
async def get_related(
|
|
435
|
+
self,
|
|
436
|
+
edge: str,
|
|
437
|
+
*,
|
|
438
|
+
direction: str = "out",
|
|
439
|
+
model_class: type["BaseSurrealModel"] | None = None,
|
|
440
|
+
) -> list[Any]:
|
|
441
|
+
"""
|
|
442
|
+
Get related records through a relation edge.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
edge: The relation/edge table name.
|
|
446
|
+
direction: ``"out"`` for outgoing relations (default),
|
|
447
|
+
``"in"`` for incoming relations.
|
|
448
|
+
model_class: Optional model class to deserialize results into.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
A list of related records (model instances if model_class is provided,
|
|
452
|
+
otherwise raw dicts/values).
|
|
453
|
+
|
|
454
|
+
Example::
|
|
455
|
+
|
|
456
|
+
following = await user.get_related("follows", direction="out", model_class=User)
|
|
457
|
+
followers = await user.get_related("follows", direction="in", model_class=User)
|
|
458
|
+
"""
|
|
459
|
+
validate_edge_name(edge)
|
|
460
|
+
thing = self._get_thing()
|
|
461
|
+
|
|
462
|
+
if direction == "out":
|
|
463
|
+
query = f"SELECT VALUE ->{edge}->? FROM ONLY {thing};"
|
|
464
|
+
elif direction == "in":
|
|
465
|
+
query = f"SELECT VALUE <-{edge}<-? FROM ONLY {thing};"
|
|
466
|
+
else:
|
|
467
|
+
raise ValueError(f"direction must be 'out' or 'in', got '{direction}'")
|
|
468
|
+
|
|
469
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
470
|
+
results = await client.query(query, {})
|
|
471
|
+
|
|
472
|
+
if not isinstance(results, list) or len(results) == 0:
|
|
473
|
+
return []
|
|
474
|
+
|
|
475
|
+
# Results from SELECT VALUE ->edge->? are a flat list of record IDs/objects
|
|
476
|
+
records = results
|
|
477
|
+
|
|
478
|
+
if model_class is not None:
|
|
479
|
+
# If results are RecordIDs, we need to SELECT them
|
|
480
|
+
if records and isinstance(records[0], RecordID):
|
|
481
|
+
# RecordIDs come from the database, validate each one before interpolation
|
|
482
|
+
ids = []
|
|
483
|
+
for r in records:
|
|
484
|
+
rid = str(r)
|
|
485
|
+
validate_thing(rid)
|
|
486
|
+
ids.append(rid)
|
|
487
|
+
placeholders = ", ".join(ids)
|
|
488
|
+
fetch_query = f"SELECT * FROM {placeholders};"
|
|
489
|
+
records = await client.query(fetch_query, {})
|
|
490
|
+
if not isinstance(records, list):
|
|
491
|
+
return []
|
|
492
|
+
|
|
493
|
+
try:
|
|
494
|
+
return model_class.from_db(records) # type: ignore
|
|
495
|
+
except (ValueError, TypeError):
|
|
496
|
+
return records
|
|
497
|
+
|
|
498
|
+
return records
|
|
499
|
+
|
|
500
|
+
async def traverse(self, path: str) -> list[Any]:
|
|
501
|
+
"""
|
|
502
|
+
Execute a graph traversal from this record.
|
|
503
|
+
|
|
504
|
+
Args:
|
|
505
|
+
path: A SurrealQL graph traversal path starting with ``->`` or ``<-``.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
A list of records found by the traversal.
|
|
509
|
+
|
|
510
|
+
Example::
|
|
511
|
+
|
|
512
|
+
# Friends of friends
|
|
513
|
+
fof = await user.traverse("->follows->User->follows->User")
|
|
514
|
+
"""
|
|
515
|
+
validate_graph_path(path)
|
|
516
|
+
thing = self._get_thing()
|
|
517
|
+
|
|
518
|
+
query = f"SELECT VALUE {path} FROM ONLY {thing};"
|
|
519
|
+
client = await SurrealDBConnectionManager.get_client()
|
|
520
|
+
results = await client.query(query, {})
|
|
521
|
+
|
|
522
|
+
if isinstance(results, list):
|
|
523
|
+
return results
|
|
524
|
+
return []
|
|
525
|
+
|
|
307
526
|
@classmethod
|
|
308
527
|
def objects(cls) -> Any:
|
|
309
528
|
"""
|
|
@@ -45,6 +45,7 @@ class QuerySet:
|
|
|
45
45
|
self._order_by: str | None = None
|
|
46
46
|
self._model_table: str = getattr(model, "_table_name", model.__name__)
|
|
47
47
|
self._variables: dict = {}
|
|
48
|
+
self._fetch_fields: list[str] = []
|
|
48
49
|
self._group_by_fields: list[str] = []
|
|
49
50
|
self._annotations: dict[str, Aggregation] = {}
|
|
50
51
|
|
|
@@ -76,7 +77,7 @@ class QuerySet:
|
|
|
76
77
|
Returns:
|
|
77
78
|
Self: The current instance for method chaining.
|
|
78
79
|
"""
|
|
79
|
-
self._variables
|
|
80
|
+
self._variables.update(kwargs)
|
|
80
81
|
return self
|
|
81
82
|
|
|
82
83
|
def filter(self, *args: Q, **kwargs: Any) -> Self:
|
|
@@ -185,6 +186,28 @@ class QuerySet:
|
|
|
185
186
|
self._order_by = ", ".join(order_parts)
|
|
186
187
|
return self
|
|
187
188
|
|
|
189
|
+
def fetch(self, *fields: str) -> Self:
|
|
190
|
+
"""
|
|
191
|
+
Add a FETCH clause to resolve record links inline.
|
|
192
|
+
|
|
193
|
+
This prevents N+1 queries by fetching linked records in a single query.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
*fields: Field names to fetch/resolve.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Self: The current instance for method chaining.
|
|
200
|
+
|
|
201
|
+
Example::
|
|
202
|
+
|
|
203
|
+
posts = await Post.objects().fetch("author", "tags").exec()
|
|
204
|
+
# Generates: SELECT * FROM Post FETCH author, tags;
|
|
205
|
+
"""
|
|
206
|
+
for field in fields:
|
|
207
|
+
validate_field_name(field, "FETCH field")
|
|
208
|
+
self._fetch_fields.extend(fields)
|
|
209
|
+
return self
|
|
210
|
+
|
|
188
211
|
# ==================== Internal query building ====================
|
|
189
212
|
|
|
190
213
|
def _build_where(self) -> tuple[str, dict[str, Any]]:
|
|
@@ -243,6 +266,9 @@ class QuerySet:
|
|
|
243
266
|
if self._offset is not None:
|
|
244
267
|
query += f" START {self._offset}"
|
|
245
268
|
|
|
269
|
+
if self._fetch_fields:
|
|
270
|
+
query += f" FETCH {', '.join(self._fetch_fields)}"
|
|
271
|
+
|
|
246
272
|
query += ";"
|
|
247
273
|
all_variables = {**self._variables, **where_vars}
|
|
248
274
|
return query, all_variables
|
|
@@ -11,6 +11,17 @@ VALID_FIELD_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9
|
|
|
11
11
|
# Must start with a letter or underscore (like Python identifiers)
|
|
12
12
|
VALID_ALIAS_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
13
13
|
|
|
14
|
+
# Pattern for valid graph traversal paths: arrow-separated segments
|
|
15
|
+
# e.g. ->follows->User, <-follows<-User, ->follows->User->likes->Post
|
|
16
|
+
VALID_GRAPH_PATH_PATTERN = re.compile(r"^(<-|->)[a-zA-Z_][a-zA-Z0-9_]*((<-|->)[a-zA-Z_][a-zA-Z0-9_]*)*$")
|
|
17
|
+
|
|
18
|
+
# Pattern for valid record thing strings: table:id where both parts are safe
|
|
19
|
+
# ID part allows alphanumeric, underscores, and hyphens
|
|
20
|
+
VALID_THING_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*:[a-zA-Z0-9_`-]+$")
|
|
21
|
+
|
|
22
|
+
# Pattern for valid SurrealQL variable references: $variable_name
|
|
23
|
+
VALID_VARIABLE_REF_PATTERN = re.compile(r"^\$[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
24
|
+
|
|
14
25
|
|
|
15
26
|
def remove_quotes_for_variables(query: str) -> str:
|
|
16
27
|
# Regex to remove single quotes around variables ($)
|
|
@@ -56,6 +67,65 @@ def validate_alias_name(alias: str) -> None:
|
|
|
56
67
|
)
|
|
57
68
|
|
|
58
69
|
|
|
70
|
+
def validate_edge_name(edge: str) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Validate an edge (relation table) name to prevent injection.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
edge: The edge/relation name to validate.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If the edge name contains invalid characters.
|
|
79
|
+
"""
|
|
80
|
+
if not edge or not edge.strip():
|
|
81
|
+
raise ValueError("edge name cannot be empty")
|
|
82
|
+
if not VALID_ALIAS_PATTERN.match(edge):
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Invalid edge name '{edge}': must contain only alphanumeric characters "
|
|
85
|
+
"and underscores, and start with a letter or underscore"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_graph_path(path: str) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Validate a graph traversal path to prevent injection.
|
|
92
|
+
|
|
93
|
+
Accepts paths like ``->follows->User``, ``<-follows<-User``,
|
|
94
|
+
or mixed ``->follows->User->likes->Post``.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
path: The graph traversal path to validate.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ValueError: If the path contains invalid characters or structure.
|
|
101
|
+
"""
|
|
102
|
+
if not path or not path.strip():
|
|
103
|
+
raise ValueError("graph path cannot be empty")
|
|
104
|
+
if not VALID_GRAPH_PATH_PATTERN.match(path):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Invalid graph path '{path}': must be arrow-separated segments starting with -> or <- (e.g. '->follows->User')"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_thing(thing: str) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Validate a ``table:id`` record identifier to prevent injection.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
thing: The record identifier in ``table:id`` format.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If the thing string is not a valid ``table:id`` format.
|
|
119
|
+
"""
|
|
120
|
+
if not thing or not thing.strip():
|
|
121
|
+
raise ValueError("record identifier cannot be empty")
|
|
122
|
+
if not VALID_THING_PATTERN.match(thing):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Invalid record identifier '{thing}': must be in 'table:id' format "
|
|
125
|
+
"with only alphanumeric characters, underscores, and hyphens"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
59
129
|
def parse_lookup(key: str) -> tuple[str, str]:
|
|
60
130
|
"""
|
|
61
131
|
Parse a filter key into field name and lookup type.
|
|
@@ -109,6 +179,8 @@ def build_filter_condition(field: str, lookup: str, value: Any, counter: int) ->
|
|
|
109
179
|
return f"{field} {op} ${var_name}", {var_name: list(value)}, counter + 1
|
|
110
180
|
elif isinstance(value, str) and value.startswith("$"):
|
|
111
181
|
# Backward compat: string values starting with $ are variable references
|
|
182
|
+
if not VALID_VARIABLE_REF_PATTERN.match(value):
|
|
183
|
+
raise ValueError(f"Invalid variable reference '{value}': must match $variable_name pattern")
|
|
112
184
|
return f"{field} {op} {value}", {}, counter
|
|
113
185
|
else:
|
|
114
186
|
return f"{field} {op} ${var_name}", {var_name: value}, counter + 1
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/connection_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|