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.
Files changed (72) hide show
  1. typedal-4.4.1/.crush/.gitignore +1 -0
  2. typedal-4.4.1/.crush/crush.db-shm +0 -0
  3. typedal-4.4.1/.crush/crush.db-wal +0 -0
  4. typedal-4.4.1/.crush/logs/crush.log +25 -0
  5. {typedal-4.3.6 → typedal-4.4.1}/CHANGELOG.md +16 -0
  6. {typedal-4.3.6 → typedal-4.4.1}/PKG-INFO +1 -1
  7. {typedal-4.3.6 → typedal-4.4.1}/docs/3_building_queries.md +33 -0
  8. {typedal-4.3.6 → typedal-4.4.1}/docs/8_mixins.md +4 -0
  9. typedal-4.4.1/docs/9_memoization.md +150 -0
  10. {typedal-4.3.6 → typedal-4.4.1}/docs/index.md +1 -0
  11. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/__about__.py +1 -1
  12. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/caching.py +36 -2
  13. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/core.py +6 -1
  14. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/query_builder.py +38 -6
  15. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/tables.py +12 -0
  16. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/types.py +4 -0
  17. typedal-4.4.1/tests/__init__.py +0 -0
  18. {typedal-4.3.6 → typedal-4.4.1}/tests/test_query_builder.py +163 -20
  19. {typedal-4.3.6 → typedal-4.4.1}/tests/test_relationships.py +45 -0
  20. /typedal-4.3.6/src/typedal/py.typed → /typedal-4.4.1/.crush/init +0 -0
  21. {typedal-4.3.6 → typedal-4.4.1}/.github/workflows/su6.yml +0 -0
  22. {typedal-4.3.6 → typedal-4.4.1}/.gitignore +0 -0
  23. {typedal-4.3.6 → typedal-4.4.1}/.readthedocs.yml +0 -0
  24. {typedal-4.3.6 → typedal-4.4.1}/README.md +0 -0
  25. {typedal-4.3.6 → typedal-4.4.1}/coverage.svg +0 -0
  26. {typedal-4.3.6 → typedal-4.4.1}/docs/1_getting_started.md +0 -0
  27. {typedal-4.3.6 → typedal-4.4.1}/docs/2_defining_tables.md +0 -0
  28. {typedal-4.3.6 → typedal-4.4.1}/docs/4_relationships.md +0 -0
  29. {typedal-4.3.6 → typedal-4.4.1}/docs/5_py4web.md +0 -0
  30. {typedal-4.3.6 → typedal-4.4.1}/docs/6_migrations.md +0 -0
  31. {typedal-4.3.6 → typedal-4.4.1}/docs/7_configuration.md +0 -0
  32. {typedal-4.3.6 → typedal-4.4.1}/docs/css/code_blocks.css +0 -0
  33. {typedal-4.3.6 → typedal-4.4.1}/docs/requirements.txt +0 -0
  34. {typedal-4.3.6 → typedal-4.4.1}/example_new.py +0 -0
  35. {typedal-4.3.6 → typedal-4.4.1}/example_old.py +0 -0
  36. {typedal-4.3.6 → typedal-4.4.1}/mkdocs.yml +0 -0
  37. {typedal-4.3.6 → typedal-4.4.1}/pyproject.toml +0 -0
  38. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/__init__.py +0 -0
  39. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/cli.py +0 -0
  40. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/config.py +0 -0
  41. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/constants.py +0 -0
  42. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/define.py +0 -0
  43. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/fields.py +0 -0
  44. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/for_py4web.py +0 -0
  45. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/for_web2py.py +0 -0
  46. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/helpers.py +0 -0
  47. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/mixins.py +0 -0
  48. /typedal-4.3.6/tests/__init__.py → /typedal-4.4.1/src/typedal/py.typed +0 -0
  49. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/relationships.py +0 -0
  50. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/rows.py +0 -0
  51. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/serializers/as_json.py +0 -0
  52. {typedal-4.3.6 → typedal-4.4.1}/src/typedal/web2py_py4web_shared.py +0 -0
  53. {typedal-4.3.6 → typedal-4.4.1}/tests/configs/simple.toml +0 -0
  54. {typedal-4.3.6 → typedal-4.4.1}/tests/configs/valid.env +0 -0
  55. {typedal-4.3.6 → typedal-4.4.1}/tests/configs/valid.toml +0 -0
  56. {typedal-4.3.6 → typedal-4.4.1}/tests/py314_tests.py +0 -0
  57. {typedal-4.3.6 → typedal-4.4.1}/tests/test_cli.py +0 -0
  58. {typedal-4.3.6 → typedal-4.4.1}/tests/test_config.py +0 -0
  59. {typedal-4.3.6 → typedal-4.4.1}/tests/test_docs_examples.py +0 -0
  60. {typedal-4.3.6 → typedal-4.4.1}/tests/test_helpers.py +0 -0
  61. {typedal-4.3.6 → typedal-4.4.1}/tests/test_json.py +0 -0
  62. {typedal-4.3.6 → typedal-4.4.1}/tests/test_main.py +0 -0
  63. {typedal-4.3.6 → typedal-4.4.1}/tests/test_mixins.py +0 -0
  64. {typedal-4.3.6 → typedal-4.4.1}/tests/test_mypy.py +0 -0
  65. {typedal-4.3.6 → typedal-4.4.1}/tests/test_orm.py +0 -0
  66. {typedal-4.3.6 → typedal-4.4.1}/tests/test_py4web.py +0 -0
  67. {typedal-4.3.6 → typedal-4.4.1}/tests/test_row.py +0 -0
  68. {typedal-4.3.6 → typedal-4.4.1}/tests/test_stats.py +0 -0
  69. {typedal-4.3.6 → typedal-4.4.1}/tests/test_table.py +0 -0
  70. {typedal-4.3.6 → typedal-4.4.1}/tests/test_web2py.py +0 -0
  71. {typedal-4.3.6 → typedal-4.4.1}/tests/test_xx_others.py +0 -0
  72. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.3.6
3
+ Version: 4.4.1
4
4
  Summary: Typing support for PyDAL
5
5
  Project-URL: Documentation, https://typedal.readthedocs.io/
6
6
  Project-URL: Issues, https://github.com/trialandsuccess/TypeDAL/issues
@@ -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.
@@ -8,3 +8,4 @@
8
8
  6. [Migrations](./6_migrations.md)
9
9
  7. [Advanced Configuration](./7_configuration.md)
10
10
  8. [Mixins](./8_mixins.md)
11
+ 9. [Function Memoization](./9_memoization.md)
@@ -5,4 +5,4 @@ This file contains the Version info for this package.
5
5
  # SPDX-FileCopyrightText: 2023-present Robin van der Noord <robinvandernoord@gmail.com>
6
6
  #
7
7
  # SPDX-License-Identifier: MIT
8
- __version__ = "4.3.6"
8
+ __version__ = "4.4.1"
@@ -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
- _TypedalCache._db.commit()
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 track_collect(_qb: "QueryBuilder[t.Any]", _: TypedRows[t.Any], raw: Rows) -> None:
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
- return db(query).select(*select_args, **select_kwargs)
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