surreal-orm-lite 0.31.2__tar.gz → 0.32.1__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 (48) hide show
  1. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/CHANGELOG.md +189 -0
  2. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/PKG-INFO +270 -15
  3. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/README.md +269 -14
  4. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/pyproject.toml +1 -1
  5. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/__init__.py +1 -1
  6. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/define_parser.py +38 -1
  7. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/live.py +5 -0
  8. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/model_base.py +293 -6
  9. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/model_generator.py +49 -15
  10. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/q.py +18 -2
  11. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/query_set.py +32 -11
  12. surreal_orm_lite-0.32.1/src/surreal_orm_lite/schema.py +1203 -0
  13. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/subquery.py +18 -1
  14. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/utils.py +28 -6
  15. surreal_orm_lite-0.31.2/src/surreal_orm_lite/schema.py +0 -437
  16. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/.gitignore +0 -0
  17. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/LICENSE +0 -0
  18. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/Makefile +0 -0
  19. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/_sdk.py +0 -0
  20. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/aggregations.py +0 -0
  21. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/auth.py +0 -0
  22. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/cache.py +0 -0
  23. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/changefeed.py +0 -0
  24. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/concurrency.py +0 -0
  25. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/connection_config.py +0 -0
  26. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/connection_manager.py +0 -0
  27. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/constants.py +0 -0
  28. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/decoding.py +0 -0
  29. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/edge.py +0 -0
  30. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/embedded.py +0 -0
  31. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/encoding.py +0 -0
  32. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/enum.py +0 -0
  33. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/exceptions.py +0 -0
  34. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/functions.py +0 -0
  35. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/geo.py +0 -0
  36. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/introspection.py +0 -0
  37. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/model_auth.py +0 -0
  38. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/py.typed +0 -0
  39. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/routing.py +0 -0
  40. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/schema_state.py +0 -0
  41. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/signals.py +0 -0
  42. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/surql_literal.py +0 -0
  43. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/testing/__init__.py +0 -0
  44. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/testing/database.py +0 -0
  45. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/testing/fixtures.py +0 -0
  46. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/time_travel.py +0 -0
  47. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/transaction.py +0 -0
  48. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/types.py +0 -0
