surreal-orm-lite 0.32.0__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 (47) hide show
  1. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/CHANGELOG.md +44 -0
  2. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/PKG-INFO +11 -5
  3. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/README.md +10 -4
  4. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/pyproject.toml +1 -1
  5. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/__init__.py +1 -1
  6. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/model_base.py +14 -4
  7. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/schema.py +76 -20
  8. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/.gitignore +0 -0
  9. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/LICENSE +0 -0
  10. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/Makefile +0 -0
  11. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/_sdk.py +0 -0
  12. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/aggregations.py +0 -0
  13. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/auth.py +0 -0
  14. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/cache.py +0 -0
  15. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/changefeed.py +0 -0
  16. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/concurrency.py +0 -0
  17. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/connection_config.py +0 -0
  18. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/connection_manager.py +0 -0
  19. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/constants.py +0 -0
  20. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/decoding.py +0 -0
  21. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/define_parser.py +0 -0
  22. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/edge.py +0 -0
  23. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/embedded.py +0 -0
  24. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/encoding.py +0 -0
  25. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/enum.py +0 -0
  26. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/exceptions.py +0 -0
  27. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/functions.py +0 -0
  28. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/geo.py +0 -0
  29. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/introspection.py +0 -0
  30. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/live.py +0 -0
  31. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/model_auth.py +0 -0
  32. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/model_generator.py +0 -0
  33. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/py.typed +0 -0
  34. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/q.py +0 -0
  35. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/query_set.py +0 -0
  36. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/routing.py +0 -0
  37. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/schema_state.py +0 -0
  38. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/signals.py +0 -0
  39. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/subquery.py +0 -0
  40. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/surql_literal.py +0 -0
  41. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/testing/__init__.py +0 -0
  42. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/testing/database.py +0 -0
  43. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/testing/fixtures.py +0 -0
  44. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/time_travel.py +0 -0
  45. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/transaction.py +0 -0
  46. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/types.py +0 -0
  47. {surreal_orm_lite-0.32.0 → surreal_orm_lite-0.32.1}/src/surreal_orm_lite/utils.py +0 -0
@@ -5,6 +5,50 @@ 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
+
8
52
  ## [0.32.0] - 2026-10-20
9
53
 
10
54
  Materialized views as read-only models: a model declares the `SELECT` that SurrealDB computes a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: surreal-orm-lite
3
- Version: 0.32.0
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
@@ -4848,9 +4848,11 @@ print([(str(order.id), order.amount) for order in await PaidOrder.objects().orde
4848
4848
  date: `count()`, `math::sum()`, `math::mean()`, `math::min()`, `math::max()`, `time::min()`, `time::max()` (and
4849
4849
  `math::stddev()`, `math::variance()` on 3.x), whatever their argument (`math::sum(amount ?? 0)`). `math::median()`,
4850
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).
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).
4854
4856
  - Every source write runs the view's query for that record: when it fails — a function given a value of the wrong
4855
4857
  type, an aggregated field missing
4856
4858
  ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)) — SurrealDB refuses the write, on
