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.
- {typedal-4.2.1 → typedal-4.3.0}/CHANGELOG.md +12 -0
- {typedal-4.2.1 → typedal-4.3.0}/PKG-INFO +1 -1
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/__about__.py +1 -1
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/caching.py +196 -24
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/core.py +30 -2
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/define.py +2 -1
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/relationships.py +23 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/types.py +4 -1
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_relationships.py +68 -0
- {typedal-4.2.1 → typedal-4.3.0}/.github/workflows/su6.yml +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/.gitignore +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/.readthedocs.yml +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/README.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/coverage.svg +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/1_getting_started.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/2_defining_tables.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/3_building_queries.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/4_relationships.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/5_py4web.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/6_migrations.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/7_configuration.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/8_mixins.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/css/code_blocks.css +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/index.md +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/docs/requirements.txt +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/example_new.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/example_old.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/mkdocs.yml +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/pyproject.toml +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/__init__.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/cli.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/config.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/constants.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/fields.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/for_py4web.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/for_web2py.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/helpers.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/mixins.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/py.typed +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/query_builder.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/rows.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/serializers/as_json.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/tables.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/src/typedal/web2py_py4web_shared.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/__init__.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/configs/simple.toml +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/configs/valid.env +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/configs/valid.toml +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/py314_tests.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_cli.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_config.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_docs_examples.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_helpers.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_json.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_main.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_mixins.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_mypy.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_orm.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_py4web.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_query_builder.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_row.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_stats.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_table.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_web2py.py +0 -0
- {typedal-4.2.1 → typedal-4.3.0}/tests/test_xx_others.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
inst.metadata
|
|
259
|
-
|
|
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
|
|
345
|
+
def _load_memoize_from_cache(key: str) -> t.Any:
|
|
268
346
|
"""
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
+
if result := _fetch_cached_payload(key):
|
|
359
|
+
return result[0]
|
|
275
360
|
|
|
276
|
-
return
|
|
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,
|
|
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,
|
|
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[
|
|
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
|
|
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
|