surreal-orm-lite 0.31.2__tar.gz → 0.32.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/CHANGELOG.md +145 -0
  2. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/PKG-INFO +264 -15
  3. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/README.md +263 -14
  4. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/pyproject.toml +1 -1
  5. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/__init__.py +1 -1
  6. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/define_parser.py +38 -1
  7. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/live.py +5 -0
  8. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/model_base.py +282 -5
  9. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/model_generator.py +49 -15
  10. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/q.py +18 -2
  11. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/query_set.py +32 -11
  12. surreal_orm_lite-0.32.0/src/surreal_orm_lite/schema.py +1147 -0
  13. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/subquery.py +18 -1
  14. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/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.0}/.gitignore +0 -0
  17. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/LICENSE +0 -0
  18. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/Makefile +0 -0
  19. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/_sdk.py +0 -0
  20. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/aggregations.py +0 -0
  21. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/auth.py +0 -0
  22. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/cache.py +0 -0
  23. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/changefeed.py +0 -0
  24. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/concurrency.py +0 -0
  25. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/connection_config.py +0 -0
  26. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/connection_manager.py +0 -0
  27. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/constants.py +0 -0
  28. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/decoding.py +0 -0
  29. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/edge.py +0 -0
  30. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/embedded.py +0 -0
  31. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/encoding.py +0 -0
  32. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/enum.py +0 -0
  33. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/exceptions.py +0 -0
  34. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/functions.py +0 -0
  35. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/geo.py +0 -0
  36. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/introspection.py +0 -0
  37. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/model_auth.py +0 -0
  38. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/py.typed +0 -0
  39. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/routing.py +0 -0
  40. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/schema_state.py +0 -0
  41. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/signals.py +0 -0
  42. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/surql_literal.py +0 -0
  43. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/testing/__init__.py +0 -0
  44. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/testing/database.py +0 -0
  45. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/testing/fixtures.py +0 -0
  46. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/time_travel.py +0 -0
  47. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/transaction.py +0 -0
  48. {surreal_orm_lite-0.31.2 → surreal_orm_lite-0.32.0}/src/surreal_orm_lite/types.py +0 -0