@@ -5,6 +5,195 @@ 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.32.1] - 2026-10-20
9
+
10
+ Two follow-ups to the materialized views of v0.32.0 (#286): a function SurrealDB lists as unusable
11
+ in a view is allowed inside the argument of each of several aggregates, and a chain of views can
12
+ be removed outer view first in one buffered transaction. No new public API.
13
+
14
+ ### Fixed
15
+
16
+ - **A listed function inside the argument of each of several aggregates passes the
17
+ `view_query` check** (#286). Since v0.32.0, a grouped view whose projection calls
18
+ `array::distinct()`, `array::group()`, `array::join()`, `math::median()` or `vector::sum()`
19
+ outside an aggregate's argument raises `ValueError` at the `class` statement, but only an element
20
+ that is one whole aggregate had its calls skipped: an expression combining several aggregates,
21
+ such as `math::sum(amount) + math::sum(array::len(array::distinct(tags)))`, was refused on every
22
+ line. Such an expression now passes the check; a listed function beside or around an aggregate
23
+ (`math::sum(amount) + array::len(array::distinct(tags))`) is still refused, with the same
24
+ message. SurrealDB 3.x keeps such a view up to date; on 2.x and the embedded engine
25
+ `define_view()` still refuses an expression around aggregates before any DDL, as in v0.32.0.
26
+ `generate_models_from_db()` follows the check: such a view table is now generated as a view
27
+ model instead of a class carrying a `# View: AS …` comment.
28
+ - **`remove_view()` accepts a chain of views removed outer view first in one buffered
29
+ transaction** (#286). On SurrealDB 2.x and the embedded engine, `remove_view()` refuses with
30
+ `SurrealDbError`, before any DDL, to remove a view that another view reads, and it read committed
31
+ state only, so this block was refused although the server accepts both statements at commit:
32
+
33
+ ```python
34
+ async with SurrealDBConnectionManager.transaction() as tx:
35
+ await OrderStatsTotals.remove_view(tx=tx) # the outer view, queued
36
+ await OrderStats.remove_view(tx=tx) # refused: the outer view was still counted
37
+ ```
38
+
39
+ In a buffered transaction (SurrealDB 2.x, HTTP, the embedded engine), a view whose
40
+ `remove_view(tx=tx)` the same block has already queued is no longer counted, unless the block
41
+ names it again afterwards (a `define_view(tx=tx)` of it). A view removed in another transaction,
42
+ or by a `REMOVE TABLE` you add to the block yourself (`tx.add(…)`), is still counted: the check
43
+ errs on the side of the refusal. SurrealDB 3.x decides by itself, as before.
44
+
45
+ ### Notes
46
+
47
+ - README section 33 and the "ORM behaviour: SurrealDB 2.x vs 3.x" row on removing a view that
48
+ another view reads say so.
49
+ - Server pins unchanged: tested against SurrealDB 3.3.4 and 2.7.0 and on the embedded engine,
50
+ plus 3.1.5 for backward compatibility.
51
+
52
+ ## [0.32.0] - 2026-10-20
53
+
54
+ Materialized views as read-only models: a model declares the `SELECT` that SurrealDB computes a
55
+ table from (`DEFINE TABLE … AS SELECT`), applies it idempotently with `define_view()` and reads it
56
+ like any model, while every ORM write of it is refused. The same code runs on SurrealDB 2.x, 3.x
57
+ and the embedded engine; a grouped view that SurrealDB 2.x would compute wrongly is refused before
58
+ any DDL there. The third minor of Tier 3.
59
+
60
+ ### Added
61
+
62
+ - **`SurrealConfigDict(view_query="SELECT …")`**: the model reads a materialized view, a table
63
+ SurrealDB computes from that query and keeps up to date as the tables it selects from change.
64
+ The query is one `SELECT … FROM <table> [WHERE …] [GROUP BY … | GROUP ALL]` (a final `;` is
65
+ dropped), checked when the class is defined: a mistake raises `TypeError` or `ValueError` at the
66
+ `class` statement. `Model.get_view_query()` returns it as checked. `Model.view_ddl()` renders
67
+ ``DEFINE TABLE OVERWRITE `<table>` AS (<query>);`` without a connection,
68
+ `await Model.define_view()` applies it on the model's connection (idempotent: run it at
69
+ start-up) and returns it, and `await Model.remove_view()` writes `REMOVE TABLE IF EXISTS`.
70
+ `overwrite=False` writes `IF NOT EXISTS`, `missing_ok=False` raises `SurrealDbNotFoundError` for
71
+ a missing view, and both appliers take `tx=`. A subclass inherits `view_query`;
72
+ `view_query=None` makes it an ordinary model.
73
+
74
+ ```python
75
+ class OrderStats(BaseSurrealModel):
76
+ model_config = SurrealConfigDict(
77
+ table_name="order_stats",
78
+ view_query="SELECT status, count() AS total, math::sum(amount) AS revenue FROM orders GROUP BY status",
79
+ )
80
+
81
+ id: RecordID | None = None
82
+ status: str
83
+ total: int
84
+ revenue: float
85
+
86
+
87
+ await OrderStats.define_view() # idempotent (OVERWRITE)
88
+ paid = await OrderStats.objects().get(["paid"]) # a grouped view's id: its group values
89
+ await paid.merge(total=0) # TypeError: Cannot modify materialized view …
90
+ ```
91
+
92
+ - **Every write of a view model raises `TypeError`** before any signal, transaction or request:
93
+ `save()`, `upsert()`, `update()`, `merge()`, `patch()`, `delete()`, the atomic helpers
94
+ (`atomic_append()` … `atomic_remove_many()`), and the QuerySet's `bulk_create()`,
95
+ `bulk_update()`, `bulk_delete()`, `patch()`, `update_or_create()`, `get_or_create()` (even when
96
+ the record exists) and `delete_table()`. The message starts with the full SurrealDB-ORM's
97
+ `Cannot modify materialized view '<table>'` and names the call: write to the tables the view
98
+ selects from instead. Reads, `refresh()`, the relation helpers, `define_events()` and
99
+ `remove_event()` stay allowed, since they do not write the view's table.
100
+ - **Array record ids**: `get(["paid"])` and `filter(id=["paid"])` address a grouped view's record
101
+ (`order_stats:['paid']`, the array of its group values); a `GROUP ALL` view's id is `[]`, read
102
+ with `first()`.
103
+ - README section 33, "Materialized views (`view_query`, `define_view()`)" (declaring a view,
104
+ applying and removing, reading, refused writes, transactions, connections and the query cache,
105
+ SurrealDB 2.x and 3.x, security, a note for users of the full SurrealDB-ORM), a features row,
106
+ eight rows in the 2.x/3.x behaviour table and one in the embedded-engine table, the comparison
107
+ and roadmap rows, and the example notebook `examples/v0.32.0_materialized_views.ipynb`.
108
+
109
+ ### Changed
110
+
111
+ - **`generate_models_from_db()` writes a view model**: a view table becomes a class with
112
+ `view_query=…` in its `SurrealConfigDict` and `id: RecordID | None = None`, marked
113
+ `# Read-only materialized view`, instead of a class carrying a `# View: AS …` comment. A view the
114
+ lite cannot declare (an edge, a computed field, a query `view_query` refuses) keeps that comment.
115
+ README section 31 says so.
116
+ - **Cached reads of a view model are dropped by every ORM write** (README section 29): reads of a
117
+ view model, and reads whose subqueries read one, since a write to a source table changes the view
118
+ and the ORM does not know which tables a view reads. `define_view()` and `remove_view()` drop
119
+ cached reads like every write. Writes by raw SurrealQL or by another process are not seen, as for
120
+ every table.
121
+ - A list or tuple given as a record id (`get()`, `filter(id=...)`, `id__in` items,
122
+ `get_or_create()`/`update_or_create()` id criteria) is now an array record id; it used to be the
123
+ string of its `repr()`.
124
+
125
+ ### Notes
126
+
127
+ - **A view's query is trusted application code, never user input.** It is written into `AS (…)`
128
+ as given, since SurrealDB takes no bound parameter in a definition. Its structural check
129
+ (literals and quoted identifiers terminated, brackets balanced, no NUL, no SurrealQL comment
130
+ marker outside a literal, one `SELECT` and no `;` outside brackets) catches mistakes —
131
+ unbalanced brackets, a comment that would swallow the parenthesis the ORM appends, a second
132
+ statement where one SELECT is expected — and is not an injection defence. To filter on a value,
133
+ store it in a record or a `DEFINE PARAM` and read it from the query; never format it into the
134
+ text. The table name is checked as an identifier and quoted. A refusal names the view's table,
135
+ never the statement, and keeps the server's reason whole, which names a missing source table.
136
+ - **Grouped views on SurrealDB 2.x and the embedded engine**: those engines keep only whole
137
+ aggregates (`count()`, `math::sum()`, `math::mean()`, `math::min()`, `math::max()`,
138
+ `time::min()`, `time::max()`) and `GROUP BY` fields up to date, and compute anything else once
139
+ per source record. So `define_view()` raises `SurrealDbError` before any DDL there for
140
+ `math::stddev()` and `math::variance()`, an expression around an aggregate
141
+ (`math::sum(amount) / count()`), a field outside `GROUP BY` and outside an aggregate (3.x refuses
142
+ that one too) and a `GROUP BY` on an alias. SurrealDB 3.x keeps them all up to date.
143
+ - **Removing a view that another view reads**: SurrealDB 3.x refuses it, and `remove_view()`
144
+ passes the refusal on as `SurrealDbError`; on 2.x and the embedded engine `remove_view()` raises
145
+ `SurrealDbError` before any DDL, since those engines would turn each view reading it into an
146
+ ordinary table that keeps its last records and no longer updates. Remove the outer views first.
147
+ - **The table guard**: `define_view()` never turns a table that holds records into a view, since
148
+ SurrealDB would delete them, and `define_view(overwrite=False)` refuses any ordinary table, which
149
+ `IF NOT EXISTS` would silently keep. An ordinary table without records, as `define_events()` or,
150
+ on 2.x, a `watch()` run before `define_view()` leaves it, becomes the view. `remove_view()`
151
+ refuses a table that is not a view. The guard is a check-then-act on committed state, not a
152
+ lock.
153
+ - **SurrealDB 2.x and 3.x**: raw writes to a view (raw SurrealQL, not the ORM) are accepted by 2.x
154
+ and 3.1, which change the view's records, and refused by 3.2 and later; a sum of integers is an
155
+ `int` and a mean a `Decimal` on 2.x, both `float` on 3.x (declare them `float`); `count()` of an
156
+ array field adds the arrays' lengths on 2.x and counts the records whose array is not empty on
157
+ 3.x; live queries and events see an emptied group as an `UPDATE` then a `DELETE` on 2.x, a
158
+ `DELETE` on 3.x. The embedded engine behaves as a 2.x server, without live queries.
159
+ - **A source write missing an aggregated field is refused** by SurrealDB on every line
160
+ ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)): write
161
+ `math::sum(amount ?? 0)`.
162
+ - **`define_view()` recomputes the view** (`OVERWRITE`): each call costs one pass over the source
163
+ and drops the events, fields and indexes defined on the view, so run `define_events()` after
164
+ `define_view()`. A view is defined without a `PERMISSIONS` clause, so SurrealDB applies its
165
+ default `PERMISSIONS NONE`: a record user reads nothing from it, and `define_view()` resets
166
+ permissions set by hand. Applying needs a privileged system user (root, or a namespace or
167
+ database `OWNER` or `EDITOR`): run `define_view()` at start-up or deployment.
168
+ - **The ORM's write refusal guards against mistakes and is not an access control**: on 2.x and
169
+ 3.1 the server accepts writes to a view from any session allowed to write the table.
170
+ - On a view model, `filter(id__in=...)` is sent as `$ids CONTAINS id`: SurrealDB 3.x answers no
171
+ record to `id IN` on a view table, while `CONTAINS` is right on every line. Write raw SurrealQL
172
+ over a view the same way.
173
+ - On SurrealDB 3.x, `live()` takes a view instance's id from the notification's record, since the
174
+ server's view notifications carry none; `watch()` yields the envelopes as received (read
175
+ `record`). A live query on a view filtered on `id` never notifies on 3.x: filter on the view's
176
+ fields.
177
+ - On SurrealDB 3.1, tested for backward compatibility only, the server refuses to redefine a view
178
+ that holds records: `define_view()` raises `SurrealDbError` ("… The key being inserted already
179
+ exists") and the view stays as it was, where 3.2 and later, 2.x and the embedded engine
180
+ recompute it. On 3.1, call `remove_view()` before `define_view()`.
181
+ - **Differences from the full SurrealDB-ORM**: the same `view_query` key and `_is_view()`, plus
182
+ `get_view_query()`; the same `TypeError` first words on every write path (full ORM: `save()`,
183
+ `update()`, `merge()`, `delete()` only); `define_view()`, `remove_view()` and `view_ddl()` where
184
+ the full ORM applies a view through migrations or `define_table()`. The documented
185
+ `MaterializedView` base class with `source_query` is not shipped by the full ORM either: declare
186
+ `view_query` on a `BaseSurrealModel`.
187
+ - **Deferred**: applying a view through migrations (`CreateTable`/`AlterTable`) and
188
+ `define_table()`, which will accept a view model and apply it through `view_ddl()`, come with
189
+ v0.37.0; table permissions for record users and a view query built from a QuerySet come later.
190
+ - The 0.31.0 notes placed the `SurrealConfigDict` keys that generated classes stand in for with
191
+ comments in v0.32.0–v0.34.0: `view_query` arrives with this release, and README section 31 now
192
+ places the others (`table_type`, `permissions`, `changefeed`, `relation_in`, `relation_out`,
193
+ `enforced`) in later minors.
194
+ - **Server pins unchanged**: tested against SurrealDB 3.3.4 and 2.7.0 and on the embedded engine,
195
+ plus 3.1.5 for backward compatibility.
196
+
8
197
  ## [0.31.2] - 2026-10-19
9
198
 
10
199
  Transaction errors give the server's reason and the failed statement's position, without the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: surreal-orm-lite
3
- Version: 0.31.2
3
+ Version: 0.32.1
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
@@ -266,6 +266,7 @@ embedded engine refuse one above `4294967295` (`2**32 - 1`), SurrealDB 3.x reads
266
266
  | Multiple databases (named connections, `using()`) | ✅ |
267
267
  | Schema introspection (`DatabaseIntrospector`, `generate_models_from_db()`) | ✅ |
268
268
  | Server-side events & function DDL (`Event`, `define_function()`) | ✅ |
269
+ | Materialized views (`view_query`, `define_view()`, read-only) | ✅ |
269
270
 
270
271
  ### Supported Filter Lookups
271
272
 
@@ -4299,15 +4300,18 @@ class Person(BaseSurrealModel):
4299
4300
  temporary file, and a symbolic link is replaced by the file, its target never written. `overwrite=False` refuses an
4300
4301
  existing path with `FileExistsError` before reading anything, and creates the file exclusively.
4301
4302
  - One class per table: a `BaseSurrealModel`, or a `BaseSurrealEdge` for a `TYPE RELATION` table (which declares no
4302
- `id`, `in_` or `out`: the base class provides them). Its `model_config` holds the `table_name`, `connection=` when
4303
- the schema was read from another connection than `"default"`, then `server_fields` and `extra="allow"` (below). A
4304
- model declares `id: str | None = None` first, then one line per field, in codepoint order of the columns.
4303
+ `id`, `in_` or `out`: the base class provides them). Its `model_config` holds the `table_name`, `view_query` for a
4304
+ view table (since v0.32.0: a materialized view model, [section 33](#33-materialized-views-view_query-define_view)),
4305
+ `connection=` when the schema was read from another connection than `"default"`, then `server_fields` and
4306
+ `extra="allow"` (below). A model declares `id: str | None = None` first (`id: RecordID | None = None` for a view: a
4307
+ grouped view's ids are arrays), then one line per field, in codepoint order of the columns.
4305
4308
  - A class is named after its table in PascalCase, without singularisation (`game_tables` → `GameTables`); a name
4306
4309
  that is taken — a Python keyword, a name the module imports, another table's class — gets `Model` appended (`range`
4307
- → `RangeModel`). The table's comment, relation ends, view query, change feed (`# Changefeed: 1d`), permissions
4310
+ → `RangeModel`). The table's comment, relation ends, change feed (`# Changefeed: 1d`), permissions
4308
4311
  (`# Permissions: FOR select WHERE …, FOR create, update FULL, FOR delete NONE`, left out when all four are `NONE`,
4309
4312
  a table's default), indexes and events are comments of its class, and the module's header lists every table or
4310
- field left out, with the reason.
4313
+ field left out, with the reason. A view model's class says `# Read-only materialized view`; a view the lite cannot
4314
+ declare — an edge, a computed field, a query `view_query` refuses — keeps its query as a `# View: AS …` comment.
4311
4315
  - The source is safe to import: names, comments and expressions read from the database reach it only as escaped
4312
4316
  string literals, single-line ASCII comments and identifiers the generator builds, so a schema cannot inject code
4313
4317
  into it. It holds no timestamp, server address, namespace or database name, and no
@@ -4402,8 +4406,8 @@ class Person(BaseSurrealModel):
4402
4406
  `DEFINE FIELD OVERWRITE … VALUE …`, without their `TYPE`, `ASSERT` or `PERMISSIONS` (the module's docstring says so).
4403
4407
  - Long lines: the `model_config` line is wrapped the way `ruff format` keeps it — one keyword per line once it exceeds
4404
4408
  128 columns, and the `server_fields` names one per line once their own line does. Other long lines — long names, a
4405
- long `Literal` union — are left on one line for your formatter. When every line fits in 128 columns, the module
4406
- passes `ruff check` and `ruff format --check` at that line length.
4409
+ long `Literal` union, a long view query (`view_query="…"`) — are left on one line for your formatter. When every
4410
+ line fits in 128 columns, the module passes `ruff check` and `ruff format --check` at that line length.
4407
4411
 
4408
4412
  #### SurrealDB 2.x and 3.x
4409
4413
 
@@ -4483,10 +4487,11 @@ reads, stores, logs or writes them.
4483
4487
  ORM's connection name, which routing, reconnects and the generated models' `connection=` key need (full ORM: an
4484
4488
  `HTTPConnection`).
4485
4489
  - **Generated models use comments** where the full ORM writes
4486
- `SurrealConfigDict(permissions=…, changefeed=…, view_query=…, relation_in=…, relation_out=…, enforced=…)`
4487
- (`# Permissions: …`, `# Changefeed: …`, `# View: AS …`, `# IN … OUT … ENFORCED`), and the base class where it writes
4488
- `table_type=` (`BaseSurrealEdge` for a relation table): those keys arrive in the lite's `SurrealConfigDict` with
4489
- v0.32.0–v0.34.0. `READONLY` is a comment too (full ORM: `Field(readonly=True)`).
4490
+ `SurrealConfigDict(permissions=…, changefeed=…, relation_in=…, relation_out=…, enforced=…)` (`# Permissions: …`,
4491
+ `# Changefeed: …`, `# IN … OUT … ENFORCED`), and the base class where it writes `table_type=` (`BaseSurrealEdge`
4492
+ for a relation table): those keys arrive in the lite's `SurrealConfigDict` with later minors. A view is generated
4493
+ with `view_query=…` since v0.32.0, as in the full ORM (a `# View: AS …` comment when the lite cannot declare it: an
4494
+ edge, a computed field, a query it refuses). `READONLY` is a comment too (full ORM: `Field(readonly=True)`).
4490
4495
  - **`RecordID` instead of `ForeignKey`/`ReferencesField`**; no `VectorField` or `Encrypted`; `computed()` instead
4491
4496
  of `Computed()`; `Duration`, `UUID`, `Decimal` and the geometry classes instead of `str`, `dict` and `float`;
4492
4497
  nested fields become sub-models (full ORM: dotted attribute lines).
@@ -4757,6 +4762,246 @@ system user: run `define_events()` and `define_function()` at start-up or deploy
4757
4762
  - **The migration operations** (`DefineEvent`, `RemoveEvent`, `RawSQL`) and the removal of the events a model no
4758
4763
  longer declares **come with migrations (v0.37.0)**.
4759
4764
 
4765
+ ### 33. Materialized views (`view_query`, `define_view()`)
4766
+
4767
+ Since v0.32.0 a model can read a **materialized view**: a table SurrealDB computes from a `SELECT` and keeps up to
4768
+ date as its source table changes — counts, sums, filtered copies. The view is declared on the model with
4769
+ `view_query`, applied idempotently with `define_view()` and read like any model, and every write of the model is
4770
+ refused: SurrealDB computes the view's records itself. The same code runs on SurrealDB 2.x, 3.x and the embedded
4771
+ engine, except where the [2.x/3.x table](#orm-behaviour-surrealdb-2x-vs-3x) says otherwise. The examples assume a
4772
+ configured connection: they run as written on the embedded engine (`mem://`).
4773
+
4774
+ ```python
4775
+ from surrealdb import RecordID
4776
+
4777
+ from surreal_orm_lite import BaseSurrealModel, SurrealConfigDict
4778
+
4779
+
4780
+ class Order(BaseSurrealModel):
4781
+ model_config = SurrealConfigDict(table_name="orders")
4782
+
4783
+ id: str | None = None
4784
+ status: str
4785
+ amount: float
4786
+ customer: str | None = None
4787
+
4788
+
4789
+ class OrderStats(BaseSurrealModel):
4790
+ model_config = SurrealConfigDict(
4791
+ table_name="order_stats",
4792
+ view_query="SELECT status, count() AS total, math::sum(amount) AS revenue FROM orders GROUP BY status",
4793
+ )
4794
+
4795
+ id: RecordID | None = None
4796
+ status: str
4797
+ total: int
4798
+ revenue: float
4799
+
4800
+
4801
+ print(OrderStats.view_ddl())
4802
+ # DEFINE TABLE OVERWRITE `order_stats` AS (SELECT status, count() AS total, math::sum(amount) AS revenue FROM orders GROUP BY status);
4803
+
4804
+ await Order(id="o1", status="paid", amount=10.0, customer="ada").save() # the source table exists first
4805
+ await OrderStats.define_view() # idempotent (OVERWRITE): run it at start-up
4806
+
4807
+ await Order(id="o2", status="paid", amount=5.5, customer="bob").save()
4808
+ await Order(id="o3", status="new", amount=1.0, customer="ada").save()
4809
+ for stats in await OrderStats.objects().order_by("status").exec():
4810
+ print(stats.status, stats.total, stats.revenue)
4811
+ # new 1 1.0
4812
+ # paid 2 15.5
4813
+
4814
+ paid = await OrderStats.objects().get(["paid"])
4815
+ print(paid.id, paid.total)
4816
+ # order_stats:['paid'] 2
4817
+
4818
+ try:
4819
+ await paid.merge(total=0)
4820
+ except TypeError as error:
4821
+ print(error)
4822
+ # Cannot modify materialized view 'order_stats': OrderStats.merge() writes to it, and SurrealDB computes a view's records from its view_query. Write to the tables it selects from instead.
4823
+ ```
4824
+
4825
+ #### Declaring a view
4826
+
4827
+ ```python
4828
+ class PaidOrder(BaseSurrealModel):
4829
+ model_config = SurrealConfigDict(
4830
+ table_name="paid_orders", view_query="SELECT customer, amount FROM orders WHERE status = 'paid'"
4831
+ )
4832
+
4833
+ id: RecordID | None = None
4834
+ customer: str | None = None
4835
+ amount: float
4836
+
4837
+
4838
+ await PaidOrder.define_view()
4839
+ print([(str(order.id), order.amount) for order in await PaidOrder.objects().order_by("amount").exec()])
4840
+ # [('paid_orders:o2', 5.5), ('paid_orders:o1', 10.0)]
4841
+ ```
4842
+
4843
+ - `view_query` is one `SELECT … FROM <table> [WHERE …] [GROUP BY … | GROUP ALL]` (a final `;` is dropped), checked
4844
+ when the class is defined: a mistake raises `TypeError` or `ValueError` at the `class` statement. It takes no
4845
+ `ORDER BY`, `LIMIT` or `FETCH` (SurrealDB refuses them in a view), and it is not a `SELECT VALUE`: a view stores
4846
+ records. The model's `get_view_query()` returns it as checked.
4847
+ - **A grouped view** selects its `GROUP BY` fields (or expressions of them) and aggregates SurrealDB keeps up to
4848
+ date: `count()`, `math::sum()`, `math::mean()`, `math::min()`, `math::max()`, `time::min()`, `time::max()` (and
4849
+ `math::stddev()`, `math::variance()` on 3.x), whatever their argument (`math::sum(amount ?? 0)`). `math::median()`,
4850
+ `array::group()`, `array::distinct()`, `array::join()` and `vector::sum()` are refused (inside an aggregate's
4851
+ argument they are allowed). Since v0.32.1 that holds when an expression combines several aggregates too
4852
+ (`math::sum(amount) + math::sum(array::len(array::distinct(tags)))`); beside an aggregate
4853
+ (`math::sum(amount) + array::len(array::distinct(tags))`) they are still refused. On SurrealDB 3.x an expression
4854
+ around an aggregate (`math::sum(amount) / count()`) and a `GROUP BY` on an alias work; on 2.x and the embedded
4855
+ engine `define_view()` refuses them (see "SurrealDB 2.x and 3.x" below).
4856
+ - Every source write runs the view's query for that record: when it fails — a function given a value of the wrong
4857
+ type, an aggregated field missing
4858
+ ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)) — SurrealDB refuses the write, on
4859
+ every line.
4860
+ - **Ids**: a plain view keeps the source records' ids (`paid_orders:o1`); a grouped view's id is the array of its
4861
+ group values (`order_stats:['paid']`), and `GROUP ALL` gives `[]`. Declare `id: RecordID | None = None` and look a
4862
+ group up with `get(["paid"])` or `filter(id=["paid"])`; read a `GROUP ALL` view with `first()`. Declare sums and
4863
+ means `float`: SurrealDB 2.x and the embedded engine compute an `int` sum of integers and a `Decimal` mean, which a
4864
+ `float` field reads as a float.
4865
+ - A view model cannot be an edge (`BaseSurrealEdge`), use `AuthenticatedUserMixin` or declare computed fields: each
4866
+ raises `TypeError` at the `class` statement. A subclass inherits `view_query` and defines the view on its own
4867
+ table; `view_query=None` makes it an ordinary model. The `table_name` is any identifier the ORM reads (a SurrealQL
4868
+ keyword such as `select` is defined, but not read by the QuerySet, as for every model).
4869
+
4870
+ #### Applying and removing
4871
+
4872
+ - `define_view()` writes `DEFINE TABLE OVERWRITE … AS (…)`: SurrealDB fills the view from the source's records, and
4873
+ each call recomputes the view and drops the events, fields and indexes defined on it, so at start-up it costs one
4874
+ pass over the source. `overwrite=False` writes `IF NOT EXISTS` and keeps a view already defined, whatever its
4875
+ query. It returns the statement it applied; `view_ddl()` returns the statement without applying it.
4876
+ - The source table must exist (a record saved, or a `DEFINE TABLE`), else `SurrealDbError` naming it
4877
+ (`Can't apply view definition: 'order_stats' -> The table 'orders' does not exist`).
4878
+ - Redefining a view drops the events, fields and indexes defined on it: run `define_events()` after `define_view()`.
4879
+ - `remove_view()` writes `REMOVE TABLE IF EXISTS`, which removes the view and its records; with `missing_ok=False` a
4880
+ view that does not exist raises `SurrealDbNotFoundError`.
4881
+ - The table guard: when the model's table already exists as an ordinary table **without records** — SurrealDB
4882
+ creates one when `define_events()` or, on 2.x, a `watch()` runs before `define_view()` — `define_view()` turns it
4883
+ into the view. When it holds records, `define_view()` raises `SurrealDbError` before any DDL, because SurrealDB
4884
+ would delete them (the message gives the `raw_query("REMOVE TABLE …")` to run if they are not needed).
4885
+ `define_view(overwrite=False)` raises on any ordinary table, which `IF NOT EXISTS` would silently keep, and
4886
+ `remove_view()` raises on a table that is not a view.
4887
+ - A view may read another view: it is updated when the inner view changes, but not recomputed when the inner one is
4888
+ redefined.
4889
+ - **Remove the views that read another view first.** SurrealDB 3.x refuses to remove a table that a view reads — a
4890
+ source table or an inner view — and `remove_view()` passes that refusal on (`SurrealDbError`,
4891
+ `Cannot delete table … on which a view is defined`). On 2.x and the embedded engine `remove_view()` raises
4892
+ `SurrealDbError` before any DDL, because those engines would remove it and turn every view reading it into an
4893
+ ordinary table that keeps its last records and no longer updates; the ORM finds those views by their plain `FROM`
4894
+ targets. A raw `REMOVE TABLE` of a source table does that on 2.x and the embedded engine: such a former view then
4895
+ makes `define_view()` refuse (it holds records: run the `raw_query("REMOVE TABLE …")` the message gives) and
4896
+ `remove_view()` refuse (it is not a view).
4897
+
4898
+ #### Reading
4899
+
4900
+ - Every read works — filters, `order_by()`, aggregates, `get()`, `refresh()`, `Subquery`, `fetch()`, `using()`, and
4901
+ on WebSocket `live()`/`watch()`, which receive the records SurrealDB recomputes. `relate()` and `remove_relation()`
4902
+ from or to a view record write the edge table and are allowed.
4903
+ - On a view model, `filter(id__in=...)` is sent as `$ids CONTAINS id`: SurrealDB 3.x (3.1 and 3.3 measured) answers
4904
+ no record to `id IN` on a view table, while `CONTAINS` is right on every line; write raw SurrealQL over a view the
4905
+ same way.
4906
+ - `live()` instances carry the record id on both lines; `watch()` yields the server's envelopes as received: on 3.x
4907
+ their `result` has no `id`, read `record`.
4908
+ - On SurrealDB 3.x a live query on a view filtered on `id` never notifies (the server's view notifications carry no
4909
+ id); filter on the view's fields, e.g. `filter(status="paid")`.
4910
+
4911
+ #### Writes are refused
4912
+
4913
+ - Every ORM write of a view model raises `TypeError` before any signal, transaction or request: the instance
4914
+ methods `save()`, `upsert()`, `update()`, `merge()`, `patch()`, `delete()` and the atomic helpers
4915
+ `atomic_append()`, `atomic_remove()`, `atomic_set_add()`, `atomic_increment()`, `atomic_append_many()`,
4916
+ `atomic_set_add_many()`, `atomic_remove_many()`, and the QuerySet methods `bulk_create()`, `bulk_update()`,
4917
+ `bulk_delete()`, `patch()`, `update_or_create()`, `get_or_create()` (even when the record exists) and
4918
+ `delete_table()`. The message starts with the full ORM's `Cannot modify materialized view '<table>'` and names the
4919
+ call: write to the tables the view selects from instead.
4920
+ - `refresh()`, `relate()`, `remove_relation()`, `remove_all_relations()`, `define_events()` and `remove_event()` stay
4921
+ allowed: they do not write the view's table.
4922
+ - Raw SurrealQL (`raw_query()`, `QuerySet.query()`, the client's `query()`) is not checked: SurrealDB 3.2 and later
4923
+ refuse a write to a view, 2.x and 3.1 accept it and change the view's records.
4924
+
4925
+ #### Transactions, connections and the query cache
4926
+
4927
+ - `define_view()` and `remove_view()` take `tx=`. In a buffered transaction (SurrealDB 2.x, HTTP, the embedded
4928
+ engine) the statement is applied at commit, and the block's later source writes update the view; in an interactive
4929
+ one (WebSocket and SurrealDB 3.x) it is applied in the transaction, which reads the view and its own source writes
4930
+ (`objects(tx=tx)`), unseen outside the block until it commits. Either way, a block that raises leaves no view. The
4931
+ table guard and the 2.x checks run before anything is queued, on committed state; a refusal of a queued statement
4932
+ comes back when the block commits, as the transaction's own error.
4933
+ - A chain of views can be removed in one buffered block, outer view first (since v0.32.1): on 2.x and the embedded
4934
+ engine `remove_view(tx=tx)` does not count a view whose `remove_view(tx=tx)` the same block has already queued,
4935
+ unless the block names it again afterwards (a `define_view(tx=tx)` of it). A view removed in another transaction,
4936
+ or by a `REMOVE TABLE` you add to the block yourself, is still counted.
4937
+ - Both run on the model's connection (its `connection` key, or the `using()` block's); a `tx=` of another connection
4938
+ raises `SurrealDbError` before any request. A view and its source tables live in one database: give a view model
4939
+ its sources' connection.
4940
+ - The query cache ([section 29](#29-query-cache-cache-querycache)): reads of a view model, and reads whose subqueries
4941
+ read one, are dropped by every ORM write, since a write to a source table changes the view and the ORM does not know
4942
+ which tables a view reads. `define_view()` and `remove_view()` drop cached reads like every write. Writes by raw
4943
+ SurrealQL or by another process are not seen, as for every table.
4944
+ - Defining and removing need a system user allowed to define: root, or a namespace or database `OWNER` or `EDITOR`.
4945
+ On a server, a `VIEWER` or a record user gets `SurrealDbAuthenticationError`.
4946
+ - Applying DDL fires no signal, and neither do the records SurrealDB computes for a view.
4947
+
4948
+ #### SurrealDB 2.x and 3.x
4949
+
4950
+ - Raw writes to a view are accepted by 2.x and 3.1, which change the view's records, and refused by 3.2 and later.
4951
+ The ORM refuses its own writes on every line.
4952
+ - Grouped views: 2.x and the embedded engine keep up to date only whole aggregates and `GROUP BY` fields, and
4953
+ compute anything else once per source record. So `define_view()` raises `SurrealDbError` before any DDL there for
4954
+ `math::stddev()` and `math::variance()`, an expression around an aggregate (`math::sum(amount) / count()`), a field
4955
+ outside `GROUP BY` and outside an aggregate (3.x refuses that one too), and a `GROUP BY` on an alias (2.x would put
4956
+ every record in one group).
4957
+ - Value types: `int` sums and `Decimal` means on 2.x, `float` on 3.x; `count()` of an array field adds the arrays'
4958
+ lengths on 2.x, and counts the records whose array is not empty on 3.x.
4959
+ - Live queries and events see an emptied group as an `UPDATE` then a `DELETE` on 2.x, a `DELETE` on 3.x.
4960
+ - Removing a view, or a source table, that another view reads: 3.x refuses it; on 2.x `remove_view()` raises before
4961
+ any DDL, and a raw `REMOVE TABLE` leaves the views reading it as ordinary tables keeping their last records.
4962
+ - On every line, a source write missing an aggregated field is refused
4963
+ ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)): write `math::sum(amount ?? 0)`.
4964
+ - The [2.x/3.x table](#orm-behaviour-surrealdb-2x-vs-3x) lists these differences; the embedded engine behaves as a
4965
+ 2.x server.
4966
+
4967
+ #### Security
4968
+
4969
+ The table name is checked as an identifier and written quoted in every statement the ORM sends. The query is written
4970
+ into `AS (…)` as it is, since SurrealDB takes no bound parameter in a definition; when the class is defined the ORM
4971
+ checks its structure: string literals and quoted identifiers terminated, brackets balanced, no NUL character, no
4972
+ SurrealQL comment marker outside a literal, one `SELECT` and no `;` outside brackets. The check catches mistakes —
4973
+ unbalanced brackets, a comment that would swallow the parenthesis the ORM appends, a second statement where one
4974
+ SELECT is expected — and is not an injection defence: a view's query is trusted application code, never user input.
4975
+ To filter on a value, store it in a record or a `DEFINE PARAM` and read it from the query; never format it into the
4976
+ text. A refusal names the view's table, never the statement, and keeps the server's reason whole, which names the
4977
+ source table to create.
4978
+
4979
+ Destructive DDL is guarded: `define_view()` never turns a table that holds records into a view and never lets
4980
+ `IF NOT EXISTS` keep an ordinary table, and `remove_view()` never removes a table that is not a view nor, on 2.x and
4981
+ the embedded engine, a view that another view reads. The guard is a check-then-act on committed state, not a lock: a
4982
+ record written or a table created by another process between its reads and the DDL is not seen. The write refusal
4983
+ guards against mistakes and is not an access control: on 2.x and 3.1 the server accepts writes to a view from any
4984
+ session allowed to write the table. A view is defined without a `PERMISSIONS` clause, so SurrealDB applies its
4985
+ default `PERMISSIONS NONE`: a record user reads nothing from a view, and `define_view()` resets permissions set by
4986
+ hand on it. Applying a view needs a privileged system user: run `define_view()` at start-up or deployment, not on a
4987
+ record user's session.
4988
+
4989
+ #### Coming from the full SurrealDB-ORM
4990
+
4991
+ - **`view_query` is the same key** (`SurrealConfigDict(view_query="SELECT …")`); `get_view_query()` is added and
4992
+ `_is_view()` kept.
4993
+ - **`TypeError` with the same first words** (`Cannot modify materialized view '<table>'`), on every write path above
4994
+ (full ORM: `save()`, `update()`, `merge()`, `delete()` only).
4995
+ - **`define_view()`/`remove_view()`/`view_ddl()`** apply, remove and render the view, where the full ORM applies one
4996
+ through migrations (`CreateTable`/`AlterTable`) or `define_table()`. Those come with v0.37.0, whose
4997
+ `define_table()` accepts a view model and applies it through `view_ddl()`, so full-ORM code calling
4998
+ `define_table()` on a view works from then on.
4999
+ - **`AS (…)` as in the full ORM, the table name quoted** (``DEFINE TABLE OVERWRITE `order_stats` AS (…)``).
5000
+ - **Generated models write `view_query=`**, as the full ORM's do
5001
+ ([section 31](#31-schema-introspection-databaseintrospector-generate_models_from_db)).
5002
+ - **The documented `MaterializedView` base class with `source_query` is not shipped by the full ORM either**: declare
5003
+ `view_query` on a `BaseSurrealModel`.
5004
+
4760
5005
  ---
4761
5006
 
4762
5007
  ## Configuration Options
@@ -4879,7 +5124,7 @@ features introduced in SurrealDB 3.x. On 2.x the ORM degrades gracefully. Capabi
4879
5124
  listed behave the same on both lines. The 2.x column was measured on 2.6.5 and holds unchanged
4880
5125
  on 2.7.0. The 3.x column was measured on 3.2.4 and holds on 3.3.0, except where a row says
4881
5126
  otherwise. The full suite and every divergence below were re-verified on both new versions.
4882
- Rows added in v0.20.0 were measured directly on 2.7.0 and 3.3.0; rows added in v0.21.0 on 2.7.0 and 3.3.2, re-verified on 3.3.3, with 3.1.5 where it differs; rows added in v0.22.0 on 2.7.0 and 3.3.3, with 3.1.5 where it differs; rows added in v0.23.0 on 2.7.0 and 3.3.4, with 3.1.5 where it differs; rows added in v0.24.0 on 2.7.0 and 3.3.4, identical on 3.1.5; rows added in v0.26.0 on 2.7.0 and 3.3.4, in memory and on versioned storage, identical on 3.1.5; rows added in v0.27.0 on 2.7.0 and 3.3.4, with 3.1.5 where it differs; rows added in v0.28.0 on 2.7.0 and 3.3.4, identical on 3.1.5; the rows added in v0.28.1 on 2.7.0 and 3.3.4, identical on 3.1.5; rows added in v0.29.0 on 2.7.0 and 3.3.4 in one process, identical on 3.1.5. The row added in v0.29.2 was measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5. Rows added in v0.30.0 were measured on 2.7.0, 3.3.4 and the embedded engine, with 3.1.5 where it differs. The row added in v0.30.3 was measured on 2.7.0 and 3.3.4, over WebSocket and HTTP, and on the embedded engine, identical on 3.1.5. The row added in v0.30.7 was measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5: its parser refuses a statement that nests six objects, and `update()` nests at most four. Rows added in v0.31.0 were measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5. The row added in v0.31.1 was measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5.
5127
+ Rows added in v0.20.0 were measured directly on 2.7.0 and 3.3.0; rows added in v0.21.0 on 2.7.0 and 3.3.2, re-verified on 3.3.3, with 3.1.5 where it differs; rows added in v0.22.0 on 2.7.0 and 3.3.3, with 3.1.5 where it differs; rows added in v0.23.0 on 2.7.0 and 3.3.4, with 3.1.5 where it differs; rows added in v0.24.0 on 2.7.0 and 3.3.4, identical on 3.1.5; rows added in v0.26.0 on 2.7.0 and 3.3.4, in memory and on versioned storage, identical on 3.1.5; rows added in v0.27.0 on 2.7.0 and 3.3.4, with 3.1.5 where it differs; rows added in v0.28.0 on 2.7.0 and 3.3.4, identical on 3.1.5; the rows added in v0.28.1 on 2.7.0 and 3.3.4, identical on 3.1.5; rows added in v0.29.0 on 2.7.0 and 3.3.4 in one process, identical on 3.1.5. The row added in v0.29.2 was measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5. Rows added in v0.30.0 were measured on 2.7.0, 3.3.4 and the embedded engine, with 3.1.5 where it differs. The row added in v0.30.3 was measured on 2.7.0 and 3.3.4, over WebSocket and HTTP, and on the embedded engine, identical on 3.1.5. The row added in v0.30.7 was measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5: its parser refuses a statement that nests six objects, and `update()` nests at most four. Rows added in v0.31.0 were measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5. The row added in v0.31.1 was measured on 2.7.0, 3.3.4 and the embedded engine, identical on 3.1.5. Rows added in v0.32.0 were measured on 2.7.0, 3.3.4 and the embedded engine; 3.1.5 behaves as 3.3.4 but accepts raw writes to a view.
4883
5128
  The SDK's in-process engine has a table of its own: [ORM behaviour: the embedded engine](#orm-behaviour-the-embedded-engine).
4884
5129
 
4885
5130
  | ORM capability | SurrealDB 2.6.x / 2.7.x | SurrealDB 3.2.x / 3.3.x | Since |
@@ -5007,6 +5252,14 @@ The SDK's in-process engine has a table of its own: [ORM behaviour: the embedded
5007
5252
  | Event and function definitions read back (`EventState`, `FunctionState`) | a block on several lines, each statement ending with `;`; `option<T>` parameters as written | a block on one line, no `;` after a single statement; optional parameters rendered with `none` first | v0.31.0 |
5008
5253
  | `define_function()`, `remove_function()` and `call_function()` | same on both lines: the next `call_function()` sees a redefinition on every connection of this process to the same namespace and database, whatever its URL | the same | v0.31.0 |
5009
5254
  | Reading a datetime before year 1 or after year 9999 (SurrealDB stores years -262143 to +262142) | its RFC 3339 text, as `<string>` gives it (`"+10000-01-01T00:00:00Z"`): an untyped field holds it, a `datetime` field refuses it (`ValidationError`) | same on both lines | v0.31.1 |
5255
+ | Writes to a materialized view (`save()`, `merge()`, `delete()`, bulk writes … of a `view_query` model) | `TypeError` before any request; a raw write is accepted by the server and changes the view's records | `TypeError` before any request; the server refuses raw writes too (3.2 and later; 3.1 accepts them) | v0.32.0 |
5256
+ | Aggregates in a grouped view (`view_query` with `GROUP BY` or `GROUP ALL`) | `count()`, `math::sum()`, `math::mean()`, `math::min()`, `math::max()`, `time::min()`, `time::max()`; `math::stddev()`, `math::variance()`: `SurrealDbError` before any DDL | the same, plus `math::stddev()` and `math::variance()` | v0.32.0 |
5257
+ | An expression around an aggregate in a grouped view (`math::sum(amount) / count()`, `math::round(math::mean(amount))`, `count() * 2`) | `SurrealDbError` before any DDL: the server would compute it once per source record (a wrong `count()`, or every source write refused) | kept up to date | v0.32.0 |
5258
+ | A grouped view selecting a field outside `GROUP BY` and outside an aggregate (`amount`, `math::percentile(amount, 50)`, `*`) | `SurrealDbError` before any DDL: the server would keep the last source record's value, or refuse every source write | `SurrealDbError` when the view is defined (the server refuses it) | v0.32.0 |
5259
+ | A grouped view whose `GROUP BY` names the alias of a computed field (`time::month(at) AS month` … `GROUP BY month`) | `SurrealDbError` before any DDL: the server would put every source record in one group | one group per computed value | v0.32.0 |
5260
+ | Values a view computes (`math::sum()`, `math::mean()`, `count()` of an array) | a sum of integers is an `int`, a mean a `Decimal`; `count(tags)` of an array field adds the arrays' lengths | both are `float`; `count(tags)` counts the records whose array is not empty | v0.32.0 |
5261
+ | Live queries and events on a view (a group emptied by a delete; a live query filtered on `id`) | an emptied group: an `UPDATE` with the emptied values (a `count()` of 0), then a `DELETE`; a filter on `id` notifies | an emptied group: a `DELETE`; a filter on `id` never notifies (the view's notifications carry no `id`): filter on the view's fields | v0.32.0 |
5262
+ | Removing a materialized view, or a source table, that another view reads (`remove_view()`, `REMOVE TABLE`) | `remove_view()` raises `SurrealDbError` before any DDL, unless its transaction removes those views first; a raw `REMOVE TABLE` makes them ordinary tables, last records kept | `SurrealDbError` from the server (`Cannot delete table … on which a view is defined`): remove the views that read it first | v0.32.0 |
5010
5263
  | Full suite and every divergence in this table, re-run on SurrealDB 2.7.0 and 3.3.0 | 2.7.0 identical to 2.6.5 (same passes, same 3.x-only skips) | 3.3.0 identical to 3.2.4, except the system-user signin row | v0.19.1 |
5011
5264
 
5012
5265
  > **Note on record IDs**: A record loaded from the database has its `id` field set to a native `surrealdb.RecordID` object, not a plain string. Use `model.get_raw_id()` to obtain the bare identifier string (e.g. `"alice"`), or compare directly with `model.id == RecordID("User", "alice")`. In-memory instances you construct yourself retain whatever value you assign.
@@ -5040,6 +5293,7 @@ Capabilities not listed behave as on a 2.x server.
5040
5293
  | Several connections (`add_connection()`) | `mem://`: each name is a database of its own; an on-disk store (`surrealkv://`, `file://`) is open under one name at a time — a second name raises `SurrealDbConnectionError` | each name opens its own clients; two databases on one server are isolated | v0.29.0 |
5041
5294
  | Schema introspection (`DatabaseIntrospector`) | reads like a 2.x server (core 2.3.10): same state and same generated model types as on the servers; the anonymous owner session may run `INFO`; `COUNT` indexes cannot be defined | need a system user (root, namespace or database user) | v0.30.0 |
5042
5295
  | Server-side events and stored functions (v0.31.0) | as on a 2.x server: `$input` is `NONE`; `Event(async_mode=True)` raises `SurrealDbError` before any DDL | `ASYNC` events on 3.x | v0.31.0 |
5296
+ | Materialized views (v0.32.0) | as on a 2.x server: `int` sums and `Decimal` means; in a grouped view, `math::stddev()`/`math::variance()`, an expression around an aggregate, a field outside `GROUP BY` and a `GROUP BY` on an alias raise `SurrealDbError` before any DDL; writes to a view refused by the ORM (the engine would accept them); no live queries | `float` sums and means on 3.x | v0.32.0 |
5043
5297
  | Everything else | CRUD, QuerySet, aggregations, relations, typed edges, rich and geo fields, upsert/patch, server values, computed fields, stored functions, signals: the same as on a 2.x server | unchanged | v0.25.0 |
5044
5298
 
5045
5299
  ---
@@ -5086,7 +5340,8 @@ Contributions are welcome! Please:
5086
5340
  | v0.29.0 | Tier 2 — Multi-database | ✅ Released |
5087
5341
  | v0.30.0 | Tier 3 — Schema introspection | ✅ Released |
5088
5342
  | v0.31.0 | Tier 3 — Server-side events and DDL helpers | ✅ Released |
5089
- | v0.32.0 – v0.39.0 | Tier 3 — Advanced (search, DDL, migrations, CLI) | 📋 Planned |
5343
+ | v0.32.0 | Tier 3 — Materialized views | ✅ Released |
5344
+ | v0.33.0 – v0.39.0 | Tier 3 — Advanced (search, DDL, migrations, CLI) | 📋 Planned |
5090
5345
  | v0.40.0 | Beta Phase (API freeze, hardening) | 📋 Planned |
5091
5346
  | v2.0.0 | Production / GA (aligned with SDK 2.0) | 📋 Planned |
5092
5347
 
@@ -5142,7 +5397,7 @@ SDK) and **server support**. Everything below is on the lite roadmap via the off
5142
5397
  | Schema Introspection | ✅ v0.30.0 (`DatabaseIntrospector`, `generate_models_from_db()`) | ✅ |
5143
5398
  | DEFINE EVENT | ✅ v0.31.0 (`Event`, `define_events()`, `ASYNC` on 3.x) | ✅ migrations (`DefineEvent`) |
5144
5399
  | `define_function()` (DDL) | ✅ v0.31.0 | ❌ |
5145
- | Materialized views | v0.32.0 | ✅ |
5400
+ | Materialized views | ✅ v0.32.0 (`view_query`, `define_view()`) | ✅ |
5146
5401
  | Full-Text Search | v0.34.0 | ✅ |
5147
5402
  | Vector Search (KNN/HNSW) | v0.35.0 | ✅ |
5148
5403
  | Hybrid Search (RRF) | v0.36.0 | ✅ |