TypeDAL 4.2.1__tar.gz → 4.3.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 (66) hide show
  1. {typedal-4.2.1 → typedal-4.3.0}/CHANGELOG.md +12 -0
  2. {typedal-4.2.1 → typedal-4.3.0}/PKG-INFO +1 -1
  3. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/__about__.py +1 -1
  4. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/caching.py +196 -24
  5. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/core.py +30 -2
  6. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/define.py +2 -1
  7. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/relationships.py +23 -0
  8. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/types.py +4 -1
  9. {typedal-4.2.1 → typedal-4.3.0}/tests/test_relationships.py +68 -0
  10. {typedal-4.2.1 → typedal-4.3.0}/.github/workflows/su6.yml +0 -0
  11. {typedal-4.2.1 → typedal-4.3.0}/.gitignore +0 -0
  12. {typedal-4.2.1 → typedal-4.3.0}/.readthedocs.yml +0 -0
  13. {typedal-4.2.1 → typedal-4.3.0}/README.md +0 -0
  14. {typedal-4.2.1 → typedal-4.3.0}/coverage.svg +0 -0
  15. {typedal-4.2.1 → typedal-4.3.0}/docs/1_getting_started.md +0 -0
  16. {typedal-4.2.1 → typedal-4.3.0}/docs/2_defining_tables.md +0 -0
  17. {typedal-4.2.1 → typedal-4.3.0}/docs/3_building_queries.md +0 -0
  18. {typedal-4.2.1 → typedal-4.3.0}/docs/4_relationships.md +0 -0
  19. {typedal-4.2.1 → typedal-4.3.0}/docs/5_py4web.md +0 -0
  20. {typedal-4.2.1 → typedal-4.3.0}/docs/6_migrations.md +0 -0
  21. {typedal-4.2.1 → typedal-4.3.0}/docs/7_configuration.md +0 -0
  22. {typedal-4.2.1 → typedal-4.3.0}/docs/8_mixins.md +0 -0
  23. {typedal-4.2.1 → typedal-4.3.0}/docs/css/code_blocks.css +0 -0
  24. {typedal-4.2.1 → typedal-4.3.0}/docs/index.md +0 -0
  25. {typedal-4.2.1 → typedal-4.3.0}/docs/requirements.txt +0 -0
  26. {typedal-4.2.1 → typedal-4.3.0}/example_new.py +0 -0
  27. {typedal-4.2.1 → typedal-4.3.0}/example_old.py +0 -0
  28. {typedal-4.2.1 → typedal-4.3.0}/mkdocs.yml +0 -0
  29. {typedal-4.2.1 → typedal-4.3.0}/pyproject.toml +0 -0
  30. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/__init__.py +0 -0
  31. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/cli.py +0 -0
  32. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/config.py +0 -0
  33. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/constants.py +0 -0
  34. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/fields.py +0 -0
  35. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/for_py4web.py +0 -0
  36. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/for_web2py.py +0 -0
  37. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/helpers.py +0 -0
  38. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/mixins.py +0 -0
  39. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/py.typed +0 -0
  40. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/query_builder.py +0 -0
  41. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/rows.py +0 -0
  42. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/serializers/as_json.py +0 -0
  43. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/tables.py +0 -0
  44. {typedal-4.2.1 → typedal-4.3.0}/src/typedal/web2py_py4web_shared.py +0 -0
  45. {typedal-4.2.1 → typedal-4.3.0}/tests/__init__.py +0 -0
  46. {typedal-4.2.1 → typedal-4.3.0}/tests/configs/simple.toml +0 -0
  47. {typedal-4.2.1 → typedal-4.3.0}/tests/configs/valid.env +0 -0
  48. {typedal-4.2.1 → typedal-4.3.0}/tests/configs/valid.toml +0 -0
  49. {typedal-4.2.1 → typedal-4.3.0}/tests/py314_tests.py +0 -0
  50. {typedal-4.2.1 → typedal-4.3.0}/tests/test_cli.py +0 -0
  51. {typedal-4.2.1 → typedal-4.3.0}/tests/test_config.py +0 -0
  52. {typedal-4.2.1 → typedal-4.3.0}/tests/test_docs_examples.py +0 -0
  53. {typedal-4.2.1 → typedal-4.3.0}/tests/test_helpers.py +0 -0
  54. {typedal-4.2.1 → typedal-4.3.0}/tests/test_json.py +0 -0
  55. {typedal-4.2.1 → typedal-4.3.0}/tests/test_main.py +0 -0
  56. {typedal-4.2.1 → typedal-4.3.0}/tests/test_mixins.py +0 -0
  57. {typedal-4.2.1 → typedal-4.3.0}/tests/test_mypy.py +0 -0
  58. {typedal-4.2.1 → typedal-4.3.0}/tests/test_orm.py +0 -0
  59. {typedal-4.2.1 → typedal-4.3.0}/tests/test_py4web.py +0 -0
  60. {typedal-4.2.1 → typedal-4.3.0}/tests/test_query_builder.py +0 -0
  61. {typedal-4.2.1 → typedal-4.3.0}/tests/test_row.py +0 -0
  62. {typedal-4.2.1 → typedal-4.3.0}/tests/test_stats.py +0 -0
  63. {typedal-4.2.1 → typedal-4.3.0}/tests/test_table.py +0 -0
  64. {typedal-4.2.1 → typedal-4.3.0}/tests/test_web2py.py +0 -0
  65. {typedal-4.2.1 → typedal-4.3.0}/tests/test_xx_others.py +0 -0
  66. {typedal-4.2.1 → typedal-4.3.0}/tests/timings.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  <!--next-version-placeholder-->
