TypeDAL 4.3.5__tar.gz → 4.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. typedal-4.4.0/.crush/.gitignore +1 -0
  2. typedal-4.4.0/.crush/crush.db-shm +0 -0
  3. typedal-4.4.0/.crush/crush.db-wal +0 -0
  4. typedal-4.4.0/.crush/logs/crush.log +37 -0
  5. {typedal-4.3.5 → typedal-4.4.0}/CHANGELOG.md +12 -0
  6. {typedal-4.3.5 → typedal-4.4.0}/PKG-INFO +1 -1
  7. {typedal-4.3.5 → typedal-4.4.0}/docs/3_building_queries.md +31 -0
  8. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/__about__.py +1 -1
  9. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/caching.py +6 -1
  10. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/cli.py +2 -1
  11. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/core.py +3 -2
  12. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/query_builder.py +29 -5
  13. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/tables.py +12 -0
  14. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/types.py +4 -0
  15. typedal-4.4.0/tests/__init__.py +0 -0
  16. {typedal-4.3.5 → typedal-4.4.0}/tests/test_config.py +18 -0
  17. {typedal-4.3.5 → typedal-4.4.0}/tests/test_query_builder.py +128 -19
  18. /typedal-4.3.5/src/typedal/py.typed → /typedal-4.4.0/.crush/init +0 -0
  19. {typedal-4.3.5 → typedal-4.4.0}/.github/workflows/su6.yml +0 -0
  20. {typedal-4.3.5 → typedal-4.4.0}/.gitignore +0 -0
  21. {typedal-4.3.5 → typedal-4.4.0}/.readthedocs.yml +0 -0
  22. {typedal-4.3.5 → typedal-4.4.0}/README.md +0 -0
  23. {typedal-4.3.5 → typedal-4.4.0}/coverage.svg +0 -0
  24. {typedal-4.3.5 → typedal-4.4.0}/docs/1_getting_started.md +0 -0
  25. {typedal-4.3.5 → typedal-4.4.0}/docs/2_defining_tables.md +0 -0
  26. {typedal-4.3.5 → typedal-4.4.0}/docs/4_relationships.md +0 -0
  27. {typedal-4.3.5 → typedal-4.4.0}/docs/5_py4web.md +0 -0
  28. {typedal-4.3.5 → typedal-4.4.0}/docs/6_migrations.md +0 -0
  29. {typedal-4.3.5 → typedal-4.4.0}/docs/7_configuration.md +0 -0
  30. {typedal-4.3.5 → typedal-4.4.0}/docs/8_mixins.md +0 -0
  31. {typedal-4.3.5 → typedal-4.4.0}/docs/css/code_blocks.css +0 -0
  32. {typedal-4.3.5 → typedal-4.4.0}/docs/index.md +0 -0
  33. {typedal-4.3.5 → typedal-4.4.0}/docs/requirements.txt +0 -0
  34. {typedal-4.3.5 → typedal-4.4.0}/example_new.py +0 -0
  35. {typedal-4.3.5 → typedal-4.4.0}/example_old.py +0 -0
  36. {typedal-4.3.5 → typedal-4.4.0}/mkdocs.yml +0 -0
  37. {typedal-4.3.5 → typedal-4.4.0}/pyproject.toml +0 -0
  38. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/__init__.py +0 -0
  39. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/config.py +0 -0
  40. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/constants.py +0 -0
  41. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/define.py +0 -0
  42. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/fields.py +0 -0
  43. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/for_py4web.py +0 -0
  44. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/for_web2py.py +0 -0
  45. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/helpers.py +0 -0
  46. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/mixins.py +0 -0
  47. /typedal-4.3.5/tests/__init__.py → /typedal-4.4.0/src/typedal/py.typed +0 -0
  48. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/relationships.py +0 -0
  49. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/rows.py +0 -0
  50. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/serializers/as_json.py +0 -0
  51. {typedal-4.3.5 → typedal-4.4.0}/src/typedal/web2py_py4web_shared.py +0 -0
  52. {typedal-4.3.5 → typedal-4.4.0}/tests/configs/simple.toml +0 -0
  53. {typedal-4.3.5 → typedal-4.4.0}/tests/configs/valid.env +0 -0
  54. {typedal-4.3.5 → typedal-4.4.0}/tests/configs/valid.toml +0 -0
  55. {typedal-4.3.5 → typedal-4.4.0}/tests/py314_tests.py +0 -0
  56. {typedal-4.3.5 → typedal-4.4.0}/tests/test_cli.py +0 -0
  57. {typedal-4.3.5 → typedal-4.4.0}/tests/test_docs_examples.py +0 -0
  58. {typedal-4.3.5 → typedal-4.4.0}/tests/test_helpers.py +0 -0
  59. {typedal-4.3.5 → typedal-4.4.0}/tests/test_json.py +0 -0
  60. {typedal-4.3.5 → typedal-4.4.0}/tests/test_main.py +0 -0
  61. {typedal-4.3.5 → typedal-4.4.0}/tests/test_mixins.py +0 -0
  62. {typedal-4.3.5 → typedal-4.4.0}/tests/test_mypy.py +0 -0
  63. {typedal-4.3.5 → typedal-4.4.0}/tests/test_orm.py +0 -0
  64. {typedal-4.3.5 → typedal-4.4.0}/tests/test_py4web.py +0 -0
  65. {typedal-4.3.5 → typedal-4.4.0}/tests/test_relationships.py +0 -0
  66. {typedal-4.3.5 → typedal-4.4.0}/tests/test_row.py +0 -0
  67. {typedal-4.3.5 → typedal-4.4.0}/tests/test_stats.py +0 -0
  68. {typedal-4.3.5 → typedal-4.4.0}/tests/test_table.py +0 -0
  69. {typedal-4.3.5 → typedal-4.4.0}/tests/test_web2py.py +0 -0
  70. {typedal-4.3.5 → typedal-4.4.0}/tests/test_xx_others.py +0 -0
  71. {typedal-4.3.5 → typedal-4.4.0}/tests/timings.py +0 -0
