TypeDAL 4.3.6__tar.gz → 4.4.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.
- typedal-4.4.1/.crush/.gitignore +1 -0
- typedal-4.4.1/.crush/crush.db-shm +0 -0
- typedal-4.4.1/.crush/crush.db-wal +0 -0
- typedal-4.4.1/.crush/logs/crush.log +25 -0
- {typedal-4.3.6 → typedal-4.4.1}/CHANGELOG.md +16 -0
- {typedal-4.3.6 → typedal-4.4.1}/PKG-INFO +1 -1
- {typedal-4.3.6 → typedal-4.4.1}/docs/3_building_queries.md +33 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/8_mixins.md +4 -0
- typedal-4.4.1/docs/9_memoization.md +150 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/index.md +1 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/__about__.py +1 -1
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/caching.py +36 -2
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/core.py +6 -1
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/query_builder.py +38 -6
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/tables.py +12 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/types.py +4 -0
- typedal-4.4.1/tests/__init__.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_query_builder.py +163 -20
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_relationships.py +45 -0
- /typedal-4.3.6/src/typedal/py.typed → /typedal-4.4.1/.crush/init +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/.github/workflows/su6.yml +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/.gitignore +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/.readthedocs.yml +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/README.md +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/coverage.svg +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/1_getting_started.md +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/2_defining_tables.md +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/4_relationships.md +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/5_py4web.md +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/6_migrations.md +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/7_configuration.md +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/css/code_blocks.css +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/docs/requirements.txt +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/example_new.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/example_old.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/mkdocs.yml +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/pyproject.toml +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/__init__.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/cli.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/config.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/constants.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/define.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/fields.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/for_py4web.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/for_web2py.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/helpers.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/mixins.py +0 -0
- /typedal-4.3.6/tests/__init__.py → /typedal-4.4.1/src/typedal/py.typed +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/relationships.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/rows.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/serializers/as_json.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/src/typedal/web2py_py4web_shared.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/configs/simple.toml +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/configs/valid.env +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/configs/valid.toml +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/py314_tests.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_cli.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_config.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_docs_examples.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_helpers.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_json.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_main.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_mixins.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_mypy.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_orm.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_py4web.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_row.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_stats.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_table.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_web2py.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/test_xx_others.py +0 -0
- {typedal-4.3.6 → typedal-4.4.1}/tests/timings.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{"time":"2026-01-26T17:01:48.91963488+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
|
|
2
|
+
{"time":"2026-01-26T17:01:49.11634171+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
|
|
3
|
+
{"time":"2026-01-26T17:01:49.116589785+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/config.(*Config).configureProviders","file":"github.com/charmbracelet/crush/internal/config/load.go","line":259},"msg":"Skipping provider due to missing API key","provider":"anthropic"}
|
|
4
|
+
{"time":"2026-01-26T17:01:49.128053484+01:00","level":"INFO","msg":"OK 20250424200609_initial.sql (1.26ms)"}
|
|
5
|
+
{"time":"2026-01-26T17:01:49.128338994+01:00","level":"INFO","msg":"OK 20250515105448_add_summary_message_id.sql (263.8µs)"}
|
|
6
|
+
{"time":"2026-01-26T17:01:49.128678839+01:00","level":"INFO","msg":"OK 20250624000000_add_created_at_indexes.sql (325.2µs)"}
|
|
7
|
+
{"time":"2026-01-26T17:01:49.128984204+01:00","level":"INFO","msg":"OK 20250627000000_add_provider_to_messages.sql (291.73µs)"}
|
|
8
|
+
{"time":"2026-01-26T17:01:49.12929983+01:00","level":"INFO","msg":"OK 20250810000000_add_is_summary_message.sql (265.62µs)"}
|
|
9
|
+
{"time":"2026-01-26T17:01:49.129580779+01:00","level":"INFO","msg":"OK 20250812000000_add_todos_to_sessions.sql (268.09µs)"}
|
|
10
|
+
{"time":"2026-01-26T17:01:49.129585964+01:00","level":"INFO","msg":"goose: successfully migrated database to version: 20250812000000"}
|
|
11
|
+
{"time":"2026-01-26T17:01:49.129633405+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
|
|
12
|
+
{"time":"2026-01-26T17:01:49.129745943+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":110},"msg":"Initializing MCP clients"}
|
|
13
|
+
{"time":"2026-01-26T17:01:49.73470854+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).createAndStartLSPClient","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":76},"msg":"LSP client initialized","name":"python"}
|
|
14
|
+
{"time":"2026-01-26T17:02:52.962674187+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
|
|
15
|
+
{"time":"2026-01-26T17:02:57.3247847+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
|
|
16
|
+
{"time":"2026-01-26T17:11:33.432259554+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).Shutdown.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":507},"msg":"Shutdown took 6.286852ms"}
|
|
17
|
+
{"time":"2026-01-26T17:11:33.982816329+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":55},"msg":"Fetching providers from Catwalk"}
|
|
18
|
+
{"time":"2026-01-26T17:11:34.178260248+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/config.(*catwalkSync).Get.func1","file":"github.com/charmbracelet/crush/internal/config/catwalk.go","line":63},"msg":"Catwalk providers not modified"}
|
|
19
|
+
{"time":"2026-01-26T17:11:34.178494988+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/config.(*Config).configureProviders","file":"github.com/charmbracelet/crush/internal/config/load.go","line":259},"msg":"Skipping provider due to missing API key","provider":"anthropic"}
|
|
20
|
+
{"time":"2026-01-26T17:11:34.18216688+01:00","level":"INFO","msg":"goose: no migrations to run. current version: 20250812000000"}
|
|
21
|
+
{"time":"2026-01-26T17:11:34.182194111+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).initLSPClients","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":21},"msg":"LSP clients initialization started in background"}
|
|
22
|
+
{"time":"2026-01-26T17:11:34.182335616+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":110},"msg":"Initializing MCP clients"}
|
|
23
|
+
{"time":"2026-01-26T17:11:34.786142525+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.(*App).createAndStartLSPClient","file":"github.com/charmbracelet/crush/internal/app/lsp.go","line":76},"msg":"LSP client initialized","name":"python"}
|
|
24
|
+
{"time":"2026-01-26T17:11:43.042930337+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).generateTitle","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":788},"msg":"generated title with small model"}
|
|
25
|
+
{"time":"2026-01-26T17:11:44.911909276+01:00","level":"WARN","source":{"function":"github.com/charmbracelet/crush/internal/agent/tools.init.func1","file":"github.com/charmbracelet/crush/internal/agent/tools/rg.go","line":18},"msg":"Ripgrep (rg) not found in $PATH. Some grep features might be limited or slower."}
|
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
<!--next-version-placeholder-->
|
|
4
4
|
|
|
5
|
+
## v4.4.1 (2026-01-26)
|
|
6
|
+
|
|
7
|
+
### Fix
|
|
8
|
+
|
|
9
|
+
* Test and support memoize with empty table and .execute() ([`76ea39b`](https://github.com/trialandsuccess/TypeDAL/commit/76ea39bf4f8beb2ad303e66b69ad01780359ad14))
|
|
10
|
+
|
|
11
|
+
### Documentation
|
|
12
|
+
|
|
13
|
+
* Add chapter 9 about `memoize` ([`e23a8c8`](https://github.com/trialandsuccess/TypeDAL/commit/e23a8c80ee60a5adb2d32c6d05fb398d5d703116))
|
|
14
|
+
|
|
15
|
+
## v4.4.0 (2026-01-20)
|
|
16
|
+
|
|
17
|
+
### Feature
|
|
18
|
+
|
|
19
|
+
* Add groupby and having methods to QueryBuilder ([`8bce7ec`](https://github.com/trialandsuccess/TypeDAL/commit/8bce7ec137db79ab1631f4504ae891b5bd42c1d8))
|
|
20
|
+
|
|
5
21
|
## v4.3.6 (2026-01-19)
|
|
6
22
|
|
|
7
23
|
### Fix
|
|
@@ -22,6 +22,8 @@ A Query Builder can be initialized by calling one of these methods on a TypedTab
|
|
|
22
22
|
- where
|
|
23
23
|
- select
|
|
24
24
|
- join
|
|
25
|
+
- groupby
|
|
26
|
+
- having
|
|
25
27
|
- cache
|
|
26
28
|
|
|
27
29
|
e.g. `Person.where(...)` -> `QueryBuilder[Person]`
|
|
@@ -155,6 +157,35 @@ Person.join('articles', method='inner') # will only yield persons that have rel
|
|
|
155
157
|
|
|
156
158
|
For more details about relationships and joins, see [4. Relationships](./4_relationships.md).
|
|
157
159
|
|
|
160
|
+
### groupby & having
|
|
161
|
+
|
|
162
|
+
Group query results by one or more fields, typically used with aggregate functions like `count()`, `sum()`, `avg()`, etc.
|
|
163
|
+
Use `having` to filter the grouped results based on aggregate conditions.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# Basic grouping: count articles per author
|
|
167
|
+
Article.select(Article.author, Article.id.count().with_alias("article_count"))
|
|
168
|
+
.groupby(Article.author)
|
|
169
|
+
.collect()
|
|
170
|
+
|
|
171
|
+
# Group by multiple fields
|
|
172
|
+
Sale.select(Sale.product, Sale.region, Sale.amount.sum().with_alias("total"))
|
|
173
|
+
.groupby(Sale.product, Sale.region)
|
|
174
|
+
.collect()
|
|
175
|
+
|
|
176
|
+
# Filter groups with having: only authors with more than 5 articles
|
|
177
|
+
Article.select(Article.author, Article.id.count().with_alias("article_count"))
|
|
178
|
+
.groupby(Article.author)
|
|
179
|
+
.having(Article.id.count() > 5)
|
|
180
|
+
.collect()
|
|
181
|
+
|
|
182
|
+
# Can be chained in any order
|
|
183
|
+
School.groupby(School.id)
|
|
184
|
+
.having(Team.id.count() > 0)
|
|
185
|
+
.select(School.id, Team.id.count())
|
|
186
|
+
.collect()
|
|
187
|
+
```
|
|
188
|
+
|
|
158
189
|
### cache
|
|
159
190
|
|
|
160
191
|
```python
|
|
@@ -186,6 +217,8 @@ In order to enable this functionality, TypeDAL adds a `before update` and `befor
|
|
|
186
217
|
which manages the dependencies. You can disable this behavior by passing `cache_dependency=False` to `db.define`.
|
|
187
218
|
Be aware doing this might break some caching functionality!
|
|
188
219
|
|
|
220
|
+
**Note:** For caching function results (instead of just query results), see [9. Function Memoization](./9_memoization.md).
|
|
221
|
+
|
|
189
222
|
### Collecting
|
|
190
223
|
|
|
191
224
|
The Query Builder has a few operations that don't return a new builder instance:
|
|
@@ -121,3 +121,7 @@ recent_articles = (
|
|
|
121
121
|
|
|
122
122
|
By using these mixins, you can enhance the functionality of your models in a modular and reusable manner, saving you
|
|
123
123
|
time and effort in your development process.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
Looking to cache expensive function results? Head to [9. Function Memoization](./9_memoization.md) to learn about `db.memoize()`.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# 9. Function Memoization
|
|
2
|
+
|
|
3
|
+
TypeDAL provides database-aware function memoization via `db.memoize()`. This allows you to cache the result of expensive functions and automatically invalidate the cache when the underlying database rows change.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
While `.cache()` on query builders (see [3. Building Queries](./3_building_queries.md#cache)) caches query results, `db.memoize()` caches entire function results while tracking all database operations that happen inside the function.
|
|
8
|
+
|
|
9
|
+
This is designed for cases where the database query itself is fast, but the application logic that follows is expensive.
|
|
10
|
+
|
|
11
|
+
## Basic Usage
|
|
12
|
+
|
|
13
|
+
Wrap any function call with `db.memoize()` to cache its result:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
def process_articles(articles: TypedRows[Article]) -> dict:
|
|
17
|
+
result = {}
|
|
18
|
+
# dummy example, normally you'd use .join() of course
|
|
19
|
+
for article in articles:
|
|
20
|
+
comments = Comment.where(article=article).collect()
|
|
21
|
+
result[article.id] = comments
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
articles = Article.where(published=True).collect()
|
|
25
|
+
|
|
26
|
+
result, status = db.memoize(process_articles, articles)
|
|
27
|
+
assert status == "fresh"
|
|
28
|
+
|
|
29
|
+
result, status = db.memoize(process_articles, articles)
|
|
30
|
+
assert status == "cached"
|
|
31
|
+
|
|
32
|
+
Comment.first().update_record(text="Updated")
|
|
33
|
+
|
|
34
|
+
result, status = db.memoize(process_articles, articles)
|
|
35
|
+
assert status == "fresh" # cache invalidated!
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The return value is a tuple of `(result, status)`:
|
|
39
|
+
- `result`: The actual return value of the function
|
|
40
|
+
- `status`: Either `"fresh"` (newly computed) or `"cached"` (retrieved from cache)
|
|
41
|
+
|
|
42
|
+
## Automatic Dependency Tracking
|
|
43
|
+
|
|
44
|
+
TypeDAL automatically tracks all database rows loaded during function execution. This includes:
|
|
45
|
+
|
|
46
|
+
- Direct queries (`Table.where(...).collect()`)
|
|
47
|
+
- Joins (`Table.join().collect()`)
|
|
48
|
+
- Nested queries inside loops
|
|
49
|
+
|
|
50
|
+
When any tracked row is updated, inserted, or deleted, the cached result is invalidated:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
def something_slow():
|
|
54
|
+
return list(User.join())
|
|
55
|
+
|
|
56
|
+
result, status = db.memoize(something_slow)
|
|
57
|
+
assert status == "fresh"
|
|
58
|
+
|
|
59
|
+
User.first().update_record(name="Changed")
|
|
60
|
+
|
|
61
|
+
result, status = db.memoize(something_slow)
|
|
62
|
+
assert status == "fresh" # cache was invalidated
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## TTL (Time To Live)
|
|
66
|
+
|
|
67
|
+
Control cache lifetime using the `ttl` parameter. It accepts seconds (int), a `timedelta`, or an absolute `datetime`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from datetime import datetime, timedelta
|
|
71
|
+
|
|
72
|
+
# Expire after 1 hour (3600 seconds)
|
|
73
|
+
db.memoize(func, data, ttl=3600)
|
|
74
|
+
|
|
75
|
+
# Expire after 1 hour (timedelta)
|
|
76
|
+
db.memoize(func, data, ttl=timedelta(hours=1))
|
|
77
|
+
|
|
78
|
+
# Expire at specific datetime
|
|
79
|
+
db.memoize(func, data, ttl=datetime(2026, 1, 7))
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Cache Maintenance
|
|
83
|
+
|
|
84
|
+
The `typedal.caching` module provides utilities for cache management:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from typedal.caching import clear_cache, remove_cache_for_table, clear_expired
|
|
88
|
+
|
|
89
|
+
# Remove all cache entries
|
|
90
|
+
clear_cache()
|
|
91
|
+
|
|
92
|
+
# Invalidate all cache entries related to a specific table
|
|
93
|
+
remove_cache_for_table(User)
|
|
94
|
+
|
|
95
|
+
# Clean up expired entries only
|
|
96
|
+
clear_expired()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
You can also use the CLI:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
# Clean up expired entries
|
|
103
|
+
typedal cache.clear
|
|
104
|
+
|
|
105
|
+
# Show cache statistics
|
|
106
|
+
typedal cache.stats
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Debugging & Profiling
|
|
110
|
+
|
|
111
|
+
TypeDAL provides `before_collect`/`before_execute` and `after_collect`/`after_execute` hooks on the database instance for debugging and profiling queries:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
def print_query(qb: QueryBuilder):
|
|
115
|
+
print("going to run", qb.to_sql())
|
|
116
|
+
|
|
117
|
+
def print_duration(_qb: QueryBuilder, rows, _raw):
|
|
118
|
+
print("took", rows.metadata["select_duration"])
|
|
119
|
+
|
|
120
|
+
db.before_collect.append(print_query)
|
|
121
|
+
db.after_collect.append(print_duration)
|
|
122
|
+
|
|
123
|
+
TestQueryTable.all() # will trigger both hooks
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
These hooks are used internally for dependency tracking but are exposed for debugging and observability.
|
|
127
|
+
|
|
128
|
+
## How It Works
|
|
129
|
+
|
|
130
|
+
When you call `db.memoize(func, *args, **kwargs)`:
|
|
131
|
+
|
|
132
|
+
1. TypeDAL computes a cache key based on the function and its arguments
|
|
133
|
+
2. If a valid cached result exists, it's returned with `status="cached"`
|
|
134
|
+
3. Otherwise, the function is executed while tracking all database operations
|
|
135
|
+
4. The result is cached along with the IDs of all rows accessed
|
|
136
|
+
5. When any tracked row changes, the cache entry is invalidated
|
|
137
|
+
|
|
138
|
+
The cache is stored in the `typedal_cache` (and `typedal_cache_dependency`) table (same as query-level caching).
|
|
139
|
+
|
|
140
|
+
## Disabling Dependency Tracking
|
|
141
|
+
|
|
142
|
+
If you need to disable cache invalidation hooks for a specific table:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
@db.define(cache_dependency=False)
|
|
146
|
+
class SpecialTable(TypedTable):
|
|
147
|
+
...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Warning:** Disabling this may break caching functionality for queries involving this table.
|
|
@@ -12,6 +12,7 @@ import dill # nosec
|
|
|
12
12
|
from pydal.objects import Field, Rows, Set
|
|
13
13
|
|
|
14
14
|
from .fields import TypedField
|
|
15
|
+
from .helpers import throw
|
|
15
16
|
from .rows import TypedRows
|
|
16
17
|
from .tables import TypedTable
|
|
17
18
|
from .types import CacheStatus, Query
|
|
@@ -177,8 +178,12 @@ def clear_cache() -> None:
|
|
|
177
178
|
|
|
178
179
|
Immediately commits
|
|
179
180
|
"""
|
|
181
|
+
db: TypeDAL = _TypedalCache._db or throw(
|
|
182
|
+
RuntimeError("@define or db.define is not called on typedal caching classes yet!")
|
|
183
|
+
)
|
|
184
|
+
|
|
180
185
|
_TypedalCache.truncate("RESTART IDENTITY CASCADE")
|
|
181
|
-
|
|
186
|
+
db.commit()
|
|
182
187
|
|
|
183
188
|
|
|
184
189
|
def clear_expired() -> int:
|
|
@@ -572,16 +577,45 @@ def memoize(
|
|
|
572
577
|
return cached, "cached"
|
|
573
578
|
# Cache miss - compute result
|
|
574
579
|
|
|
575
|
-
def
|
|
580
|
+
def track_execute(qb: "QueryBuilder[t.Any]", raw: Rows):
|
|
576
581
|
# find dependant table+id combinations, includes relationships:
|
|
577
582
|
deps.update(_determine_dependencies_auto(raw))
|
|
578
583
|
|
|
584
|
+
# tables: qb.model;
|
|
585
|
+
# something with qb.relationships
|
|
586
|
+
# something with qb.select_args
|
|
587
|
+
|
|
588
|
+
related_tables = (
|
|
589
|
+
{
|
|
590
|
+
# original table
|
|
591
|
+
str(qb.model)
|
|
592
|
+
}
|
|
593
|
+
| {
|
|
594
|
+
# other tables in select()
|
|
595
|
+
_get_table_name(arg)
|
|
596
|
+
for arg in qb.select_args
|
|
597
|
+
}
|
|
598
|
+
| {
|
|
599
|
+
# other tables in relationships
|
|
600
|
+
str(r.table)
|
|
601
|
+
for r in qb.relationships.values()
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# mark dependency for every relevant table in this query without id:
|
|
606
|
+
deps.update({(table, 0) for table in related_tables})
|
|
607
|
+
|
|
608
|
+
def track_collect(qb: "QueryBuilder[t.Any]", _: TypedRows[t.Any], raw: Rows) -> None:
|
|
609
|
+
return track_execute(qb, raw)
|
|
610
|
+
|
|
579
611
|
# hooks every .collect() to track extra dependencies
|
|
580
612
|
db._after_collect.append(track_collect)
|
|
613
|
+
db._after_execute.append(track_execute)
|
|
581
614
|
try:
|
|
582
615
|
result = func(*args, **kwargs)
|
|
583
616
|
finally:
|
|
584
617
|
db._after_collect.remove(track_collect)
|
|
618
|
+
db._after_execute.remove(track_execute)
|
|
585
619
|
|
|
586
620
|
# Save to cache
|
|
587
621
|
if isinstance(ttl, dt.datetime):
|
|
@@ -4,6 +4,7 @@ Core functionality of TypeDAL.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import datetime as dt
|
|
7
8
|
import sys
|
|
8
9
|
import typing as t
|
|
9
10
|
import warnings
|
|
@@ -147,10 +148,12 @@ class TypeDAL(pydal.DAL):
|
|
|
147
148
|
_config: TypeDALConfig
|
|
148
149
|
_builder: TableDefinitionBuilder
|
|
149
150
|
|
|
150
|
-
# similar to the insert/update/delete hooks at table-level but for .collect:
|
|
151
|
+
# similar to the insert/update/delete hooks at table-level but for .collect/.execute:
|
|
151
152
|
# note: return values are ignored!
|
|
152
153
|
_before_collect: list[t.Callable[["QueryBuilder[t.Any]"], None]]
|
|
153
154
|
_after_collect: list[t.Callable[["QueryBuilder[t.Any]", "TypedRows[t.Any]", "Rows"], None]]
|
|
155
|
+
_before_execute: list[t.Callable[["QueryBuilder[t.Any]"], None]]
|
|
156
|
+
_after_execute: list[t.Callable[["QueryBuilder[t.Any]", "Rows"], None]]
|
|
154
157
|
|
|
155
158
|
def __init__(
|
|
156
159
|
self,
|
|
@@ -207,6 +210,8 @@ class TypeDAL(pydal.DAL):
|
|
|
207
210
|
|
|
208
211
|
self._before_collect = []
|
|
209
212
|
self._after_collect = []
|
|
213
|
+
self._before_execute = []
|
|
214
|
+
self._after_execute = []
|
|
210
215
|
|
|
211
216
|
if config.folder:
|
|
212
217
|
Path(config.folder).mkdir(exist_ok=True)
|
|
@@ -70,8 +70,7 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
|
|
|
70
70
|
"""
|
|
71
71
|
self.model = model
|
|
72
72
|
table = self._ensure_table_defined()
|
|
73
|
-
|
|
74
|
-
default_query = table.id > 0
|
|
73
|
+
default_query: Query = table.id > 0
|
|
75
74
|
self.query = add_query or default_query
|
|
76
75
|
self.select_args = select_args or []
|
|
77
76
|
self.select_kwargs = select_kwargs or {}
|
|
@@ -111,7 +110,7 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
|
|
|
111
110
|
Querybuilder is truthy if it has t.Any conditions.
|
|
112
111
|
"""
|
|
113
112
|
table = self._ensure_table_defined()
|
|
114
|
-
default_query = table.id > 0
|
|
113
|
+
default_query: Query = table.id > 0
|
|
115
114
|
return any(
|
|
116
115
|
[
|
|
117
116
|
self.query != default_query,
|
|
@@ -183,6 +182,31 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
|
|
|
183
182
|
"""
|
|
184
183
|
return self.select(orderby=fields)
|
|
185
184
|
|
|
185
|
+
def groupby(self, *fields: t.Any) -> "QueryBuilder[T_MetaInstance]":
|
|
186
|
+
"""
|
|
187
|
+
Group the query results by specified fields.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
fields: Field(s) to group by (e.g., Table.column)
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
QueryBuilder: A new QueryBuilder instance with grouping applied.
|
|
194
|
+
"""
|
|
195
|
+
groupby_value = fields[0] if len(fields) == 1 else fields
|
|
196
|
+
return self.select(groupby=groupby_value)
|
|
197
|
+
|
|
198
|
+
def having(self, condition: t.Any) -> "QueryBuilder[T_MetaInstance]":
|
|
199
|
+
"""
|
|
200
|
+
Filter grouped query results based on aggregate conditions.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
condition: Query condition for filtering groups (e.g., Team.id.count() > 0)
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
QueryBuilder: A new QueryBuilder instance with having condition applied.
|
|
207
|
+
"""
|
|
208
|
+
return self.select(having=condition)
|
|
209
|
+
|
|
186
210
|
def where(
|
|
187
211
|
self,
|
|
188
212
|
*queries_or_lambdas: Query | t.Callable[[t.Type[T_MetaInstance]], Query] | dict[str, t.Any],
|
|
@@ -524,11 +548,19 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
|
|
|
524
548
|
Raw version of .collect which only executes the SQL, without performing t.Any magic afterwards.
|
|
525
549
|
"""
|
|
526
550
|
db = self._get_db()
|
|
527
|
-
metadata = self.metadata.copy()
|
|
551
|
+
metadata: Metadata = self.metadata.copy()
|
|
528
552
|
|
|
529
553
|
query, select_args, select_kwargs = self._before_query(metadata, add_id=add_id)
|
|
530
554
|
|
|
531
|
-
|
|
555
|
+
for fn_before in db._before_execute:
|
|
556
|
+
fn_before(self)
|
|
557
|
+
|
|
558
|
+
rows = db(query).select(*select_args, **select_kwargs)
|
|
559
|
+
|
|
560
|
+
for fn_after in db._after_execute:
|
|
561
|
+
fn_after(self, rows)
|
|
562
|
+
|
|
563
|
+
return rows
|
|
532
564
|
|
|
533
565
|
def collect(
|
|
534
566
|
self,
|
|
@@ -552,7 +584,7 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
|
|
|
552
584
|
for fn_before in db._before_collect:
|
|
553
585
|
fn_before(self)
|
|
554
586
|
|
|
555
|
-
metadata = self.metadata.copy()
|
|
587
|
+
metadata: Metadata = self.metadata.copy()
|
|
556
588
|
|
|
557
589
|
if metadata.get("cache", {}).get("enabled") and (result := self._collect_cached(metadata)):
|
|
558
590
|
return result
|
|
@@ -326,6 +326,18 @@ class TableMeta(type):
|
|
|
326
326
|
"""
|
|
327
327
|
return QueryBuilder(self).orderby(*fields)
|
|
328
328
|
|
|
329
|
+
def groupby(self: t.Type[T_MetaInstance], *fields: t.Any) -> "QueryBuilder[T_MetaInstance]":
|
|
330
|
+
"""
|
|
331
|
+
See QueryBuilder.groupby!
|
|
332
|
+
"""
|
|
333
|
+
return QueryBuilder(self).groupby(*fields)
|
|
334
|
+
|
|
335
|
+
def having(self: t.Type[T_MetaInstance], condition: t.Any) -> "QueryBuilder[T_MetaInstance]":
|
|
336
|
+
"""
|
|
337
|
+
See QueryBuilder.having!
|
|
338
|
+
"""
|
|
339
|
+
return QueryBuilder(self).having(condition)
|
|
340
|
+
|
|
329
341
|
def cache(self: t.Type[T_MetaInstance], *deps: t.Any, **kwargs: t.Any) -> "QueryBuilder[T_MetaInstance]":
|
|
330
342
|
"""
|
|
331
343
|
See QueryBuilder.cache!
|
|
@@ -220,6 +220,8 @@ class SelectKwargs(t.TypedDict, total=False):
|
|
|
220
220
|
join: t.Optional[list[Expression]]
|
|
221
221
|
left: t.Optional[list[Expression]]
|
|
222
222
|
orderby: "OrderBy | t.Iterable[OrderBy] | None"
|
|
223
|
+
groupby: "GroupBy | t.Iterable[GroupBy] | None"
|
|
224
|
+
having: "Having | None"
|
|
223
225
|
limitby: t.Optional[tuple[int, int]]
|
|
224
226
|
distinct: bool | Field | Expression
|
|
225
227
|
orderby_on_limitby: bool
|
|
@@ -323,5 +325,7 @@ CacheModel = t.Callable[[str, CacheFn, int], Rows]
|
|
|
323
325
|
CacheTuple = tuple[CacheModel, int]
|
|
324
326
|
|
|
325
327
|
OrderBy: t.TypeAlias = str | Expression
|
|
328
|
+
GroupBy: t.TypeAlias = Field | Expression
|
|
329
|
+
Having: t.TypeAlias = Query | Expression
|
|
326
330
|
|
|
327
331
|
T_annotation = t.Type[t.Any] | types.UnionType
|
|
File without changes
|
|
@@ -45,25 +45,6 @@ def test_query_type():
|
|
|
45
45
|
assert isinstance(TestQueryTable.number != 3, Query)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
"""
|
|
49
|
-
SELECT "test_query_table"."id"
|
|
50
|
-
, "test_query_table"."number"
|
|
51
|
-
, "relations_8106139955393"."id"
|
|
52
|
-
, "relations_8106139955393"."name"
|
|
53
|
-
, "relations_8106139955393"."value"
|
|
54
|
-
, "relations_8106139955393"."querytable"
|
|
55
|
-
FROM "test_query_table"
|
|
56
|
-
LEFT JOIN "test_relationship" AS "relations_8106139955393"
|
|
57
|
-
ON ("relations_8106139955393"."querytable" = "test_query_table"."id")
|
|
58
|
-
WHERE ("test_query_table"."id" IN (SELECT "test_query_table"."id"
|
|
59
|
-
FROM "test_query_table"
|
|
60
|
-
WHERE ("test_query_table"."id" > 0)
|
|
61
|
-
ORDER BY "test_query_table"."id"
|
|
62
|
-
LIMIT 3 OFFSET 0))
|
|
63
|
-
ORDER BY "test_query_table"."number" DESC;
|
|
64
|
-
"""
|
|
65
|
-
|
|
66
|
-
|
|
67
48
|
def _setup_data():
|
|
68
49
|
TestQueryTable.truncate()
|
|
69
50
|
first = TestQueryTable.insert(number=0)
|
|
@@ -546,7 +527,7 @@ def test_minimal_functionality_on_pydal_style_tables():
|
|
|
546
527
|
assert len(qb2) == 1
|
|
547
528
|
|
|
548
529
|
|
|
549
|
-
def test_before_after_collect():
|
|
530
|
+
def test_before_after_collect(capsys):
|
|
550
531
|
_setup_data()
|
|
551
532
|
|
|
552
533
|
def print_query(qb: QueryBuilder):
|
|
@@ -562,3 +543,165 @@ def test_before_after_collect():
|
|
|
562
543
|
db._after_collect.append(print_duration)
|
|
563
544
|
|
|
564
545
|
TestQueryTable.all()
|
|
546
|
+
captured = capsys.readouterr()
|
|
547
|
+
assert "going to run" in captured.out
|
|
548
|
+
assert "took" in captured.out
|
|
549
|
+
|
|
550
|
+
db._before_execute.append(lambda *_: print("BEFORE EXECUTE"))
|
|
551
|
+
db._after_execute.append(lambda *_: print("AFTER EXECUTE"))
|
|
552
|
+
TestQueryTable.select().execute()
|
|
553
|
+
captured = capsys.readouterr()
|
|
554
|
+
assert "BEFORE EXECUTE" in captured.out
|
|
555
|
+
assert "AFTER EXECUTE" in captured.out
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_groupby_basic():
|
|
559
|
+
"""Test basic groupby with count aggregation."""
|
|
560
|
+
_setup_data()
|
|
561
|
+
|
|
562
|
+
result = (
|
|
563
|
+
TestRelationship.select(
|
|
564
|
+
TestRelationship.querytable.with_alias("query_table"),
|
|
565
|
+
TestRelationship.querytable.count().with_alias("count"),
|
|
566
|
+
)
|
|
567
|
+
.groupby(TestRelationship.querytable)
|
|
568
|
+
.execute()
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
assert len(result) == 2
|
|
572
|
+
for row in result:
|
|
573
|
+
assert row["count"] == 4
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def test_groupby_multiple_fields():
|
|
577
|
+
"""Test grouping by multiple fields."""
|
|
578
|
+
_setup_data()
|
|
579
|
+
|
|
580
|
+
result = (
|
|
581
|
+
TestRelationship.select(
|
|
582
|
+
TestRelationship.querytable,
|
|
583
|
+
TestRelationship.value,
|
|
584
|
+
TestRelationship.id.count().with_alias("count"),
|
|
585
|
+
)
|
|
586
|
+
.groupby(TestRelationship.querytable, TestRelationship.value)
|
|
587
|
+
.execute()
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Should group by combination of querytable and value
|
|
591
|
+
assert len(result) > 0
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def test_groupby_with_having():
|
|
595
|
+
"""Test groupby with having to filter groups."""
|
|
596
|
+
_setup_data()
|
|
597
|
+
|
|
598
|
+
result = (
|
|
599
|
+
TestRelationship.select(
|
|
600
|
+
TestRelationship.querytable.with_alias("query_table"),
|
|
601
|
+
TestRelationship.querytable.count().with_alias("count"),
|
|
602
|
+
)
|
|
603
|
+
.groupby(TestRelationship.querytable)
|
|
604
|
+
.having(TestRelationship.querytable.count() > 3)
|
|
605
|
+
.execute()
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Only groups with count > 3
|
|
609
|
+
assert len(result) == 2
|
|
610
|
+
for row in result:
|
|
611
|
+
assert row["count"] > 3
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def test_having_filters_aggregates():
|
|
615
|
+
"""Test that having properly filters based on aggregate conditions."""
|
|
616
|
+
_setup_data()
|
|
617
|
+
|
|
618
|
+
# Get all groups
|
|
619
|
+
all_groups = (
|
|
620
|
+
TestRelationship.select(
|
|
621
|
+
TestRelationship.querytable,
|
|
622
|
+
TestRelationship.querytable.count().with_alias("count"),
|
|
623
|
+
)
|
|
624
|
+
.groupby(TestRelationship.querytable)
|
|
625
|
+
.execute()
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Filter with having
|
|
629
|
+
filtered = (
|
|
630
|
+
TestRelationship.select(
|
|
631
|
+
TestRelationship.querytable,
|
|
632
|
+
TestRelationship.querytable.count().with_alias("count"),
|
|
633
|
+
)
|
|
634
|
+
.groupby(TestRelationship.querytable)
|
|
635
|
+
.having(TestRelationship.querytable.count() > 10)
|
|
636
|
+
.execute()
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Should have fewer results (or zero if no groups have count > 10)
|
|
640
|
+
assert len(filtered) <= len(all_groups)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def test_groupby_to_sql():
|
|
644
|
+
"""Verify SQL generation includes GROUP BY."""
|
|
645
|
+
sql = (
|
|
646
|
+
TestRelationship.select(TestRelationship.querytable, TestRelationship.querytable.count())
|
|
647
|
+
.groupby(TestRelationship.querytable)
|
|
648
|
+
.to_sql()
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
assert "GROUP BY" in sql
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def test_having_to_sql():
|
|
655
|
+
"""Verify SQL generation includes HAVING."""
|
|
656
|
+
sql = (
|
|
657
|
+
TestRelationship.select(TestRelationship.querytable, TestRelationship.querytable.count())
|
|
658
|
+
.groupby(TestRelationship.querytable)
|
|
659
|
+
.having(TestRelationship.querytable.count() > 0)
|
|
660
|
+
.to_sql()
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
assert "GROUP BY" in sql
|
|
664
|
+
assert "HAVING" in sql
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def test_groupby_chaining():
|
|
668
|
+
"""Test that multiple groupby calls work (last one should win)."""
|
|
669
|
+
_setup_data()
|
|
670
|
+
|
|
671
|
+
# First groupby by querytable
|
|
672
|
+
builder1 = TestRelationship.select(
|
|
673
|
+
TestRelationship.querytable, TestRelationship.querytable.count().with_alias("count")
|
|
674
|
+
).groupby(TestRelationship.querytable)
|
|
675
|
+
|
|
676
|
+
# Then groupby by value (should override)
|
|
677
|
+
builder2 = builder1.groupby(TestRelationship.value)
|
|
678
|
+
|
|
679
|
+
sql = builder2.to_sql()
|
|
680
|
+
# Should only have the second groupby
|
|
681
|
+
assert "GROUP BY" in sql
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def test_groupby_having_on_table_class():
|
|
685
|
+
"""Test calling .groupby() and .having() directly on table class in different orders."""
|
|
686
|
+
_setup_data()
|
|
687
|
+
|
|
688
|
+
builder1 = (
|
|
689
|
+
TestRelationship.groupby(TestRelationship.querytable)
|
|
690
|
+
.having(TestRelationship.querytable.count() > 0)
|
|
691
|
+
.select(TestRelationship.querytable, TestRelationship.querytable.count())
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
sql1 = builder1.to_sql()
|
|
695
|
+
|
|
696
|
+
builder2 = (
|
|
697
|
+
TestRelationship.having(TestRelationship.querytable.count() > 0)
|
|
698
|
+
.groupby(TestRelationship.querytable)
|
|
699
|
+
.select(TestRelationship.querytable, TestRelationship.querytable.count())
|
|
700
|
+
)
|
|
701
|
+
sql2 = builder2.to_sql()
|
|
702
|
+
|
|
703
|
+
assert sql1 == sql2
|
|
704
|
+
assert "GROUP BY" in sql1
|
|
705
|
+
assert "HAVING" in sql1
|
|
706
|
+
|
|
707
|
+
assert builder1.execute() == builder2.execute()
|
|
@@ -774,6 +774,51 @@ def test_memoize_nested_dependencies2():
|
|
|
774
774
|
assert status == "cached"
|
|
775
775
|
|
|
776
776
|
|
|
777
|
+
def test_memoize_with_empty_table():
|
|
778
|
+
"""
|
|
779
|
+
Test memoization when the table has no data yet.
|
|
780
|
+
Ensures cache invalidation works correctly when data is added later.
|
|
781
|
+
"""
|
|
782
|
+
# (no setup data needed since we'll truncate anyway)
|
|
783
|
+
|
|
784
|
+
# Clean up - ensure table is empty
|
|
785
|
+
for table in db.tables:
|
|
786
|
+
db[table].truncate()
|
|
787
|
+
|
|
788
|
+
db.commit()
|
|
789
|
+
|
|
790
|
+
# Memoize a function with empty table
|
|
791
|
+
def get_all_users() -> list[str]:
|
|
792
|
+
users = User.join().select(User.name).execute() # also tests execute instead of collect
|
|
793
|
+
return [user[User.name] for user in users]
|
|
794
|
+
|
|
795
|
+
# First call with empty table
|
|
796
|
+
result1, status1 = db.memoize(get_all_users)
|
|
797
|
+
assert status1 == "fresh"
|
|
798
|
+
assert result1 == []
|
|
799
|
+
|
|
800
|
+
# Second call - should be cached
|
|
801
|
+
result2, status2 = db.memoize(get_all_users)
|
|
802
|
+
assert status2 == "cached"
|
|
803
|
+
assert result2 == []
|
|
804
|
+
|
|
805
|
+
# Now insert data into the empty table
|
|
806
|
+
role = Role.insert(name="admin")
|
|
807
|
+
User.insert(name="First User", roles=[role], main_role=role, extra_roles=[])
|
|
808
|
+
db.commit()
|
|
809
|
+
|
|
810
|
+
# Third call - cache should be invalidated due to insert
|
|
811
|
+
result3, status3 = db.memoize(get_all_users)
|
|
812
|
+
assert status3 == "fresh", "Cache should be invalidated after insert into previously empty table"
|
|
813
|
+
assert len(result3) == 1
|
|
814
|
+
assert "First User" in result3
|
|
815
|
+
|
|
816
|
+
# Fourth call - should be cached again
|
|
817
|
+
result4, status4 = db.memoize(get_all_users)
|
|
818
|
+
assert status4 == "cached"
|
|
819
|
+
assert result3 == result4
|
|
820
|
+
|
|
821
|
+
|
|
777
822
|
def test_illegal():
|
|
778
823
|
with pytest.raises(ValueError), pytest.warns(UserWarning):
|
|
779
824
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|