TypeDAL 4.4.0__tar.gz → 4.4.2__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 (74) hide show
  1. {typedal-4.4.0 → typedal-4.4.2}/.crush/crush.db-shm +0 -0
  2. typedal-4.4.2/.crush/crush.db-wal +0 -0
  3. typedal-4.4.2/.crush/logs/crush.log +25 -0
  4. {typedal-4.4.0 → typedal-4.4.2}/CHANGELOG.md +16 -0
  5. {typedal-4.4.0 → typedal-4.4.2}/PKG-INFO +1 -1
  6. {typedal-4.4.0 → typedal-4.4.2}/docs/3_building_queries.md +2 -0
  7. {typedal-4.4.0 → typedal-4.4.2}/docs/8_mixins.md +4 -0
  8. typedal-4.4.2/docs/9_memoization.md +150 -0
  9. {typedal-4.4.0 → typedal-4.4.2}/docs/index.md +1 -0
  10. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/__about__.py +1 -1
  11. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/caching.py +15 -1
  12. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/cli.py +1 -2
  13. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/core.py +5 -1
  14. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/query_builder.py +9 -1
  15. {typedal-4.4.0 → typedal-4.4.2}/tests/test_query_builder.py +59 -25
  16. {typedal-4.4.0 → typedal-4.4.2}/tests/test_relationships.py +45 -0
  17. typedal-4.4.0/.crush/crush.db-wal +0 -0
  18. typedal-4.4.0/.crush/logs/crush.log +0 -37
  19. {typedal-4.4.0 → typedal-4.4.2}/.crush/.gitignore +0 -0
  20. {typedal-4.4.0 → typedal-4.4.2}/.crush/init +0 -0
  21. {typedal-4.4.0 → typedal-4.4.2}/.github/workflows/su6.yml +0 -0
  22. {typedal-4.4.0 → typedal-4.4.2}/.gitignore +0 -0
  23. {typedal-4.4.0 → typedal-4.4.2}/.readthedocs.yml +0 -0
  24. {typedal-4.4.0 → typedal-4.4.2}/README.md +0 -0
  25. {typedal-4.4.0 → typedal-4.4.2}/coverage.svg +0 -0
  26. {typedal-4.4.0 → typedal-4.4.2}/docs/1_getting_started.md +0 -0
  27. {typedal-4.4.0 → typedal-4.4.2}/docs/2_defining_tables.md +0 -0
  28. {typedal-4.4.0 → typedal-4.4.2}/docs/4_relationships.md +0 -0
  29. {typedal-4.4.0 → typedal-4.4.2}/docs/5_py4web.md +0 -0
  30. {typedal-4.4.0 → typedal-4.4.2}/docs/6_migrations.md +0 -0
  31. {typedal-4.4.0 → typedal-4.4.2}/docs/7_configuration.md +0 -0
  32. {typedal-4.4.0 → typedal-4.4.2}/docs/css/code_blocks.css +0 -0
  33. {typedal-4.4.0 → typedal-4.4.2}/docs/requirements.txt +0 -0
  34. {typedal-4.4.0 → typedal-4.4.2}/example_new.py +0 -0
  35. {typedal-4.4.0 → typedal-4.4.2}/example_old.py +0 -0
  36. {typedal-4.4.0 → typedal-4.4.2}/mkdocs.yml +0 -0
  37. {typedal-4.4.0 → typedal-4.4.2}/pyproject.toml +0 -0
  38. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/__init__.py +0 -0
  39. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/config.py +0 -0
  40. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/constants.py +0 -0
  41. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/define.py +0 -0
  42. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/fields.py +0 -0
  43. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/for_py4web.py +0 -0
  44. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/for_web2py.py +0 -0
  45. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/helpers.py +0 -0
  46. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/mixins.py +0 -0
  47. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/py.typed +0 -0
  48. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/relationships.py +0 -0
  49. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/rows.py +0 -0
  50. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/serializers/as_json.py +0 -0
  51. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/tables.py +0 -0
  52. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/types.py +0 -0
  53. {typedal-4.4.0 → typedal-4.4.2}/src/typedal/web2py_py4web_shared.py +0 -0
  54. {typedal-4.4.0 → typedal-4.4.2}/tests/__init__.py +0 -0
  55. {typedal-4.4.0 → typedal-4.4.2}/tests/configs/simple.toml +0 -0
  56. {typedal-4.4.0 → typedal-4.4.2}/tests/configs/valid.env +0 -0
  57. {typedal-4.4.0 → typedal-4.4.2}/tests/configs/valid.toml +0 -0
  58. {typedal-4.4.0 → typedal-4.4.2}/tests/py314_tests.py +0 -0
  59. {typedal-4.4.0 → typedal-4.4.2}/tests/test_cli.py +0 -0
  60. {typedal-4.4.0 → typedal-4.4.2}/tests/test_config.py +0 -0
  61. {typedal-4.4.0 → typedal-4.4.2}/tests/test_docs_examples.py +0 -0
  62. {typedal-4.4.0 → typedal-4.4.2}/tests/test_helpers.py +0 -0
  63. {typedal-4.4.0 → typedal-4.4.2}/tests/test_json.py +0 -0
  64. {typedal-4.4.0 → typedal-4.4.2}/tests/test_main.py +0 -0
  65. {typedal-4.4.0 → typedal-4.4.2}/tests/test_mixins.py +0 -0
  66. {typedal-4.4.0 → typedal-4.4.2}/tests/test_mypy.py +0 -0
  67. {typedal-4.4.0 → typedal-4.4.2}/tests/test_orm.py +0 -0
  68. {typedal-4.4.0 → typedal-4.4.2}/tests/test_py4web.py +0 -0
  69. {typedal-4.4.0 → typedal-4.4.2}/tests/test_row.py +0 -0
  70. {typedal-4.4.0 → typedal-4.4.2}/tests/test_stats.py +0 -0
  71. {typedal-4.4.0 → typedal-4.4.2}/tests/test_table.py +0 -0
  72. {typedal-4.4.0 → typedal-4.4.2}/tests/test_web2py.py +0 -0
  73. {typedal-4.4.0 → typedal-4.4.2}/tests/test_xx_others.py +0 -0
  74. {typedal-4.4.0 → typedal-4.4.2}/tests/timings.py +0 -0
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.2 (2026-01-26)
6
+
7
+ ### Fix
8
+
9
+ * **memoize:** Simplify `related_tables` logic by just looking at the raw Rows.fields ([`3dee337`](https://github.com/trialandsuccess/TypeDAL/commit/3dee3379a2bbcd493be273344303aff0aff069a7))
10
+
11
+ ## v4.4.1 (2026-01-26)
12
+
13
+ ### Fix
14
+
15
+ * Test and support memoize with empty table and .execute() ([`76ea39b`](https://github.com/trialandsuccess/TypeDAL/commit/76ea39bf4f8beb2ad303e66b69ad01780359ad14))
16
+
17
+ ### Documentation
18
+
19
+ * Add chapter 9 about `memoize` ([`e23a8c8`](https://github.com/trialandsuccess/TypeDAL/commit/e23a8c80ee60a5adb2d32c6d05fb398d5d703116))
20
+
5
21
  ## v4.4.0 (2026-01-20)
6
22
 
7
23
  ### Feature
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.4.0
3
+ Version: 4.4.2
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
@@ -217,6 +217,8 @@ In order to enable this functionality, TypeDAL adds a `before update` and `befor
217
217
  which manages the dependencies. You can disable this behavior by passing `cache_dependency=False` to `db.define`.
218
218
  Be aware doing this might break some caching functionality!
219
219
 
220
+ **Note:** For caching function results (instead of just query results), see [9. Function Memoization](./9_memoization.md).
221
+
220
222
  ### Collecting
221
223
 
222
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.4.0"
8
+ __version__ = "4.4.2"
@@ -577,16 +577,30 @@ def memoize(
577
577
  return cached, "cached"
578
578
  # Cache miss - compute result
579
579
 
580
- def track_collect(_qb: "QueryBuilder[t.Any]", _: TypedRows[t.Any], raw: Rows) -> None:
580
+ def track_execute(_qb: "QueryBuilder[t.Any]", raw: Rows):
581
581
  # find dependant table+id combinations, includes relationships:
582
582
  deps.update(_determine_dependencies_auto(raw))
583
583
 
584
+ related_tables = {
585
+ # skip qb.select, just look at the final fields in the raw data:
586
+ _get_table_name(field)
587
+ for field in raw.fields
588
+ }
589
+
590
+ # mark dependency for every relevant table in this query without id:
591
+ deps.update({(table, 0) for table in related_tables})
592
+
593
+ def track_collect(qb: "QueryBuilder[t.Any]", _: TypedRows[t.Any], raw: Rows) -> None:
594
+ return track_execute(qb, raw)
595
+
584
596
  # hooks every .collect() to track extra dependencies
585
597
  db._after_collect.append(track_collect)
598
+ db._after_execute.append(track_execute)
586
599
  try:
587
600
  result = func(*args, **kwargs)
588
601
  finally:
589
602
  db._after_collect.remove(track_collect)
603
+ db._after_execute.remove(track_execute)
590
604
 
591
605
  # Save to cache
592
606
  if isinstance(ttl, dt.datetime):
@@ -392,8 +392,7 @@ def fake_migrations(
392
392
 
393
393
  previously_migrated = (
394
394
  db(
395
- db.ewh_implemented_features.name.belongs(to_fake)
396
- & (db.ewh_implemented_features.installed == True) # noqa E712
395
+ db.ewh_implemented_features.name.belongs(to_fake) & (db.ewh_implemented_features.installed == True) # noqa E712
397
396
  )
398
397
  .select(db.ewh_implemented_features.name)
399
398
  .column("name")
@@ -148,10 +148,12 @@ class TypeDAL(pydal.DAL):
148
148
  _config: TypeDALConfig
149
149
  _builder: TableDefinitionBuilder
150
150
 
151
- # 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:
152
152
  # note: return values are ignored!
153
153
  _before_collect: list[t.Callable[["QueryBuilder[t.Any]"], None]]
154
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]]
155
157
 
156
158
  def __init__(
157
159
  self,
@@ -208,6 +210,8 @@ class TypeDAL(pydal.DAL):
208
210
 
209
211
  self._before_collect = []
210
212
  self._after_collect = []
213
+ self._before_execute = []
214
+ self._after_execute = []
211
215
 
212
216
  if config.folder:
213
217
  Path(config.folder).mkdir(exist_ok=True)
@@ -552,7 +552,15 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
552
552
 
553
553
  query, select_args, select_kwargs = self._before_query(metadata, add_id=add_id)
554
554
 
555
- 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
556
564
 
557
565
  def collect(
558
566
  self,
@@ -527,7 +527,7 @@ def test_minimal_functionality_on_pydal_style_tables():
527
527
  assert len(qb2) == 1
528
528
 
529
529
 
530
- def test_before_after_collect():
530
+ def test_before_after_collect(capsys):
531
531
  _setup_data()
532
532
 
533
533
  def print_query(qb: QueryBuilder):
@@ -543,16 +543,30 @@ def test_before_after_collect():
543
543
  db._after_collect.append(print_duration)
544
544
 
545
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
546
556
 
547
557
 
548
558
  def test_groupby_basic():
549
559
  """Test basic groupby with count aggregation."""
550
560
  _setup_data()
551
561
 
552
- result = TestRelationship.select(
553
- TestRelationship.querytable.with_alias("query_table"),
554
- TestRelationship.querytable.count().with_alias("count"),
555
- ).groupby(TestRelationship.querytable).execute()
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
+ )
556
570
 
557
571
  assert len(result) == 2
558
572
  for row in result:
@@ -563,11 +577,15 @@ def test_groupby_multiple_fields():
563
577
  """Test grouping by multiple fields."""
564
578
  _setup_data()
565
579
 
566
- result = TestRelationship.select(
567
- TestRelationship.querytable,
568
- TestRelationship.value,
569
- TestRelationship.id.count().with_alias("count"),
570
- ).groupby(TestRelationship.querytable, TestRelationship.value).execute()
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
+ )
571
589
 
572
590
  # Should group by combination of querytable and value
573
591
  assert len(result) > 0
@@ -577,10 +595,15 @@ def test_groupby_with_having():
577
595
  """Test groupby with having to filter groups."""
578
596
  _setup_data()
579
597
 
580
- result = TestRelationship.select(
581
- TestRelationship.querytable.with_alias("query_table"),
582
- TestRelationship.querytable.count().with_alias("count"),
583
- ).groupby(TestRelationship.querytable).having(TestRelationship.querytable.count() > 3).execute()
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
+ )
584
607
 
585
608
  # Only groups with count > 3
586
609
  assert len(result) == 2
@@ -593,16 +616,25 @@ def test_having_filters_aggregates():
593
616
  _setup_data()
594
617
 
595
618
  # Get all groups
596
- all_groups = TestRelationship.select(
597
- TestRelationship.querytable,
598
- TestRelationship.querytable.count().with_alias("count"),
599
- ).groupby(TestRelationship.querytable).execute()
619
+ all_groups = (
620
+ TestRelationship.select(
621
+ TestRelationship.querytable,
622
+ TestRelationship.querytable.count().with_alias("count"),
623
+ )
624
+ .groupby(TestRelationship.querytable)
625
+ .execute()
626
+ )
600
627
 
601
628
  # Filter with having
602
- filtered = TestRelationship.select(
603
- TestRelationship.querytable,
604
- TestRelationship.querytable.count().with_alias("count"),
605
- ).groupby(TestRelationship.querytable).having(TestRelationship.querytable.count() > 10).execute()
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
+ )
606
638
 
607
639
  # Should have fewer results (or zero if no groups have count > 10)
608
640
  assert len(filtered) <= len(all_groups)
@@ -610,9 +642,11 @@ def test_having_filters_aggregates():
610
642
 
611
643
  def test_groupby_to_sql():
612
644
  """Verify SQL generation includes GROUP BY."""
613
- sql = TestRelationship.select(
614
- TestRelationship.querytable, TestRelationship.querytable.count()
615
- ).groupby(TestRelationship.querytable).to_sql()
645
+ sql = (
646
+ TestRelationship.select(TestRelationship.querytable, TestRelationship.querytable.count())
647
+ .groupby(TestRelationship.querytable)
648
+ .to_sql()
649
+ )
616
650
 
617
651
  assert "GROUP BY" in sql
618
652
 
@@ -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
 
Binary file
@@ -1,37 +0,0 @@
1
- {"time":"2026-01-20T14:19:21.359487106+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-20T14:19:21.518115889+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-20T14:19:21.52626179+01:00","level":"INFO","msg":"OK 20250424200609_initial.sql (1.52ms)"}
4
- {"time":"2026-01-20T14:19:21.526651475+01:00","level":"INFO","msg":"OK 20250515105448_add_summary_message_id.sql (330.71µs)"}
5
- {"time":"2026-01-20T14:19:21.526995425+01:00","level":"INFO","msg":"OK 20250624000000_add_created_at_indexes.sql (325.29µs)"}
6
- {"time":"2026-01-20T14:19:21.527303588+01:00","level":"INFO","msg":"OK 20250627000000_add_provider_to_messages.sql (293.15µs)"}
7
- {"time":"2026-01-20T14:19:21.52790648+01:00","level":"INFO","msg":"OK 20250810000000_add_is_summary_message.sql (495.71µs)"}
8
- {"time":"2026-01-20T14:19:21.528316924+01:00","level":"INFO","msg":"OK 20250812000000_add_todos_to_sessions.sql (389.38µs)"}
9
- {"time":"2026-01-20T14:19:21.528324939+01:00","level":"INFO","msg":"goose: successfully migrated database to version: 20250812000000"}
10
- {"time":"2026-01-20T14:19:21.528356959+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"}
11
- {"time":"2026-01-20T14:19:21.52843831+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":102},"msg":"Initializing MCP clients"}
12
- {"time":"2026-01-20T14:21:06.413595152+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"}
13
- {"time":"2026-01-20T14:21:14.539289381+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"}
14
- {"time":"2026-01-20T14:21:15.710688424+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."}
15
- {"time":"2026-01-20T14:21:17.08089755+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"}
16
- {"time":"2026-01-20T14:21:17.151106375+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"}
17
- {"time":"2026-01-20T14:22:05.503761296+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"}
18
- {"time":"2026-01-20T14:26:05.030425805+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"}
19
- {"time":"2026-01-20T14:36:26.778665189+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"}
20
- {"time":"2026-01-20T14:38:51.314473736+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"}
21
- {"time":"2026-01-20T15:02:22.602405814+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":403},"msg":"Shutdown took 6.62949ms"}
22
- {"time":"2026-01-20T15:02:23.719585648+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"}
23
- {"time":"2026-01-20T15:02:23.881789126+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"}
24
- {"time":"2026-01-20T15:02:23.884044691+01:00","level":"INFO","msg":"goose: no migrations to run. current version: 20250812000000"}
25
- {"time":"2026-01-20T15:02:23.884082792+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"}
26
- {"time":"2026-01-20T15:02:23.884273487+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":102},"msg":"Initializing MCP clients"}
27
- {"time":"2026-01-20T15:04:13.024457515+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"}
28
- {"time":"2026-01-20T15:04:14.232072885+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."}
29
- {"time":"2026-01-20T15:11:24.612161916+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":403},"msg":"Shutdown took 9.537141ms"}
30
- {"time":"2026-01-20T15:11:25.263960575+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"}
31
- {"time":"2026-01-20T15:11:25.431990051+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"}
32
- {"time":"2026-01-20T15:11:25.434282035+01:00","level":"INFO","msg":"goose: no migrations to run. current version: 20250812000000"}
33
- {"time":"2026-01-20T15:11:25.434366662+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"}
34
- {"time":"2026-01-20T15:11:25.434579879+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/app.New.func1","file":"github.com/charmbracelet/crush/internal/app/app.go","line":102},"msg":"Initializing MCP clients"}
35
- {"time":"2026-01-20T15:24:40.456010578+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"}
36
- {"time":"2026-01-20T15:24:51.673808433+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."}
37
- {"time":"2026-01-20T15:26:03.868420566+01:00","level":"INFO","source":{"function":"github.com/charmbracelet/crush/internal/agent.(*sessionAgent).Cancel","file":"github.com/charmbracelet/crush/internal/agent/agent.go","line":907},"msg":"Request cancellation initiated","session_id":"4c2d1684-8db2-4ffd-83bb-9c08fe850f29"}
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