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.
Files changed (20) hide show
  1. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/CHANGELOG.md +42 -0
  2. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/PKG-INFO +55 -17
  3. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/README.md +54 -16
  4. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/pyproject.toml +1 -1
  5. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/__init__.py +1 -1
  6. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/constants.py +0 -4
  7. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/model_base.py +219 -0
  8. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/query_set.py +27 -1
  9. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/utils.py +72 -0
  10. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/.gitignore +0 -0
  11. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/LICENSE +0 -0
  12. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/Makefile +0 -0
  13. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/__init__.py +0 -0
  14. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/aggregations.py +0 -0
  15. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/connection_manager.py +0 -0
  16. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/enum.py +0 -0
  17. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/exceptions.py +0 -0
  18. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/py.typed +0 -0
  19. {surreal_orm_lite-0.5.0 → surreal_orm_lite-0.6.2}/src/surreal_orm_lite/q.py +0 -0
  20. {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.5.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
  ![Python](https://img.shields.io/badge/python-3.11%2B-blue)
54
- ![SurrealDB](https://img.shields.io/badge/SurrealDB-2.6.0-purple)
54
+ ![SurrealDB](https://img.shields.io/badge/SurrealDB-2.6.3-purple)
55
55
  ![SDK](https://img.shields.io/badge/SDK-Official%201.0.8-green)
56
56
  ![License](https://img.shields.io/badge/license-MIT-blue)
57
57
  [![codecov](https://codecov.io/gh/EulogySnowfall/SurrealDB-ORM-lite/graph/badge.svg)](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`, `icontains`, `not_contains`
212
+ - `contains`, `not_contains`
209
213
  - `containsall`, `containsany`
210
- - `startswith`, `istartswith`
211
- - `endswith`, `iendswith`
214
+ - `startswith`, `endswith`
212
215
  - `like`, `ilike`
213
- - `match`, `regex`, `iregex`
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. Aggregations
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
- ### 8. Model Signals
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.0 | 1.0.8 | ✅ Tested |
359
- | 2.5.x | 1.0.8 | ✅ Compatible |
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 | 📋 Next |
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 | v0.6.0 | ✅ |
406
- | FETCH clause | v0.6.0 | ✅ |
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
  ![Python](https://img.shields.io/badge/python-3.11%2B-blue)
4
- ![SurrealDB](https://img.shields.io/badge/SurrealDB-2.6.0-purple)
4
+ ![SurrealDB](https://img.shields.io/badge/SurrealDB-2.6.3-purple)
5
5
  ![SDK](https://img.shields.io/badge/SDK-Official%201.0.8-green)
6
6
  ![License](https://img.shields.io/badge/license-MIT-blue)
7
7
  [![codecov](https://codecov.io/gh/EulogySnowfall/SurrealDB-ORM-lite/graph/badge.svg)](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`, `icontains`, `not_contains`
162
+ - `contains`, `not_contains`
159
163
  - `containsall`, `containsany`
160
- - `startswith`, `istartswith`
161
- - `endswith`, `iendswith`
164
+ - `startswith`, `endswith`
162
165
  - `like`, `ilike`
163
- - `match`, `regex`, `iregex`
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. Aggregations
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
- ### 8. Model Signals
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.0 | 1.0.8 | ✅ Tested |
309
- | 2.5.x | 1.0.8 | ✅ Compatible |
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 | 📋 Next |
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 | v0.6.0 | ✅ |
356
- | FETCH clause | v0.6.0 | ✅ |
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 | ✅ |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "surreal-orm-lite"
3
- version = "0.5.0"
3
+ version = "0.6.2"
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"
@@ -1,4 +1,4 @@
1
- __version__ = "0.5.0"
1
+ __version__ = "0.6.2"
2
2
 
3
3
  from .aggregations import Aggregation, Avg, Count, Max, Min, Sum
4
4
  from .connection_manager import SurrealDBConnectionManager
@@ -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 = dict(kwargs.items())
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