@@ -5,6 +5,151 @@ 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.0] - 2026-10-20
9
+
10
+ Materialized views as read-only models: a model declares the `SELECT` that SurrealDB computes a
11
+ table from (`DEFINE TABLE … AS SELECT`), applies it idempotently with `define_view()` and reads it
12
+ like any model, while every ORM write of it is refused. The same code runs on SurrealDB 2.x, 3.x
13
+ and the embedded engine; a grouped view that SurrealDB 2.x would compute wrongly is refused before
14
+ any DDL there. The third minor of Tier 3.
15
+
16
+ ### Added
17
+
18
+ - **`SurrealConfigDict(view_query="SELECT …")`**: the model reads a materialized view, a table
19
+ SurrealDB computes from that query and keeps up to date as the tables it selects from change.
20
+ The query is one `SELECT … FROM <table> [WHERE …] [GROUP BY … | GROUP ALL]` (a final `;` is
21
+ dropped), checked when the class is defined: a mistake raises `TypeError` or `ValueError` at the
22
+ `class` statement. `Model.get_view_query()` returns it as checked. `Model.view_ddl()` renders
23
+ ``DEFINE TABLE OVERWRITE `<table>` AS (<query>);`` without a connection,
24
+ `await Model.define_view()` applies it on the model's connection (idempotent: run it at
25
+ start-up) and returns it, and `await Model.remove_view()` writes `REMOVE TABLE IF EXISTS`.
26
+ `overwrite=False` writes `IF NOT EXISTS`, `missing_ok=False` raises `SurrealDbNotFoundError` for
27
+ a missing view, and both appliers take `tx=`. A subclass inherits `view_query`;
28
+ `view_query=None` makes it an ordinary model.
29
+
30
+ ```python
31
+ class OrderStats(BaseSurrealModel):
32
+ model_config = SurrealConfigDict(
33
+ table_name="order_stats",
34
+ view_query="SELECT status, count() AS total, math::sum(amount) AS revenue FROM orders GROUP BY status",
35
+ )
36
+
37
+ id: RecordID | None = None
38
+ status: str
39
+ total: int
40
+ revenue: float
41
+
42
+
43
+ await OrderStats.define_view() # idempotent (OVERWRITE)
44
+ paid = await OrderStats.objects().get(["paid"]) # a grouped view's id: its group values
45
+ await paid.merge(total=0) # TypeError: Cannot modify materialized view …
46
+ ```
47
+
48
+ - **Every write of a view model raises `TypeError`** before any signal, transaction or request:
49
+ `save()`, `upsert()`, `update()`, `merge()`, `patch()`, `delete()`, the atomic helpers
50
+ (`atomic_append()` … `atomic_remove_many()`), and the QuerySet's `bulk_create()`,
51
+ `bulk_update()`, `bulk_delete()`, `patch()`, `update_or_create()`, `get_or_create()` (even when
52
+ the record exists) and `delete_table()`. The message starts with the full SurrealDB-ORM's
53
+ `Cannot modify materialized view '<table>'` and names the call: write to the tables the view
54
+ selects from instead. Reads, `refresh()`, the relation helpers, `define_events()` and
55
+ `remove_event()` stay allowed, since they do not write the view's table.
56
+ - **Array record ids**: `get(["paid"])` and `filter(id=["paid"])` address a grouped view's record
57
+ (`order_stats:['paid']`, the array of its group values); a `GROUP ALL` view's id is `[]`, read
58
+ with `first()`.
59
+ - README section 33, "Materialized views (`view_query`, `define_view()`)" (declaring a view,
60
+ applying and removing, reading, refused writes, transactions, connections and the query cache,
61
+ SurrealDB 2.x and 3.x, security, a note for users of the full SurrealDB-ORM), a features row,
62
+ eight rows in the 2.x/3.x behaviour table and one in the embedded-engine table, the comparison
63
+ and roadmap rows, and the example notebook `examples/v0.32.0_materialized_views.ipynb`.
64
+
65
+ ### Changed
66
+
67
+ - **`generate_models_from_db()` writes a view model**: a view table becomes a class with
68
+ `view_query=…` in its `SurrealConfigDict` and `id: RecordID | None = None`, marked
69
+ `# Read-only materialized view`, instead of a class carrying a `# View: AS …` comment. A view the
70
+ lite cannot declare (an edge, a computed field, a query `view_query` refuses) keeps that comment.
71
+ README section 31 says so.
72
+ - **Cached reads of a view model are dropped by every ORM write** (README section 29): reads of a
73
+ view model, and reads whose subqueries read one, since a write to a source table changes the view
74
+ and the ORM does not know which tables a view reads. `define_view()` and `remove_view()` drop
75
+ cached reads like every write. Writes by raw SurrealQL or by another process are not seen, as for
76
+ every table.
77
+ - A list or tuple given as a record id (`get()`, `filter(id=...)`, `id__in` items,
78
+ `get_or_create()`/`update_or_create()` id criteria) is now an array record id; it used to be the
79
+ string of its `repr()`.
80
+
81
+ ### Notes
82
+
83
+ - **A view's query is trusted application code, never user input.** It is written into `AS (…)`
84
+ as given, since SurrealDB takes no bound parameter in a definition. Its structural check
85
+ (literals and quoted identifiers terminated, brackets balanced, no NUL, no SurrealQL comment
86
+ marker outside a literal, one `SELECT` and no `;` outside brackets) catches mistakes —
87
+ unbalanced brackets, a comment that would swallow the parenthesis the ORM appends, a second
88
+ statement where one SELECT is expected — and is not an injection defence. To filter on a value,
89
+ store it in a record or a `DEFINE PARAM` and read it from the query; never format it into the
90
+ text. The table name is checked as an identifier and quoted. A refusal names the view's table,
91
+ never the statement, and keeps the server's reason whole, which names a missing source table.
92
+ - **Grouped views on SurrealDB 2.x and the embedded engine**: those engines keep only whole
93
+ aggregates (`count()`, `math::sum()`, `math::mean()`, `math::min()`, `math::max()`,
94
+ `time::min()`, `time::max()`) and `GROUP BY` fields up to date, and compute anything else once
95
+ per source record. So `define_view()` raises `SurrealDbError` before any DDL there for
96
+ `math::stddev()` and `math::variance()`, an expression around an aggregate
97
+ (`math::sum(amount) / count()`), a field outside `GROUP BY` and outside an aggregate (3.x refuses
98
+ that one too) and a `GROUP BY` on an alias. SurrealDB 3.x keeps them all up to date.
99
+ - **Removing a view that another view reads**: SurrealDB 3.x refuses it, and `remove_view()`
100
+ passes the refusal on as `SurrealDbError`; on 2.x and the embedded engine `remove_view()` raises
101
+ `SurrealDbError` before any DDL, since those engines would turn each view reading it into an
102
+ ordinary table that keeps its last records and no longer updates. Remove the outer views first.
103
+ - **The table guard**: `define_view()` never turns a table that holds records into a view, since
104
+ SurrealDB would delete them, and `define_view(overwrite=False)` refuses any ordinary table, which
105
+ `IF NOT EXISTS` would silently keep. An ordinary table without records, as `define_events()` or,
106
+ on 2.x, a `watch()` run before `define_view()` leaves it, becomes the view. `remove_view()`
107
+ refuses a table that is not a view. The guard is a check-then-act on committed state, not a
108
+ lock.
109
+ - **SurrealDB 2.x and 3.x**: raw writes to a view (raw SurrealQL, not the ORM) are accepted by 2.x
110
+ and 3.1, which change the view's records, and refused by 3.2 and later; a sum of integers is an
111
+ `int` and a mean a `Decimal` on 2.x, both `float` on 3.x (declare them `float`); `count()` of an
112
+ array field adds the arrays' lengths on 2.x and counts the records whose array is not empty on
113
+ 3.x; live queries and events see an emptied group as an `UPDATE` then a `DELETE` on 2.x, a
114
+ `DELETE` on 3.x. The embedded engine behaves as a 2.x server, without live queries.
115
+ - **A source write missing an aggregated field is refused** by SurrealDB on every line
116
+ ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)): write
117
+ `math::sum(amount ?? 0)`.
118
+ - **`define_view()` recomputes the view** (`OVERWRITE`): each call costs one pass over the source
119
+ and drops the events, fields and indexes defined on the view, so run `define_events()` after
120
+ `define_view()`. A view is defined without a `PERMISSIONS` clause, so SurrealDB applies its
121
+ default `PERMISSIONS NONE`: a record user reads nothing from it, and `define_view()` resets
122
+ permissions set by hand. Applying needs a privileged system user (root, or a namespace or
123
+ database `OWNER` or `EDITOR`): run `define_view()` at start-up or deployment.
124
+ - **The ORM's write refusal guards against mistakes and is not an access control**: on 2.x and
125
+ 3.1 the server accepts writes to a view from any session allowed to write the table.
126
+ - On a view model, `filter(id__in=...)` is sent as `$ids CONTAINS id`: SurrealDB 3.x answers no
127
+ record to `id IN` on a view table, while `CONTAINS` is right on every line. Write raw SurrealQL
128
+ over a view the same way.
129
+ - On SurrealDB 3.x, `live()` takes a view instance's id from the notification's record, since the
130
+ server's view notifications carry none; `watch()` yields the envelopes as received (read
131
+ `record`). A live query on a view filtered on `id` never notifies on 3.x: filter on the view's
132
+ fields.
133
+ - On SurrealDB 3.1, tested for backward compatibility only, the server refuses to redefine a view
134
+ that holds records: `define_view()` raises `SurrealDbError` ("… The key being inserted already
135
+ exists") and the view stays as it was, where 3.2 and later, 2.x and the embedded engine
136
+ recompute it. On 3.1, call `remove_view()` before `define_view()`.
137
+ - **Differences from the full SurrealDB-ORM**: the same `view_query` key and `_is_view()`, plus
138
+ `get_view_query()`; the same `TypeError` first words on every write path (full ORM: `save()`,
139
+ `update()`, `merge()`, `delete()` only); `define_view()`, `remove_view()` and `view_ddl()` where
140
+ the full ORM applies a view through migrations or `define_table()`. The documented
141
+ `MaterializedView` base class with `source_query` is not shipped by the full ORM either: declare
142
+ `view_query` on a `BaseSurrealModel`.
143
+ - **Deferred**: applying a view through migrations (`CreateTable`/`AlterTable`) and
144
+ `define_table()`, which will accept a view model and apply it through `view_ddl()`, come with
145
+ v0.37.0; table permissions for record users and a view query built from a QuerySet come later.
146
+ - The 0.31.0 notes placed the `SurrealConfigDict` keys that generated classes stand in for with
147
+ comments in v0.32.0–v0.34.0: `view_query` arrives with this release, and README section 31 now
148
+ places the others (`table_type`, `permissions`, `changefeed`, `relation_in`, `relation_out`,
149
+ `enforced`) in later minors.
150
+ - **Server pins unchanged**: tested against SurrealDB 3.3.4 and 2.7.0 and on the embedded engine,
151
+ plus 3.1.5 for backward compatibility.
152
+
8
153
  ## [0.31.2] - 2026-10-19
9
154
 
10
155
  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.0
4
4
  Summary: Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation.
5
5
  Project-URL: Homepage, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
6
6
  Project-URL: Documentation, https://github.com/EulogySnowfall/SurrealDB-ORM-lite
@@ -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,240 @@ 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). On SurrealDB 3.x an expression around an aggregate (`math::sum(amount) / count()`) and
4852
+ a `GROUP BY` on an alias work; on 2.x and the embedded engine `define_view()` refuses them (see "SurrealDB 2.x and
4853
+ 3.x" below).
4854
+ - Every source write runs the view's query for that record: when it fails — a function given a value of the wrong
4855
+ type, an aggregated field missing
4856
+ ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)) — SurrealDB refuses the write, on
4857
+ every line.
4858
+ - **Ids**: a plain view keeps the source records' ids (`paid_orders:o1`); a grouped view's id is the array of its
4859
+ group values (`order_stats:['paid']`), and `GROUP ALL` gives `[]`. Declare `id: RecordID | None = None` and look a
4860
+ group up with `get(["paid"])` or `filter(id=["paid"])`; read a `GROUP ALL` view with `first()`. Declare sums and
4861
+ means `float`: SurrealDB 2.x and the embedded engine compute an `int` sum of integers and a `Decimal` mean, which a
4862
+ `float` field reads as a float.
4863
+ - A view model cannot be an edge (`BaseSurrealEdge`), use `AuthenticatedUserMixin` or declare computed fields: each
4864
+ raises `TypeError` at the `class` statement. A subclass inherits `view_query` and defines the view on its own
4865
+ table; `view_query=None` makes it an ordinary model. The `table_name` is any identifier the ORM reads (a SurrealQL
4866
+ keyword such as `select` is defined, but not read by the QuerySet, as for every model).
4867
+
4868
+ #### Applying and removing
4869
+
4870
+ - `define_view()` writes `DEFINE TABLE OVERWRITE … AS (…)`: SurrealDB fills the view from the source's records, and
4871
+ each call recomputes the view and drops the events, fields and indexes defined on it, so at start-up it costs one
4872
+ pass over the source. `overwrite=False` writes `IF NOT EXISTS` and keeps a view already defined, whatever its
4873
+ query. It returns the statement it applied; `view_ddl()` returns the statement without applying it.
4874
+ - The source table must exist (a record saved, or a `DEFINE TABLE`), else `SurrealDbError` naming it
4875
+ (`Can't apply view definition: 'order_stats' -> The table 'orders' does not exist`).
4876
+ - Redefining a view drops the events, fields and indexes defined on it: run `define_events()` after `define_view()`.
4877
+ - `remove_view()` writes `REMOVE TABLE IF EXISTS`, which removes the view and its records; with `missing_ok=False` a
4878
+ view that does not exist raises `SurrealDbNotFoundError`.
4879
+ - The table guard: when the model's table already exists as an ordinary table **without records** — SurrealDB
4880
+ creates one when `define_events()` or, on 2.x, a `watch()` runs before `define_view()` — `define_view()` turns it
4881
+ into the view. When it holds records, `define_view()` raises `SurrealDbError` before any DDL, because SurrealDB
4882
+ would delete them (the message gives the `raw_query("REMOVE TABLE …")` to run if they are not needed).
4883
+ `define_view(overwrite=False)` raises on any ordinary table, which `IF NOT EXISTS` would silently keep, and
4884
+ `remove_view()` raises on a table that is not a view.
4885
+ - A view may read another view: it is updated when the inner view changes, but not recomputed when the inner one is
4886
+ redefined.
4887
+ - **Remove the views that read another view first.** SurrealDB 3.x refuses to remove a table that a view reads — a
4888
+ source table or an inner view — and `remove_view()` passes that refusal on (`SurrealDbError`,
4889
+ `Cannot delete table … on which a view is defined`). On 2.x and the embedded engine `remove_view()` raises
4890
+ `SurrealDbError` before any DDL, because those engines would remove it and turn every view reading it into an
4891
+ ordinary table that keeps its last records and no longer updates; the ORM finds those views by their plain `FROM`
4892
+ targets. A raw `REMOVE TABLE` of a source table does that on 2.x and the embedded engine: such a former view then
4893
+ makes `define_view()` refuse (it holds records: run the `raw_query("REMOVE TABLE …")` the message gives) and
4894
+ `remove_view()` refuse (it is not a view).
4895
+
4896
+ #### Reading
4897
+
4898
+ - Every read works — filters, `order_by()`, aggregates, `get()`, `refresh()`, `Subquery`, `fetch()`, `using()`, and
4899
+ on WebSocket `live()`/`watch()`, which receive the records SurrealDB recomputes. `relate()` and `remove_relation()`
4900
+ from or to a view record write the edge table and are allowed.
4901
+ - On a view model, `filter(id__in=...)` is sent as `$ids CONTAINS id`: SurrealDB 3.x (3.1 and 3.3 measured) answers
4902
+ no record to `id IN` on a view table, while `CONTAINS` is right on every line; write raw SurrealQL over a view the
4903
+ same way.
4904
+ - `live()` instances carry the record id on both lines; `watch()` yields the server's envelopes as received: on 3.x
4905
+ their `result` has no `id`, read `record`.
4906
+ - On SurrealDB 3.x a live query on a view filtered on `id` never notifies (the server's view notifications carry no
4907
+ id); filter on the view's fields, e.g. `filter(status="paid")`.
4908
+
4909
+ #### Writes are refused
4910
+
4911
+ - Every ORM write of a view model raises `TypeError` before any signal, transaction or request: the instance
4912
+ methods `save()`, `upsert()`, `update()`, `merge()`, `patch()`, `delete()` and the atomic helpers
4913
+ `atomic_append()`, `atomic_remove()`, `atomic_set_add()`, `atomic_increment()`, `atomic_append_many()`,
4914
+ `atomic_set_add_many()`, `atomic_remove_many()`, and the QuerySet methods `bulk_create()`, `bulk_update()`,
4915
+ `bulk_delete()`, `patch()`, `update_or_create()`, `get_or_create()` (even when the record exists) and
4916
+ `delete_table()`. The message starts with the full ORM's `Cannot modify materialized view '<table>'` and names the
4917
+ call: write to the tables the view selects from instead.
4918
+ - `refresh()`, `relate()`, `remove_relation()`, `remove_all_relations()`, `define_events()` and `remove_event()` stay
4919
+ allowed: they do not write the view's table.
4920
+ - Raw SurrealQL (`raw_query()`, `QuerySet.query()`, the client's `query()`) is not checked: SurrealDB 3.2 and later
4921
+ refuse a write to a view, 2.x and 3.1 accept it and change the view's records.
4922
+
4923
+ #### Transactions, connections and the query cache
4924
+
4925
+ - `define_view()` and `remove_view()` take `tx=`. In a buffered transaction (SurrealDB 2.x, HTTP, the embedded
4926
+ engine) the statement is applied at commit, and the block's later source writes update the view; in an interactive
4927
+ one (WebSocket and SurrealDB 3.x) it is applied in the transaction, which reads the view and its own source writes
4928
+ (`objects(tx=tx)`), unseen outside the block until it commits. Either way, a block that raises leaves no view. The
4929
+ table guard and the 2.x checks run before anything is queued, on committed state; a refusal of a queued statement
4930
+ comes back when the block commits, as the transaction's own error.
4931
+ - Both run on the model's connection (its `connection` key, or the `using()` block's); a `tx=` of another connection
4932
+ raises `SurrealDbError` before any request. A view and its source tables live in one database: give a view model
4933
+ its sources' connection.
4934
+ - The query cache ([section 29](#29-query-cache-cache-querycache)): reads of a view model, and reads whose subqueries
4935
+ read one, are dropped by every ORM write, since a write to a source table changes the view and the ORM does not know
4936
+ which tables a view reads. `define_view()` and `remove_view()` drop cached reads like every write. Writes by raw
4937
+ SurrealQL or by another process are not seen, as for every table.
4938
+ - Defining and removing need a system user allowed to define: root, or a namespace or database `OWNER` or `EDITOR`.
4939
+ On a server, a `VIEWER` or a record user gets `SurrealDbAuthenticationError`.
4940
+ - Applying DDL fires no signal, and neither do the records SurrealDB computes for a view.
4941
+
4942
+ #### SurrealDB 2.x and 3.x
4943
+
4944
+ - 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.
4945
+ The ORM refuses its own writes on every line.
4946
+ - Grouped views: 2.x and the embedded engine keep up to date only whole aggregates and `GROUP BY` fields, and
4947
+ compute anything else once per source record. So `define_view()` raises `SurrealDbError` before any DDL there for
4948
+ `math::stddev()` and `math::variance()`, an expression around an aggregate (`math::sum(amount) / count()`), a field
4949
+ outside `GROUP BY` and outside an aggregate (3.x refuses that one too), and a `GROUP BY` on an alias (2.x would put
4950
+ every record in one group).
4951
+ - Value types: `int` sums and `Decimal` means on 2.x, `float` on 3.x; `count()` of an array field adds the arrays'
4952
+ lengths on 2.x, and counts the records whose array is not empty on 3.x.
4953
+ - Live queries and events see an emptied group as an `UPDATE` then a `DELETE` on 2.x, a `DELETE` on 3.x.
4954
+ - Removing a view, or a source table, that another view reads: 3.x refuses it; on 2.x `remove_view()` raises before
4955
+ any DDL, and a raw `REMOVE TABLE` leaves the views reading it as ordinary tables keeping their last records.
4956
+ - On every line, a source write missing an aggregated field is refused
4957
+ ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)): write `math::sum(amount ?? 0)`.
4958
+ - The [2.x/3.x table](#orm-behaviour-surrealdb-2x-vs-3x) lists these differences; the embedded engine behaves as a
4959
+ 2.x server.
4960
+
4961
+ #### Security
4962
+
4963
+ The table name is checked as an identifier and written quoted in every statement the ORM sends. The query is written
4964
+ into `AS (…)` as it is, since SurrealDB takes no bound parameter in a definition; when the class is defined the ORM
4965
+ checks its structure: string literals and quoted identifiers terminated, brackets balanced, no NUL character, no
4966
+ SurrealQL comment marker outside a literal, one `SELECT` and no `;` outside brackets. The check catches mistakes —
4967
+ unbalanced brackets, a comment that would swallow the parenthesis the ORM appends, a second statement where one
4968
+ SELECT is expected — and is not an injection defence: a view's query is trusted application code, never user input.
4969
+ 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
4970
+ text. A refusal names the view's table, never the statement, and keeps the server's reason whole, which names the
4971
+ source table to create.
4972
+
4973
+ Destructive DDL is guarded: `define_view()` never turns a table that holds records into a view and never lets
4974
+ `IF NOT EXISTS` keep an ordinary table, and `remove_view()` never removes a table that is not a view nor, on 2.x and
4975
+ the embedded engine, a view that another view reads. The guard is a check-then-act on committed state, not a lock: a
4976
+ record written or a table created by another process between its reads and the DDL is not seen. The write refusal
4977
+ guards against mistakes and is not an access control: on 2.x and 3.1 the server accepts writes to a view from any
4978
+ session allowed to write the table. A view is defined without a `PERMISSIONS` clause, so SurrealDB applies its
4979
+ default `PERMISSIONS NONE`: a record user reads nothing from a view, and `define_view()` resets permissions set by
4980
+ hand on it. Applying a view needs a privileged system user: run `define_view()` at start-up or deployment, not on a
4981
+ record user's session.
4982
+
4983
+ #### Coming from the full SurrealDB-ORM
4984
+
4985
+ - **`view_query` is the same key** (`SurrealConfigDict(view_query="SELECT …")`); `get_view_query()` is added and
4986
+ `_is_view()` kept.
4987
+ - **`TypeError` with the same first words** (`Cannot modify materialized view '<table>'`), on every write path above
4988
+ (full ORM: `save()`, `update()`, `merge()`, `delete()` only).
4989
+ - **`define_view()`/`remove_view()`/`view_ddl()`** apply, remove and render the view, where the full ORM applies one
4990
+ through migrations (`CreateTable`/`AlterTable`) or `define_table()`. Those come with v0.37.0, whose
4991
+ `define_table()` accepts a view model and applies it through `view_ddl()`, so full-ORM code calling
4992
+ `define_table()` on a view works from then on.
4993
+ - **`AS (…)` as in the full ORM, the table name quoted** (``DEFINE TABLE OVERWRITE `order_stats` AS (…)``).
4994
+ - **Generated models write `view_query=`**, as the full ORM's do
4995
+ ([section 31](#31-schema-introspection-databaseintrospector-generate_models_from_db)).
4996
+ - **The documented `MaterializedView` base class with `source_query` is not shipped by the full ORM either**: declare
4997
+ `view_query` on a `BaseSurrealModel`.
4998
+
4760
4999
  ---
4761
5000
 
4762
5001
  ## Configuration Options
@@ -4879,7 +5118,7 @@ features introduced in SurrealDB 3.x. On 2.x the ORM degrades gracefully. Capabi
4879
5118
  listed behave the same on both lines. The 2.x column was measured on 2.6.5 and holds unchanged
4880
5119
  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
5120
  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.
5121
+ 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
5122
  The SDK's in-process engine has a table of its own: [ORM behaviour: the embedded engine](#orm-behaviour-the-embedded-engine).
4884
5123
 
4885
5124
  | ORM capability | SurrealDB 2.6.x / 2.7.x | SurrealDB 3.2.x / 3.3.x | Since |
@@ -5007,6 +5246,14 @@ The SDK's in-process engine has a table of its own: [ORM behaviour: the embedded
5007
5246
  | 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
5247
  | `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
5248
  | 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 |
5249
+ | 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 |
5250
+ | 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 |
5251
+ | 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 |
5252
+ | 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 |
5253
+ | 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 |
5254
+ | 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 |
5255
+ | 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 |
5256
+ | Removing a materialized view, or a source table, that another view reads (`remove_view()`, `REMOVE TABLE`) | `remove_view()` raises `SurrealDbError` before any DDL; a raw `REMOVE TABLE` succeeds and turns each view reading the table into an ordinary table keeping its last records | `SurrealDbError` from the server (`Cannot delete table … on which a view is defined`): remove the views that read it first | v0.32.0 |
5010
5257
  | 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
5258
 
5012
5259
  > **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 +5287,7 @@ Capabilities not listed behave as on a 2.x server.
5040
5287
  | 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
5288
  | 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
5289
  | 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 |
5290
+ | 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
5291
  | 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
5292
 
5045
5293
  ---
@@ -5086,7 +5334,8 @@ Contributions are welcome! Please:
5086
5334
  | v0.29.0 | Tier 2 — Multi-database | ✅ Released |
5087
5335
  | v0.30.0 | Tier 3 — Schema introspection | ✅ Released |
5088
5336
  | 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 |
5337
+ | v0.32.0 | Tier 3 — Materialized views | ✅ Released |
5338
+ | v0.33.0 – v0.39.0 | Tier 3 — Advanced (search, DDL, migrations, CLI) | 📋 Planned |
5090
5339
  | v0.40.0 | Beta Phase (API freeze, hardening) | 📋 Planned |
5091
5340
  | v2.0.0 | Production / GA (aligned with SDK 2.0) | 📋 Planned |
5092
5341
 
@@ -5142,7 +5391,7 @@ SDK) and **server support**. Everything below is on the lite roadmap via the off
5142
5391
  | Schema Introspection | ✅ v0.30.0 (`DatabaseIntrospector`, `generate_models_from_db()`) | ✅ |
5143
5392
  | DEFINE EVENT | ✅ v0.31.0 (`Event`, `define_events()`, `ASYNC` on 3.x) | ✅ migrations (`DefineEvent`) |
5144
5393
  | `define_function()` (DDL) | ✅ v0.31.0 | ❌ |
5145
- | Materialized views | v0.32.0 | ✅ |
5394
+ | Materialized views | ✅ v0.32.0 (`view_query`, `define_view()`) | ✅ |
5146
5395
  | Full-Text Search | v0.34.0 | ✅ |
5147
5396
  | Vector Search (KNN/HNSW) | v0.35.0 | ✅ |
5148
5397
  | Hybrid Search (RRF) | v0.36.0 | ✅ |