@@ -4928,6 +4930,10 @@ print([(str(order.id), order.amount) for order in await PaidOrder.objects().orde
4928
4930
  (`objects(tx=tx)`), unseen outside the block until it commits. Either way, a block that raises leaves no view. The
4929
4931
  table guard and the 2.x checks run before anything is queued, on committed state; a refusal of a queued statement
4930
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.
4931
4937
  - Both run on the model's connection (its `connection` key, or the `using()` block's); a `tx=` of another connection
4932
4938
  raises `SurrealDbError` before any request. A view and its source tables live in one database: give a view model
4933
4939
  its sources' connection.
@@ -5253,7 +5259,7 @@ The SDK's in-process engine has a table of its own: [ORM behaviour: the embedded
5253
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 |
5254
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 |
5255
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 |
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 |
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 |
5257
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 |
5258
5264
 
5259
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.
@@ -4797,9 +4797,11 @@ print([(str(order.id), order.amount) for order in await PaidOrder.objects().orde
4797
4797
  date: `count()`, `math::sum()`, `math::mean()`, `math::min()`, `math::max()`, `time::min()`, `time::max()` (and
4798
4798
  `math::stddev()`, `math::variance()` on 3.x), whatever their argument (`math::sum(amount ?? 0)`). `math::median()`,
4799
4799
  `array::group()`, `array::distinct()`, `array::join()` and `vector::sum()` are refused (inside an aggregate's
4800
- argument they are allowed). On SurrealDB 3.x an expression around an aggregate (`math::sum(amount) / count()`) and
4801
- a `GROUP BY` on an alias work; on 2.x and the embedded engine `define_view()` refuses them (see "SurrealDB 2.x and
4802
- 3.x" below).
4800
+ argument they are allowed). Since v0.32.1 that holds when an expression combines several aggregates too
4801
+ (`math::sum(amount) + math::sum(array::len(array::distinct(tags)))`); beside an aggregate
4802
+ (`math::sum(amount) + array::len(array::distinct(tags))`) they are still refused. On SurrealDB 3.x an expression
4803
+ around an aggregate (`math::sum(amount) / count()`) and a `GROUP BY` on an alias work; on 2.x and the embedded
4804
+ engine `define_view()` refuses them (see "SurrealDB 2.x and 3.x" below).
4803
4805
  - Every source write runs the view's query for that record: when it fails — a function given a value of the wrong
4804
4806
  type, an aggregated field missing
4805
4807
  ([surrealdb/surrealdb#7447](https://github.com/surrealdb/surrealdb/issues/7447)) — SurrealDB refuses the write, on
@@ -4877,6 +4879,10 @@ print([(str(order.id), order.amount) for order in await PaidOrder.objects().orde
4877
4879
  (`objects(tx=tx)`), unseen outside the block until it commits. Either way, a block that raises leaves no view. The
4878
4880
  table guard and the 2.x checks run before anything is queued, on committed state; a refusal of a queued statement
4879
4881
  comes back when the block commits, as the transaction's own error.
4882
+ - A chain of views can be removed in one buffered block, outer view first (since v0.32.1): on 2.x and the embedded
4883
+ engine `remove_view(tx=tx)` does not count a view whose `remove_view(tx=tx)` the same block has already queued,
4884
+ unless the block names it again afterwards (a `define_view(tx=tx)` of it). A view removed in another transaction,
4885
+ or by a `REMOVE TABLE` you add to the block yourself, is still counted.
4880
4886
  - Both run on the model's connection (its `connection` key, or the `using()` block's); a `tx=` of another connection
4881
4887
  raises `SurrealDbError` before any request. A view and its source tables live in one database: give a view model
4882
4888
  its sources' connection.
@@ -5202,7 +5208,7 @@ The SDK's in-process engine has a table of its own: [ORM behaviour: the embedded
5202
5208
  | 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 |
5203
5209
  | 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 |
5204
5210
  | 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 |
5205
- | 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 |
5211
+ | 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 |
5206
5212
  | 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 |
5207
5213
 
5208
5214
  > **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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "surreal-orm-lite"
3
- version = "0.32.0"
3
+ version = "0.32.1"
4
4
  description = "Lightweight Django-style ORM for SurrealDB using the official Python SDK. Async support with Pydantic validation."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,4 +1,4 @@
1
- __version__ = "0.32.0"
1
+ __version__ = "0.32.1"
2
2
 
3
3
  from ._sdk import BoundExcluded, BoundIncluded
4
4
  from .aggregations import Aggregation, Avg, Count, Max, Min, Sum
@@ -30,6 +30,7 @@ from .schema import (
30
30
  check_view_config,
31
31
  check_view_query,
32
32
  dependent_views,
33
+ dependents_not_removed,
33
34
  ensure_async_events_supported,
34
35
  ensure_view_query_supported,
35
36
  ensure_view_removable,
@@ -54,7 +55,7 @@ from .signals import (
54
55
  pre_save,
55
56
  pre_update,
56
57
  )
57
- from .transaction import Transaction
58
+ from .transaction import BufferedTransaction, Transaction
58
59
  from .types import Duration, _duration_from_sdk, _timedelta_from_sdk
59
60
  from .utils import (
60
61
  PLAIN_CONTENT,
@@ -1145,6 +1146,13 @@ class BaseSurrealModel(BaseModel):
1145
1146
  their queries only (a view reading the table through a graph path or a sub-select is left to the server), and
1146
1147
  the line is read from ``client.version()`` only when one is found.
1147
1148
 
1149
+ A chain of views can be removed in one buffered transaction (SurrealDB 2.x, HTTP, the embedded engine), outer
1150
+ view first (v0.32.1): a view whose removal the same *tx* has already queued with ``remove_view()`` — the last
1151
+ statement of the block naming it — is not counted, though the committed ``INFO FOR DB`` still lists it. It is
1152
+ counted again when the block names it after that removal (a ``define_view()`` of it), when it is removed in
1153
+ another transaction or by a ``tx.add("REMOVE TABLE …")`` written by hand. On SurrealDB 3.x the server decides,
1154
+ as before.
1155
+
1148
1156
  Args:
1149
1157
  missing_ok: ``True`` (default) emits ``REMOVE TABLE IF EXISTS …``, so a missing view is not an error;
1150
1158
  ``False`` raises ``SurrealDbNotFoundError`` for it.
@@ -1178,9 +1186,11 @@ class BaseSurrealModel(BaseModel):
1178
1186
  if existing == "unreadable":
1179
1187
  raise SurrealDbError(view_table_refused("G3", cls.__name__, table, removal=True))
1180
1188
  if existing == "view":
1181
- await ensure_view_removable(
1182
- client, table, dependent_views(tables, table), model=cls.__name__, embedded=manager.is_embedded()
1183
- )
1189
+ dependents = dependent_views(tables, table)
1190
+ if isinstance(tx, BufferedTransaction):
1191
+ # Issue #286: the views this block has already queued for removal are gone when it commits.
1192
+ dependents = dependents_not_removed(dependents, tx.statements)
1193
+ await ensure_view_removable(client, table, dependents, model=cls.__name__, embedded=manager.is_embedded())
1184
1194
  await apply_ddl_statements(
1185
1195
  [statement],
1186
1196
  client=client,
@@ -37,8 +37,9 @@ the DDL, not a lock. Before the guard, ``define_view()`` refuses on SurrealDB 2.
37
37
  projection those engines compute once per source record (``ensure_view_query_supported()``, C1), reading the line
38
38
  from ``client.version()`` only when the query holds one; 3.x keeps such a view up to date or refuses it itself. On the
39
39
  guard's one ``INFO FOR DB``, ``remove_view()`` finds the views that read the view it removes (``dependent_views()``,
40
- through the plain ``FROM`` targets ``view_reads_table()`` reads) and, when there is one, refuses on SurrealDB 2.x and
41
- the embedded engine before any DDL (``ensure_view_removable()``, G5): those engines would remove it and turn those
40
+ through the plain ``FROM`` targets ``view_reads_table()`` reads; in a buffered transaction, without those whose
41
+ removal the block has already queued, ``dependents_not_removed()``) and, when there is one, refuses on SurrealDB 2.x
42
+ and the embedded engine before any DDL (``ensure_view_removable()``, G5): those engines would remove it and turn those
42
43
  views into ordinary tables that no longer update, where 3.x refuses the removal itself.
43
44
 
44
45
  The module sits low in the package: it imports neither ``model_base`` nor ``connection_manager``, so both can import
@@ -556,16 +557,21 @@ class ViewElement:
556
557
  (``math::sum(amount)``), else ``None``.
557
558
  - ``calls``: the lower-cased names of the functions it calls (a ``::`` path, or ``count``), in order, nested calls
558
559
  included.
560
+ - ``free_calls``: the calls of ``calls`` that stand outside the argument of every call of an aggregate SurrealDB
561
+ keeps up to date (``count``, ``math::sum`` … ``math::variance``), in order — the aggregate calls themselves
562
+ included, the calls inside their arguments left out (v0.32.1, issue #286): ``("math::sum", "math::sum")`` for
563
+ ``math::sum(a) + math::sum(array::len(array::distinct(tags)))``.
559
564
  - ``fields``: the fields it reads, in order, a repeated one repeated (``("*",)`` for ``*``); ``()`` when
560
565
  ``aggregate`` is set — the aggregate's argument is read per record, as SurrealDB intends.
561
- - ``opaque``: it holds a sub-select or a graph path, which the reading does not analyse (``calls`` and ``fields``
562
- are then empty).
566
+ - ``opaque``: it holds a sub-select or a graph path, which the reading does not analyse (``calls``,
567
+ ``free_calls`` and ``fields`` are then empty).
563
568
  """
564
569
 
565
570
  text: str
566
571
  alias: str | None
567
572
  aggregate: str | None
568
573
  calls: tuple[str, ...]
574
+ free_calls: tuple[str, ...]
569
575
  fields: tuple[str, ...]
570
576
  opaque: bool
571
577
 
@@ -613,8 +619,12 @@ def check_view_query(value: object, *, model: str) -> str:
613
619
  - V6: it is a ``SELECT VALUE``: a view stores records;
614
620
  - V7: it groups its records (a top-level ``GROUP BY`` or ``GROUP ALL``) and its projection calls one of
615
621
  ``array::distinct``, ``array::group``, ``array::join``, ``math::median``, ``vector::sum`` (in any case) outside
616
- an element that is one whole aggregate call: SurrealDB documents them as unusable in a view. Inside an
617
- aggregate's argument (``math::max(array::len(array::distinct(tags)))``) they work on every line.
622
+ the argument of every aggregate call (``ViewElement.free_calls``): SurrealDB documents them as unusable in a
623
+ view. Inside an aggregate's argument (``math::max(array::len(array::distinct(tags)))``) they work on every line,
624
+ also when an element combines several aggregates (``math::sum(amount) +
625
+ math::sum(array::len(array::distinct(tags)))``, since v0.32.1): such an expression around aggregates passes V7,
626
+ and ``define_view()`` refuses it on SurrealDB 2.x and the embedded engine (C1). Beside or around an aggregate
627
+ (``math::sum(a) + array::len(array::distinct(tags))``) they are still refused.
618
628
 
619
629
  The check catches mistakes — unbalanced brackets, a comment that would swallow the parenthesis the ORM appends, a
620
630
  second statement where one SELECT is expected — and is not an injection defence: a view's query is trusted
@@ -643,13 +653,7 @@ def check_view_query(value: object, *, model: str) -> str:
643
653
  outline = view_query_outline(text)
644
654
  if outline.grouped:
645
655
  unusable = next(
646
- (
647
- call
648
- for element in outline.elements
649
- if element.aggregate is None
650
- for call in element.calls
651
- if call in _NOT_VIEW_AGGREGATES
652
- ),
656
+ (call for element in outline.elements for call in element.free_calls if call in _NOT_VIEW_AGGREGATES),
653
657
  None,
654
658
  )
655
659
  if unusable is not None:
@@ -760,6 +764,29 @@ def _view_fields(blanked: str) -> tuple[str, ...]:
760
764
  return tuple(name for name in _VIEW_FIELD.findall(plain) if name.upper() not in _PROJECTION_KEYWORDS)
761
765
 
762
766
 
767
+ def _view_calls(blanked: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
768
+ """``(calls, free_calls)`` of the blanked expression *blanked*, as :class:`ViewElement` describes them.
769
+
770
+ Each call is read in order; one that starts inside the argument of an aggregate call read before it (up to that
771
+ call's closing bracket, found on the blanked text, so a bracket inside a literal is never one) is a call, not a free
772
+ call (issue #286)."""
773
+ calls: list[str] = []
774
+ free_calls: list[str] = []
775
+ argument_end = -1
776
+ for match in _VIEW_CALL.finditer(blanked):
777
+ name = match.group(1).lower()
778
+ if "::" not in name and name != "count":
779
+ continue
780
+ calls.append(name)
781
+ if match.start() < argument_end:
782
+ continue
783
+ free_calls.append(name)
784
+ if name in _ALL_VIEW_AGGREGATES:
785
+ closing = _closing_bracket(blanked, match.end() - 1)
786
+ argument_end = len(blanked) if closing is None else closing
787
+ return tuple(calls), tuple(free_calls)
788
+
789
+
763
790
  def _view_element(text: str, blanked: str, top: str) -> ViewElement:
764
791
  """One projection element from its three slices (*text*, *blanked*, *top*, of the same length)."""
765
792
  alias: str | None = None
@@ -774,8 +801,8 @@ def _view_element(text: str, blanked: str, top: str) -> ViewElement:
774
801
  written = expression[left:right]
775
802
  plain = blanked[left:right] if right > left else ""
776
803
  if _VIEW_OPAQUE.search(plain) is not None:
777
- return ViewElement(written, alias, None, (), (), True)
778
- calls = tuple(name.lower() for name in _VIEW_CALL.findall(plain) if "::" in name or name.lower() == "count")
804
+ return ViewElement(written, alias, None, (), (), (), True)
805
+ calls, free_calls = _view_calls(plain)
779
806
  aggregate: str | None = None
780
807
  first = _VIEW_CALL.match(plain)
781
808
  if (
@@ -785,7 +812,7 @@ def _view_element(text: str, blanked: str, top: str) -> ViewElement:
785
812
  ):
786
813
  aggregate = first.group(1).lower()
787
814
  fields = () if aggregate is not None else _view_fields(plain)
788
- return ViewElement(written, alias, aggregate, calls, fields, False)
815
+ return ViewElement(written, alias, aggregate, calls, free_calls, fields, False)
789
816
 
790
817
 
791
818
  def view_query_outline(text: str) -> ViewOutline:
@@ -795,9 +822,9 @@ def view_query_outline(text: str) -> ViewOutline:
795
822
  the server. Literals are blanked first (``define_parser.blank_literals()``), so a keyword, a call or a comma inside
796
823
  a string or a quoted name is never read. ``grouped`` is a top-level ``GROUP BY``/``GROUP ALL``; the projection
797
824
  runs from ``SELECT`` to the top-level ``FROM`` (or the end) and is split at its top-level commas; each element's
798
- alias, calls, whole aggregate and fields are read as :class:`ViewElement` describes. A sub-select or a graph path
799
- (``->``, ``<-``) makes the element opaque, and a backquoted or ``⟨⟩`` field name is invisible: both are left to
800
- the server. Pure.
825
+ alias, calls, free calls, whole aggregate and fields are read as :class:`ViewElement` describes. A sub-select or a
826
+ graph path (``->``, ``<-``) makes the element opaque, and a backquoted or ``⟨⟩`` field name is invisible: both are
827
+ left to the server. Pure.
801
828
  """
802
829
  blanked, top = _top_level(text)
803
830
  grouping = _VIEW_GROUP.search(top)
@@ -1116,6 +1143,33 @@ def dependent_views(tables: Mapping[str, Any], table: str) -> tuple[str, ...]:
1116
1143
  return tuple(sorted(names))
1117
1144
 
1118
1145
 
1146
+ def dependents_not_removed(dependents: Sequence[str], statements: Sequence[str]) -> tuple[str, ...]:
1147
+ """The views of *dependents* that the *statements* queued in a buffered transaction do not remove, in order
1148
+ (v0.32.1, issue #286).
1149
+
1150
+ G5 (:func:`ensure_view_removable`) reads committed state, where a view whose removal the same buffered block has
1151
+ already queued is still listed: removing a chain of views outer view first in one block would be refused. A
1152
+ dependent is left out when the last of *statements* that names it — as a whole name, in any case, quoted or not —
1153
+ is exactly the removal ``remove_view()`` queues for it (:func:`remove_view_statement`, with ``missing_ok`` ``True``
1154
+ or ``False``). It is kept when no statement names it, when a statement names it after its removal (a
1155
+ ``DEFINE TABLE … AS`` that defines it again, or any other), when its removal was written by hand
1156
+ (``tx.add("REMOVE TABLE …")``), and when its name is not an identifier: a reading of the text, not a parser, that
1157
+ errs on the side of the refusal. Pure.
1158
+ """
1159
+ left: list[str] = []
1160
+ for name in dependents:
1161
+ try:
1162
+ removals = {remove_view_statement(name, True), remove_view_statement(name, False)}
1163
+ except (TypeError, ValueError):
1164
+ left.append(name)
1165
+ continue
1166
+ naming = re.compile(rf"(?<!\w){re.escape(name)}(?!\w)", re.IGNORECASE)
1167
+ last = next((statement for statement in reversed(statements) if naming.search(statement)), None)
1168
+ if last not in removals:
1169
+ left.append(name)
1170
+ return tuple(left)
1171
+
1172
+
1119
1173
  async def ensure_view_removable(client: Any, table: str, dependents: Sequence[str], *, model: str, embedded: bool) -> None:
1120
1174
  """Refuse, on a connection before SurrealDB 3.x, to remove the view *table* that other views read — before any
1121
1175
  DDL (G5, v0.32.0).
@@ -1123,7 +1177,9 @@ async def ensure_view_removable(client: Any, table: str, dependents: Sequence[st
1123
1177
  While a view reads a table — a source table or an inner view —, SurrealDB 3.x refuses to remove it (``Cannot
1124
1178
  delete table … on which a view is defined``). SurrealDB 2.x and the embedded engine remove it, and every view that
1125
1179
  read it loses its ``AS`` clause: an ordinary table that keeps its last records and no longer updates. *dependents*
1126
- are those views (:func:`dependent_views`, over the table guard's one ``INFO FOR DB``): none, and nothing is read.
1180
+ are those views (:func:`dependent_views`, over the table guard's one ``INFO FOR DB``; in a buffered transaction,
1181
+ without those whose removal the block has already queued, :func:`dependents_not_removed`): none, and nothing is
1182
+ read.
1127
1183
  Else the line is read from ``client.version()`` with the lite's rule (``time_travel.line_major()``): on 3.x — or a
1128
1184
  version that cannot be read — the removal goes on and the server refuses it; before 3.x, G5 names the dependent
1129
1185
  views, sorted. On the embedded engine ``version()`` answers the SDK's version (``surrealdb-2.0.0``), whose bundled