@@ -0,0 +1 @@
1
+ *
Binary file
Binary file
@@ -0,0 +1,37 @@
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"}
@@ -2,6 +2,18 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v4.4.0 (2026-01-20)
6
+
7
+ ### Feature
8
+
9
+ * Add groupby and having methods to QueryBuilder ([`8bce7ec`](https://github.com/trialandsuccess/TypeDAL/commit/8bce7ec137db79ab1631f4504ae891b5bd42c1d8))
10
+
11
+ ## v4.3.6 (2026-01-19)
12
+
13
+ ### Fix
14
+
15
+ * Ensure typedal caching respects provided 'migrate' enabled config ([`0bcc277`](https://github.com/trialandsuccess/TypeDAL/commit/0bcc2774dcec9231dc8a58438dd70d3ce29650e8))
16
+
5
17
  ## v4.3.5 (2026-01-09)
6
18
 
7
19
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.3.5
3
+ Version: 4.4.0
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
@@ -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.5"
8
+ __version__ = "4.4.0"
@@ -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:
@@ -392,7 +392,8 @@ def fake_migrations(
392
392
 
393
393
  previously_migrated = (
394
394
  db(
395
- db.ewh_implemented_features.name.belongs(to_fake) & (db.ewh_implemented_features.installed == True) # noqa E712
395
+ db.ewh_implemented_features.name.belongs(to_fake)
396
+ & (db.ewh_implemented_features.installed == True) # noqa E712
396
397
  )
397
398
  .select(db.ewh_implemented_features.name)
398
399
  .column("name")
@@ -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
@@ -246,7 +247,7 @@ class TypeDAL(pydal.DAL):
246
247
  Try to define a model with migrate or fall back to fake migrate.
247
248
  """
248
249
  try:
249
- return self.define(model, migrate=True)
250
+ return self.define(model, migrate=self._migrate)
250
251
  except Exception as e:
251
252
  # clean up:
252
253
  self.rollback()
@@ -257,7 +258,7 @@ class TypeDAL(pydal.DAL):
257
258
  warnings.warn(f"{model} could not be migrated, try faking", source=e, category=RuntimeWarning)
258
259
 
259
260
  # try again:
260
- return self.define(model, migrate=True, fake_migrate=True, redefine=True)
261
+ return self.define(model, migrate=self._migrate, fake_migrate=self._migrate, redefine=True)
261
262
 
262
263
  default_kwargs: t.ClassVar[AnyDict] = {
263
264
  # fields are 'required' (notnull) by default:
@@ -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,7 +548,7 @@ 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
 
@@ -552,7 +576,7 @@ class QueryBuilder(t.Generic[T_MetaInstance]):
552
576
  for fn_before in db._before_collect:
553
577
  fn_before(self)
554
578
 
555
- metadata = self.metadata.copy()
579
+ metadata: Metadata = self.metadata.copy()
556
580
 
557
581
  if metadata.get("cache", {}).get("enabled") and (result := self._collect_cached(metadata)):
558
582
  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
@@ -1,6 +1,7 @@
1
1
  import datetime as dt
2
2
  import os
3
3
  import shutil
4
+ import sqlite3
4
5
  import tempfile
5
6
  import uuid
6
7
  from pathlib import Path
@@ -331,3 +332,20 @@ def test_uuid_fields_sqlite(at_temp_dir):
331
332
  UUIDTable.insert(gid="not-a-uuid")
332
333
 
333
334
  assert '"gid" uuid NOT NULL' in UUIDTable._sql()
335
+
336
+
337
+ def test_cache_migrate_disabled():
338
+ try:
339
+ os.environ["TYPEDAL_MIGRATE"] = "0"
340
+
341
+ db = TypeDAL("sqlite:memory")
342
+
343
+ assert db._migrate == False
344
+ with pytest.raises(sqlite3.OperationalError):
345
+ # should raise NotFound because migrate is disabled:
346
+ assert db.executesql("""
347
+ select * from typedal_cache;
348
+ """)
349
+
350
+ finally:
351
+ del os.environ["TYPEDAL_MIGRATE"]
@@ -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)
@@ -562,3 +543,131 @@ def test_before_after_collect():
562
543
  db._after_collect.append(print_duration)
563
544
 
564
545
  TestQueryTable.all()
546
+
547
+
548
+ def test_groupby_basic():
549
+ """Test basic groupby with count aggregation."""
550
+ _setup_data()
551
+
552
+ result = TestRelationship.select(
553
+ TestRelationship.querytable.with_alias("query_table"),
554
+ TestRelationship.querytable.count().with_alias("count"),
555
+ ).groupby(TestRelationship.querytable).execute()
556
+
557
+ assert len(result) == 2
558
+ for row in result:
559
+ assert row["count"] == 4
560
+
561
+
562
+ def test_groupby_multiple_fields():
563
+ """Test grouping by multiple fields."""
564
+ _setup_data()
565
+
566
+ result = TestRelationship.select(
567
+ TestRelationship.querytable,
568
+ TestRelationship.value,
569
+ TestRelationship.id.count().with_alias("count"),
570
+ ).groupby(TestRelationship.querytable, TestRelationship.value).execute()
571
+
572
+ # Should group by combination of querytable and value
573
+ assert len(result) > 0
574
+
575
+
576
+ def test_groupby_with_having():
577
+ """Test groupby with having to filter groups."""
578
+ _setup_data()
579
+
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()
584
+
585
+ # Only groups with count > 3
586
+ assert len(result) == 2
587
+ for row in result:
588
+ assert row["count"] > 3
589
+
590
+
591
+ def test_having_filters_aggregates():
592
+ """Test that having properly filters based on aggregate conditions."""
593
+ _setup_data()
594
+
595
+ # Get all groups
596
+ all_groups = TestRelationship.select(
597
+ TestRelationship.querytable,
598
+ TestRelationship.querytable.count().with_alias("count"),
599
+ ).groupby(TestRelationship.querytable).execute()
600
+
601
+ # 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()
606
+
607
+ # Should have fewer results (or zero if no groups have count > 10)
608
+ assert len(filtered) <= len(all_groups)
609
+
610
+
611
+ def test_groupby_to_sql():
612
+ """Verify SQL generation includes GROUP BY."""
613
+ sql = TestRelationship.select(
614
+ TestRelationship.querytable, TestRelationship.querytable.count()
615
+ ).groupby(TestRelationship.querytable).to_sql()
616
+
617
+ assert "GROUP BY" in sql
618
+
619
+
620
+ def test_having_to_sql():
621
+ """Verify SQL generation includes HAVING."""
622
+ sql = (
623
+ TestRelationship.select(TestRelationship.querytable, TestRelationship.querytable.count())
624
+ .groupby(TestRelationship.querytable)
625
+ .having(TestRelationship.querytable.count() > 0)
626
+ .to_sql()
627
+ )
628
+
629
+ assert "GROUP BY" in sql
630
+ assert "HAVING" in sql
631
+
632
+
633
+ def test_groupby_chaining():
634
+ """Test that multiple groupby calls work (last one should win)."""
635
+ _setup_data()
636
+
637
+ # First groupby by querytable
638
+ builder1 = TestRelationship.select(
639
+ TestRelationship.querytable, TestRelationship.querytable.count().with_alias("count")
640
+ ).groupby(TestRelationship.querytable)
641
+
642
+ # Then groupby by value (should override)
643
+ builder2 = builder1.groupby(TestRelationship.value)
644
+
645
+ sql = builder2.to_sql()
646
+ # Should only have the second groupby
647
+ assert "GROUP BY" in sql
648
+
649
+
650
+ def test_groupby_having_on_table_class():
651
+ """Test calling .groupby() and .having() directly on table class in different orders."""
652
+ _setup_data()
653
+
654
+ builder1 = (
655
+ TestRelationship.groupby(TestRelationship.querytable)
656
+ .having(TestRelationship.querytable.count() > 0)
657
+ .select(TestRelationship.querytable, TestRelationship.querytable.count())
658
+ )
659
+
660
+ sql1 = builder1.to_sql()
661
+
662
+ builder2 = (
663
+ TestRelationship.having(TestRelationship.querytable.count() > 0)
664
+ .groupby(TestRelationship.querytable)
665
+ .select(TestRelationship.querytable, TestRelationship.querytable.count())
666
+ )
667
+ sql2 = builder2.to_sql()
668
+
669
+ assert sql1 == sql2
670
+ assert "GROUP BY" in sql1
671
+ assert "HAVING" in sql1
672
+
673
+ assert builder1.execute() == builder2.execute()
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