4
4
 
5
+ ## v4.3.0 (2026-01-06)
6
+
7
+ ### Feature
8
+
9
+ * Re-use database caching logic for function memo ([`4c9ad68`](https://github.com/trialandsuccess/TypeDAL/commit/4c9ad686e5d2041db91981c856d5a748f0d50752))
10
+
11
+ ## v4.2.2 (2025-12-10)
12
+
13
+ ### Fix
14
+
15
+ * Improved type hints for relationships (when join='inner') ([`e54cf91`](https://github.com/trialandsuccess/TypeDAL/commit/e54cf918461757b2fd146f1f3c7d9957c723f68c))
16
+
5
17
  ## v4.2.1 (2025-12-10)
6
18
 
7
19
  ### Fix
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TypeDAL
3
- Version: 4.2.1
3
+ Version: 4.3.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
@@ -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.2.1"
8
+ __version__ = "4.3.0"
@@ -14,7 +14,7 @@ from pydal.objects import Field, Rows, Set
14
14
  from .fields import TypedField
15
15
  from .rows import TypedRows
16
16
  from .tables import TypedTable
17
- from .types import Query
17
+ from .types import CacheStatus, Query
18
18
 
19
19
  if t.TYPE_CHECKING:
20
20
  from .core import TypeDAL
@@ -159,6 +159,17 @@ def remove_cache(idx: int | t.Iterable[int], table: str) -> None:
159
159
  _TypedalCache.where(_TypedalCache.id.belongs(related)).delete()
160
160
 
161
161
 
162
+ def remove_cache_for_table(table: str) -> None:
163
+ """
164
+ Remove all cache entries that depend on a table.
165
+
166
+ Used for inserts where we don't know which cached queries
167
+ the new row would match.
168
+ """
169
+ related = _TypedalCacheDependency.where(table=table).select("entry").to_sql()
170
+ _TypedalCache.where(_TypedalCache.id.belongs(related)).delete()
171
+
172
+
162
173
  def clear_cache() -> None:
163
174
  """
164
175
  Remove everything from the cache.
@@ -210,6 +221,27 @@ def get_expire(
210
221
  return None
211
222
 
212
223
 
224
+ def _insert_cache_entry(
225
+ db: "TypeDAL",
226
+ key: str,
227
+ data: t.Any,
228
+ expires_at: dt.datetime | None,
229
+ deps: DependencyTupleSet,
230
+ ) -> None:
231
+ """
232
+ Shared internal function to insert cache entry and dependencies.
233
+ """
234
+ entry = _TypedalCache.insert(
235
+ key=key,
236
+ data=dill.dumps(data),
237
+ expires_at=expires_at,
238
+ )
239
+
240
+ _TypedalCacheDependency.bulk_insert([{"entry": entry, "table": table, "idx": idx} for table, idx in deps])
241
+
242
+ db.commit()
243
+
244
+
213
245
  def save_to_cache(
214
246
  instance: TypedRows[T_TypedTable],
215
247
  rows: Rows,
@@ -224,39 +256,85 @@ def save_to_cache(
224
256
  db = rows.db
225
257
  if (c := instance.metadata.get("cache", {})) and c.get("enabled") and (key := c.get("key")):
226
258
  expires_at = get_expire(expires_at=expires_at, ttl=ttl) or c.get("expires_at")
227
-
228
259
  deps = _determine_dependencies(instance, rows, c["depends_on"])
229
260
 
230
- entry = _TypedalCache.insert(
231
- key=key,
232
- data=dill.dumps(instance),
233
- expires_at=expires_at,
234
- )
235
-
236
- _TypedalCacheDependency.bulk_insert([{"entry": entry, "table": table, "idx": idx} for table, idx in deps])
261
+ _insert_cache_entry(db, key, instance, expires_at, deps)
237
262
 
238
- db.commit()
239
263
  instance.metadata["cache"]["status"] = "fresh"
240
264
  return instance
241
265
 
242
266
 
243
- def _load_from_cache(key: str, db: "TypeDAL") -> t.Any | None:
244
- if not (row := _TypedalCache.where(key=key).first()):
267
+ class CacheMiss:
268
+ """Sentinel class to represent a cache miss, distinguishing it from a None value."""
269
+
270
+ def __bool__(self) -> bool: # pragma: no cover
271
+ """
272
+ Ensures the sentinel evaluates to False in boolean contexts.
273
+
274
+ Example:
275
+ res = _load_memoize_from_cache("key")
276
+ if not res: # Triggers if a CacheMiss occurs
277
+ ...
278
+ """
279
+ return False
280
+
281
+
282
+ _CACHE_SENTINEL: t.Final[CacheMiss] = CacheMiss()
283
+
284
+
285
+ def _fetch_cached_payload(key: str) -> tuple[t.Any, t.Any] | None:
286
+ """
287
+ Retrieves and validates a cache entry from the database.
288
+
289
+ If the entry exists but is expired, it is deleted from the database
290
+ to maintain storage hygiene.
291
+
292
+ Args:
293
+ key: The unique string identifier for the cache entry.
294
+
295
+ Returns:
296
+ A tuple of (deserialized_data, db_row) if valid; None if missing or expired.
297
+ """
298
+ row = _TypedalCache.where(key=key).first()
299
+ if not row:
245
300
  return None
246
301
 
247
302
  now = get_now()
248
-
303
+ # Ensure comparison is offset-aware if the row has a timestamp
249
304
  expires = row.expires_at.replace(tzinfo=dt.timezone.utc) if row.expires_at else None
250
305
 
251
306
  if expires and now >= expires:
252
307
  row.delete_record()
253
308
  return None
254
309
 
255
- inst = dill.loads(row.data) # nosec
310
+ # Only one place for deserialization to happen
311
+ return dill.loads(row.data), row # nosec
312
+
313
+
314
+ def _load_from_cache(key: str, db: "TypeDAL") -> t.Any | None:
315
+ """
316
+ Loads a specific TypeDAL model instance from the cache and re-hydrates it.
317
+
318
+ This binds the cached object back to the active database connection and
319
+ restores its instance methods/metadata.
320
+
321
+ Args:
322
+ key: Cache key.
323
+ db: The active TypeDAL database instance.
324
+
325
+ Returns:
326
+ The re-hydrated model instance, or None if the load fails.
327
+ """
328
+ result = _fetch_cached_payload(key)
329
+ if not result:
330
+ return None
331
+
332
+ inst, row = result
256
333
 
257
- inst.metadata["cache"]["status"] = "cached"
258
- inst.metadata["cache"]["cached_at"] = row.cached_at
259
- inst.metadata["cache"]["expires_at"] = row.expires_at
334
+ # Re-hydrate the model instance with metadata and DB context
335
+ inst.metadata.setdefault("cache", {}).update(
336
+ {"status": "cached", "cached_at": row.cached_at, "expires_at": row.expires_at},
337
+ )
260
338
 
261
339
  inst.db = db
262
340
  inst.model = db._class_map[inst.model]
@@ -264,16 +342,43 @@ def _load_from_cache(key: str, db: "TypeDAL") -> t.Any | None:
264
342
  return inst
265
343
 
266
344
 
267
- def load_from_cache(key: str, db: "TypeDAL") -> t.Any | None:
345
+ def _load_memoize_from_cache(key: str) -> t.Any:
268
346
  """
269
- If 'key' matches a non-expired row in the database, try to load the dill.
347
+ Low-level retrieval for memoized results.
348
+
349
+ Used when the caller doesn't need TypeDAL model re-hydration, just the raw data.
270
350
 
271
- If anything fails, return None.
351
+ Args:
352
+ key: Cache key.
353
+
354
+ Returns:
355
+ The deserialized data or the _CACHE_SENTINEL object if not found.
272
356
  """
273
357
  with contextlib.suppress(Exception):
274
- return _load_from_cache(key, db)
358
+ if result := _fetch_cached_payload(key):
359
+ return result[0]
275
360
 
276
- return None # pragma: no cover
361
+ return _CACHE_SENTINEL
362
+
363
+
364
+ def load_from_cache(key: str, db: "TypeDAL") -> t.Any | None:
365
+ """
366
+ Public entry point to load model instances from cache.
367
+
368
+ Wraps the internal loader in a broad exception handler to ensure that
369
+ cache failures never crash the main application flow.
370
+
371
+ Args:
372
+ key: Cache key.
373
+ db: The active TypeDAL database instance.
374
+
375
+ Returns:
376
+ The model instance or None on any failure/miss.
377
+ """
378
+ try:
379
+ return _load_from_cache(key, db)
380
+ except Exception: # pragma: no cover
381
+ return None
277
382
 
278
383
 
279
384
  def humanize_bytes(size: int | float) -> str:
@@ -319,7 +424,9 @@ RowStats = t.TypedDict(
319
424
  def _row_stats(db: "TypeDAL", table: str, query: Query) -> RowStats:
320
425
  count_field = _TypedalCacheDependency.entry.count()
321
426
  stats: TypedRows[_TypedalCacheDependency] = db(query & (_TypedalCacheDependency.table == table)).select(
322
- _TypedalCacheDependency.entry, count_field, groupby=_TypedalCacheDependency.entry
427
+ _TypedalCacheDependency.entry,
428
+ count_field,
429
+ groupby=_TypedalCacheDependency.entry,
323
430
  )
324
431
  return {
325
432
  "Dependent Cache Entries": len(stats),
@@ -353,7 +460,9 @@ TableStats = t.TypedDict(
353
460
  def _table_stats(db: "TypeDAL", table: str, query: Query) -> TableStats:
354
461
  count_field = _TypedalCacheDependency.entry.count()
355
462
  stats: TypedRows[_TypedalCacheDependency] = db(query & (_TypedalCacheDependency.table == table)).select(
356
- _TypedalCacheDependency.entry, count_field, groupby=_TypedalCacheDependency.entry
463
+ _TypedalCacheDependency.entry,
464
+ count_field,
465
+ groupby=_TypedalCacheDependency.entry,
357
466
  )
358
467
  return {
359
468
  "Dependent Cache Entries": len(stats),
@@ -408,3 +517,66 @@ def calculate_stats(db: "TypeDAL") -> Stats[GenericStats]:
408
517
  "valid": _calculate_stats(db, _TypedalCache.id.belongs(valid_items)),
409
518
  "expired": _calculate_stats(db, _TypedalCache.id.belongs(expired_items)),
410
519
  }
520
+
521
+
522
+ def memoize(
523
+ db: "TypeDAL",
524
+ func: t.Callable[..., T],
525
+ *args: TypedRows[t.Any] | TypedTable,
526
+ key: str | None = None,
527
+ ttl: int | dt.timedelta | dt.datetime | None = None,
528
+ **kwargs: t.Any,
529
+ ) -> tuple[T, CacheStatus]:
530
+ """
531
+ Cache the result of a function applied to TypedRow(s).
532
+
533
+ Tracks dependencies on the table(s) so the cache invalidates
534
+ when those rows are updated/deleted.
535
+
536
+ Args:
537
+ db: TypeDAL database
538
+ func: Function to cache
539
+ *args: TypedRow/TypedRows instances only
540
+ key: Cache key (required for lambdas)
541
+ ttl: Time to live in seconds/timedelta, or datetime to expire at
542
+ **kwargs: Extra parameters passed to func
543
+
544
+ Returns:
545
+ tuple of (result, cache_status)
546
+ """
547
+ if not key:
548
+ if func.__name__ == "<lambda>":
549
+ raise ValueError(
550
+ "Lambda functions require explicit 'key' parameter. Use: db.memoize(your_func, data, key='my_key')",
551
+ )
552
+ key = func.__qualname__
553
+
554
+ # Extract dependencies from args
555
+ deps: DependencyTupleSet = set()
556
+ for arg in args:
557
+ if isinstance(arg, TypedRows):
558
+ for row in arg:
559
+ deps.add((str(row._table), row.id))
560
+ elif isinstance(arg, TypedTable):
561
+ deps.add((str(arg._table), arg.id))
562
+
563
+ # Generate cache key
564
+ _, hashed_key = create_and_hash_cache_key(key, *[getattr(arg, "id", None) for arg in args], kwargs)
565
+
566
+ # Try to load from cache
567
+ cached = _load_memoize_from_cache(hashed_key)
568
+ if cached is not _CACHE_SENTINEL:
569
+ return cached, "cached"
570
+
571
+ # Cache miss - compute result
572
+ result = func(*args, **kwargs)
573
+
574
+ # Save to cache
575
+ if isinstance(ttl, dt.datetime):
576
+ expires_at: dt.datetime | None = ttl
577
+ else:
578
+ expires_at = get_expire(ttl=ttl)
579
+
580
+ _insert_cache_entry(db, hashed_key, result, expires_at, deps)
581
+
582
+ return result, "fresh"
@@ -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
@@ -20,7 +21,7 @@ from .helpers import (
20
21
  sql_expression,
21
22
  to_snake,
22
23
  )
23
- from .types import Field, T, Template # type: ignore
24
+ from .types import CacheStatus, Field, T, Template # type: ignore
24
25
 
25
26
  try:
26
27
  # python 3.14+
@@ -446,6 +447,32 @@ class TypeDAL(pydal.DAL):
446
447
  """
447
448
  return sql_expression(self, sql_fragment, *raw_args, output_type=output_type, **raw_kwargs)
448
449
 
450
+ def memoize(
451
+ self,
452
+ func: t.Callable[..., T],
453
+ *args: TypedRows[t.Type[TypedTable]] | TypedTable, # type: ignore
454
+ key: str | None = None,
455
+ ttl: int | dt.timedelta | dt.datetime | None = None,
456
+ **kwargs: t.Any, # P.kwargs would be nice but they don't work without .args
457
+ ) -> tuple[T, CacheStatus]:
458
+ """
459
+ Cache the result of a function applied to TypedRow(s).
460
+
461
+ Tracks dependencies on the table(s) so the cache invalidates
462
+ when those rows are updated/deleted.
463
+
464
+ Args:
465
+ func: Function to cache
466
+ *args: Can contain TypedRow, TypedRows, or other args
467
+ key: Cache key (required for lambdas)
468
+ ttl: Time to live in seconds/timedelta, or datetime to expire at
469
+ **kwargs: Passed to func
470
+
471
+ Returns:
472
+ Cached result or fresh computation
473
+ """
474
+ return memoize(self, func, *args, key=key, ttl=ttl, **kwargs)
475
+
449
476
 
450
477
  TypeDAL.representers.setdefault("rows_render", default_representer)
451
478
 
@@ -453,10 +480,11 @@ TypeDAL.representers.setdefault("rows_render", default_representer)
453
480
 
454
481
  from .fields import * # noqa: E402 F403 # isort: skip ; to fill globals() scope
455
482
  from .define import TableDefinitionBuilder # noqa: E402
456
- from .rows import TypedSet # noqa: E402
483
+ from .rows import TypedRows, TypedSet # noqa: E402
457
484
  from .tables import TypedTable # noqa: E402
458
485
 
459
486
  from .caching import ( # isort: skip # noqa: E402
487
+ memoize,
460
488
  _TypedalCache,
461
489
  _TypedalCacheDependency,
462
490
  )
@@ -109,8 +109,9 @@ class TableDefinitionBuilder:
109
109
  warnings.warn("db.define used without inheriting TypedTable. This could lead to strange problems!")
110
110
 
111
111
  if not tablename.startswith("typedal_") and cache_dependency:
112
- from .caching import _remove_cache
112
+ from .caching import _remove_cache, remove_cache_for_table
113
113
 
114
+ table._after_insert.append(lambda _row, _id: remove_cache_for_table(tablename))
114
115
  table._before_update.append(lambda s, _: _remove_cache(s, tablename))
115
116
  table._before_delete.append(lambda s: _remove_cache(s, tablename))
116
117
 
@@ -277,6 +277,28 @@ def relationship(
277
277
  """
278
278
 
279
279
 
280
+ @t.overload
281
+ def relationship(
282
+ _type: t.Type[To_Type] | str,
283
+ condition: Condition = None,
284
+ *,
285
+ join: t.Literal["inner"],
286
+ on: OnQuery = None,
287
+ lazy: LazyPolicy | None = None,
288
+ explicit: bool = False,
289
+ ) -> To_Type:
290
+ """
291
+ Define a relationship that returns a single related instance (never None with inner join).
292
+
293
+ Args:
294
+ _type: A type or string reference like City to indicate a single related record.
295
+ join: Set to 'inner' to guarantee a non-null result.
296
+
297
+ Returns:
298
+ A single related instance (guaranteed non-null with inner join).
299
+ """
300
+
301
+
280
302
  @t.overload
281
303
  def relationship(
282
304
  _type: t.Type[To_Type] | str,
@@ -313,6 +335,7 @@ def relationship(
313
335
  condition: Lambda function defining the join condition between tables.
314
336
  Example: lambda self, post: self.id == post.author
315
337
  join: Join strategy ('left', 'inner', etc.). Defaults to 'left' when using 'on'.
338
+ When 'inner' is used with a single type, the result is guaranteed non-null.
316
339
  on: Alternative to condition for complex queries with pivot tables.
317
340
  Allows specifying multiple join conditions to avoid cross joins.
318
341
  lazy: Controls behavior when accessing relationship data without explicitly joining:
@@ -190,13 +190,16 @@ class PaginateDict(t.TypedDict):
190
190
  pagination: Pagination
191
191
 
192
192
 
193
+ CacheStatus = t.Literal["fresh", "cached"]
194
+
195
+
193
196
  class CacheMetadata(t.TypedDict):
194
197
  """Used by query builder metadata in the 'cache' key."""
195
198
 
196
199
  enabled: bool
197
200
  depends_on: list[t.Any]
198
201
  key: t.NotRequired[str | None]
199
- status: t.NotRequired[str | None]
202
+ status: t.NotRequired[CacheStatus | None]
200
203
  expires_at: t.NotRequired[dt.datetime | None]
201
204
  cached_at: t.NotRequired[dt.datetime | None]
202
205
 
@@ -3,6 +3,7 @@ import time
3
3
  import types
4
4
  import typing
5
5
  import warnings
6
+ from datetime import datetime
6
7
  from uuid import uuid4
7
8
 
8
9
  import pytest
@@ -16,6 +17,7 @@ from src.typedal.caching import (
16
17
  remove_cache,
17
18
  )
18
19
  from src.typedal.serializers import as_json
20
+ from typedal import TypedRows
19
21
 
20
22
  db = TypeDAL("sqlite:memory")
21
23
 
@@ -596,6 +598,33 @@ def test_caching():
596
598
  assert not User.count()
597
599
 
598
600
 
601
+ def test_caching_insert_invalidation():
602
+ _setup_data()
603
+
604
+ # Get the writer user
605
+ writer = User.where(name="Writer 1").collect_or_fail().first()
606
+
607
+ # Cache articles filtered by author
608
+ articles = Article.where(author=writer).cache().collect_or_fail()
609
+ assert len(articles) == 1
610
+ assert articles.metadata["cache"]["status"] == "fresh"
611
+
612
+ # Get from cache
613
+ articles_cached = Article.where(author=writer).cache().collect_or_fail()
614
+ assert articles_cached.metadata["cache"]["status"] == "cached"
615
+
616
+ # Insert new article matching the filter
617
+ Article.insert(title="New Article by Writer", author=writer)
618
+
619
+ # Query again with same filter
620
+ # Cache should be invalidated because a new row matching the filter was inserted
621
+ articles_after = Article.where(author=writer).cache().collect_or_fail()
622
+
623
+ # This is the failing case: status will likely still be "cached" when it should be "fresh"
624
+ assert len(articles_after) == 2
625
+ assert articles_after.metadata["cache"]["status"] == "fresh"
626
+
627
+
599
628
  def test_caching_dependencies():
600
629
  first_one, first_two = CacheFirst.bulk_insert([{"name": "one"}, {"name": "two"}])
601
630
 
@@ -631,6 +660,45 @@ def test_caching_dependencies():
631
660
  assert row.second.name != "een 2.0"
632
661
 
633
662
 
663
+ def test_memoize_caches_and_invalidates():
664
+ _setup_data()
665
+
666
+ def expensive_func(data: TypedRows[User], field: str) -> set:
667
+ return set(data.column(field))
668
+
669
+ users = User.all()
670
+
671
+ # First call - fresh
672
+ result1, status = db.memoize(expensive_func, users, field="name")
673
+ assert status == "fresh"
674
+ assert "Reader 1" in result1
675
+
676
+ users = User.all()
677
+
678
+ # Second call - should be cached
679
+ result2, status = db.memoize(expensive_func, users, field="name")
680
+ assert status == "cached"
681
+ assert result1 == result2
682
+
683
+ # Update a user
684
+ User.where(name="Reader 1").update(name="Reader Updated")
685
+
686
+ users = User.all()
687
+
688
+ # Third call - cache should be invalidated, result changed
689
+ result3, status = db.memoize(expensive_func, users, field="name")
690
+ assert status == "fresh"
691
+ assert "Reader Updated" in result3
692
+ assert "Reader 1" not in result3
693
+
694
+ # lambda:
695
+
696
+ with pytest.raises(ValueError):
697
+ db.memoize(lambda x: x, users.first(), ttl=datetime.now())
698
+
699
+ db.memoize(lambda x: x, users.first(), key="echo_lambda", ttl=datetime.now())
700
+
701
+
634
702
  def test_illegal():
635
703
  with pytest.raises(ValueError), pytest.warns(UserWarning):
636
704
 
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