TypeDAL 4.9.6__tar.gz → 4.9.8__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.9.6 → typedal-4.9.8}/CHANGELOG.md +24 -0
- {typedal-4.9.6 → typedal-4.9.8}/PKG-INFO +1 -1
- {typedal-4.9.6 → typedal-4.9.8}/pyproject.toml +1 -2
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/__about__.py +1 -1
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/cli.py +1 -2
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/core.py +88 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/fields.py +7 -0
- {typedal-4.9.6 → typedal-4.9.8}/tasks.py +1 -1
- {typedal-4.9.6 → typedal-4.9.8}/tests/conftest.py +6 -4
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_main.py +96 -1
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_mypy.py +4 -4
- typedal-4.9.8/tests/test_py4web.py +91 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_relationships.py +20 -19
- typedal-4.9.6/tests/test_py4web.py +0 -92
- {typedal-4.9.6 → typedal-4.9.8}/.crush/.gitignore +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/.crush/crush.db-shm +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/.crush/crush.db-wal +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/.crush/init +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/.crush/logs/crush.log +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/.github/workflows/su6.yml +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/.gitignore +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/.readthedocs.yml +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/README.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/coverage.svg +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/10_advanced_apis.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/1_getting_started.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/2_defining_tables.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/3_building_queries.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/4_relationships.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/5_py4web.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/6_migrations.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/7_configuration.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/8_mixins.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/9_memoization.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/css/code_blocks.css +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/index.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/docs/requirements.txt +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/example_new.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/example_old.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/mkdocs.yml +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/__init__.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/caching.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/config.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/constants.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/define.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/enum_helpers.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/for_py4web.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/for_web2py.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/helpers.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/mixins.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/py.typed +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/query_builder.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/relationships.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/rows.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/serializers/as_json.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/serializers/typescript.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/tables.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/types.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/src/typedal/web2py_py4web_shared.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/__init__.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/configs/simple.toml +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/configs/valid.env +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/configs/valid.toml +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/py314_tests.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_cli.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_config.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_docs_examples.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_helpers.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_json.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_mixins.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_orm.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_query_builder.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_row.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_stats.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_table.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_typescript.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_typing_mypy.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_typing_pyright.md +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_web2py.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/test_xx_others.py +0 -0
- {typedal-4.9.6 → typedal-4.9.8}/tests/timings.py +0 -0
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
<!--next-version-placeholder-->
|
|
4
4
|
|
|
5
|
+
## v4.9.8 (2026-07-14)
|
|
6
|
+
|
|
7
|
+
### Fix
|
|
8
|
+
|
|
9
|
+
* **core:** Clear table hooks and field validators on close ([`3a3fe7b`](https://github.com/trialandsuccess/TypeDAL/commit/3a3fe7b3387476a9ee50f85d492dea067f218069))
|
|
10
|
+
|
|
11
|
+
## v4.9.8-beta.2 (2026-07-14)
|
|
12
|
+
|
|
13
|
+
### Fix
|
|
14
|
+
|
|
15
|
+
* **core:** Fully detach tables and fields when closing TypeDAL ([`469e0cb`](https://github.com/trialandsuccess/TypeDAL/commit/469e0cbc5b4f1584849040735ced0232adc18b65))
|
|
16
|
+
|
|
17
|
+
## v4.9.8-beta.1 (2026-07-14)
|
|
18
|
+
|
|
19
|
+
### Fix
|
|
20
|
+
|
|
21
|
+
* **core:** Purge dialect expressions when closing TypeDAL ([`d848cf7`](https://github.com/trialandsuccess/TypeDAL/commit/d848cf72ca0ecee24ba857d6b5e03d3cacce7693))
|
|
22
|
+
|
|
23
|
+
## v4.9.7 (2026-07-14)
|
|
24
|
+
|
|
25
|
+
### Fix
|
|
26
|
+
|
|
27
|
+
* **fields:** Clear field requirements during unbind ([`7e054f9`](https://github.com/trialandsuccess/TypeDAL/commit/7e054f947358a3fbb491db0d777410433da43d44))
|
|
28
|
+
|
|
5
29
|
## v4.9.6 (2026-07-14)
|
|
6
30
|
|
|
7
31
|
### Fix
|
|
@@ -425,8 +425,7 @@ def fake_migrations(
|
|
|
425
425
|
|
|
426
426
|
previously_migrated = (
|
|
427
427
|
db(
|
|
428
|
-
db.ewh_implemented_features.name.belongs(to_fake)
|
|
429
|
-
& (db.ewh_implemented_features.installed == True), # noqa E712
|
|
428
|
+
db.ewh_implemented_features.name.belongs(to_fake) & (db.ewh_implemented_features.installed == True), # noqa E712
|
|
430
429
|
)
|
|
431
430
|
.select(db.ewh_implemented_features.name)
|
|
432
431
|
.column("name")
|
|
@@ -40,6 +40,53 @@ if t.TYPE_CHECKING:
|
|
|
40
40
|
from .types import AnyDict, DefineKwargs, Expression, Rows, Set, T_Query, Table
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _expression_subclasses() -> t.Iterator[type]:
|
|
44
|
+
"""
|
|
45
|
+
Yield pydal.objects.Expression and every (nested) subclass currently loaded, e.g. Field and TypedField.
|
|
46
|
+
"""
|
|
47
|
+
seen = {pydal.objects.Expression}
|
|
48
|
+
stack = [pydal.objects.Expression]
|
|
49
|
+
while stack:
|
|
50
|
+
for subclass in stack.pop().__subclasses__():
|
|
51
|
+
if subclass not in seen:
|
|
52
|
+
seen.add(subclass)
|
|
53
|
+
stack.append(subclass)
|
|
54
|
+
|
|
55
|
+
return iter(seen)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _purge_dialect_expressions(adapter: t.Any) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Undo pydal's global dialect-expression registration for a closed adapter.
|
|
61
|
+
|
|
62
|
+
Some dialects (currently Postgres and Snowflake) register extra Expression methods
|
|
63
|
+
(e.g. `.dow`, `.doy`) by stashing a wrapper per name in the process-wide, class-level
|
|
64
|
+
`Expression._dialect_expressions_` dict. `Expression.__new__` then copies each wrapper
|
|
65
|
+
onto every instantiated Expression subclass (Field, TypedField, ...) as a bound method.
|
|
66
|
+
Both of those are permanent references living outside of any object graph tied to a
|
|
67
|
+
specific db, so they are not reference cycles: closing the adapter and even running
|
|
68
|
+
`gc.collect()` will not free it, and with it the entire db (tables, fields, models)
|
|
69
|
+
it belongs to, for the lifetime of the process, unless explicitly undone here.
|
|
70
|
+
"""
|
|
71
|
+
expressions = pydal.objects.Expression._dialect_expressions_
|
|
72
|
+
stale = [
|
|
73
|
+
name
|
|
74
|
+
for name, wrapper in expressions.items()
|
|
75
|
+
if getattr(getattr(wrapper, "dialect", None), "adapter", None) is adapter
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
if not stale:
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
for name in stale:
|
|
82
|
+
del expressions[name]
|
|
83
|
+
|
|
84
|
+
for cls in _expression_subclasses():
|
|
85
|
+
for name in stale:
|
|
86
|
+
if name in cls.__dict__:
|
|
87
|
+
delattr(cls, name)
|
|
88
|
+
|
|
89
|
+
|
|
43
90
|
# note: these functions can not be moved to a different file,
|
|
44
91
|
# because then they will have different globals and it breaks!
|
|
45
92
|
|
|
@@ -282,6 +329,7 @@ class TypeDAL(_TypeDALBase):
|
|
|
282
329
|
|
|
283
330
|
def close(self) -> None:
|
|
284
331
|
"""Close the database connection and unbind all defined TypedTable models."""
|
|
332
|
+
adapter = self._adapter
|
|
285
333
|
try:
|
|
286
334
|
super().close()
|
|
287
335
|
finally:
|
|
@@ -289,6 +337,46 @@ class TypeDAL(_TypeDALBase):
|
|
|
289
337
|
model.unbind()
|
|
290
338
|
self._builder.class_map.clear()
|
|
291
339
|
|
|
340
|
+
if adapter is not None:
|
|
341
|
+
adapter.db = None
|
|
342
|
+
_purge_dialect_expressions(adapter)
|
|
343
|
+
|
|
344
|
+
for table_name in tuple(getattr(self, "tables", ())):
|
|
345
|
+
table: Table | None = getattr(self, table_name, None)
|
|
346
|
+
if table is None: # pragma: no cover
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
for hook_name in (
|
|
350
|
+
"_before_insert",
|
|
351
|
+
"_after_insert",
|
|
352
|
+
"_before_update",
|
|
353
|
+
"_after_update",
|
|
354
|
+
"_before_delete",
|
|
355
|
+
"_after_delete",
|
|
356
|
+
):
|
|
357
|
+
hooks = getattr(table, hook_name, None)
|
|
358
|
+
if isinstance(hooks, list):
|
|
359
|
+
hooks.clear()
|
|
360
|
+
|
|
361
|
+
if hasattr(table, "_db"):
|
|
362
|
+
table._db = None
|
|
363
|
+
|
|
364
|
+
for field_name in getattr(table, "fields", ()):
|
|
365
|
+
field: Field | None = getattr(table, field_name, None)
|
|
366
|
+
if field is None: # pragma: no cover
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
if hasattr(field, "_db"):
|
|
370
|
+
field._db = None
|
|
371
|
+
if hasattr(field, "db"):
|
|
372
|
+
field.db = None
|
|
373
|
+
if hasattr(field, "table"):
|
|
374
|
+
field.table = None
|
|
375
|
+
if hasattr(field, "_table"):
|
|
376
|
+
field._table = None
|
|
377
|
+
if hasattr(field, "requires"):
|
|
378
|
+
field.requires = []
|
|
379
|
+
|
|
292
380
|
def try_define[T: t.Any](self, model: t.Type[T], verbose: bool = False) -> t.Type[T]:
|
|
293
381
|
"""
|
|
294
382
|
Try to define a model with migrate or fall back to fake migrate.
|
|
@@ -199,7 +199,14 @@ class TypedField[T_Value](Expression): # pragma: no cover
|
|
|
199
199
|
|
|
200
200
|
def unbind(self) -> None:
|
|
201
201
|
"""Remove references to the pydal objects created during ``bind``."""
|
|
202
|
+
if getattr(self, "requires", None):
|
|
203
|
+
if isinstance(self.requires, list):
|
|
204
|
+
self.requires.clear()
|
|
205
|
+
else:
|
|
206
|
+
self.requires = []
|
|
207
|
+
|
|
202
208
|
self._table = None
|
|
209
|
+
self._db = None
|
|
203
210
|
self._field = None
|
|
204
211
|
|
|
205
212
|
def __getattr__(self, key: str) -> t.Any:
|
|
@@ -33,7 +33,7 @@ def generate_nav_entries() -> list[str]:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
@task
|
|
36
|
-
def update_docs_nav(
|
|
36
|
+
def update_docs_nav(_: Context) -> None:
|
|
37
37
|
"""Update mkdocs.yml nav section from actual markdown files to prevent sync issues."""
|
|
38
38
|
mkdocs_file = Path(__file__).parent / "mkdocs.yml"
|
|
39
39
|
|
|
@@ -21,10 +21,12 @@ def psql(request):
|
|
|
21
21
|
request.addfinalizer(postgres.stop)
|
|
22
22
|
postgres.start()
|
|
23
23
|
|
|
24
|
-
|
|
25
24
|
@pytest.fixture
|
|
26
|
-
def
|
|
25
|
+
def dal_psql_uri(psql) -> str:
|
|
27
26
|
conn_str = postgres.get_connection_url()
|
|
28
|
-
|
|
27
|
+
return "postgres://" + conn_str.split("://")[-1]
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def dal_psql(dal_psql_uri: str):
|
|
29
31
|
with tempfile.TemporaryDirectory() as d:
|
|
30
|
-
yield TypeDAL(
|
|
32
|
+
yield TypeDAL(dal_psql_uri, attempts=1, migrate=True, enable_typedal_caching=False, folder=d)
|
|
@@ -3,17 +3,21 @@ import gc
|
|
|
3
3
|
import re
|
|
4
4
|
import sys
|
|
5
5
|
import typing
|
|
6
|
+
import types
|
|
6
7
|
import weakref
|
|
8
|
+
from contextlib import contextmanager
|
|
7
9
|
from copy import copy
|
|
8
10
|
from sqlite3 import IntegrityError
|
|
9
11
|
from typing import ForwardRef
|
|
10
12
|
|
|
11
13
|
import pytest
|
|
14
|
+
from pydal.validators import IS_NOT_IN_DB
|
|
12
15
|
|
|
13
|
-
from src.typedal import TypedRows
|
|
16
|
+
from src.typedal import TypeDAL, TypedRows
|
|
14
17
|
from src.typedal.__about__ import __version__
|
|
15
18
|
from src.typedal.enum_helpers import InvalidEnumValue
|
|
16
19
|
from src.typedal.fields import *
|
|
20
|
+
from src.typedal.web2py_py4web_shared import AuthUser
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
def test_about():
|
|
@@ -47,6 +51,97 @@ def test_database_is_garbage_collected_after_close():
|
|
|
47
51
|
assert db_ref() is None
|
|
48
52
|
|
|
49
53
|
|
|
54
|
+
def test_database_is_garbage_collected_after_context_manager():
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def typedal_context():
|
|
58
|
+
db = TypeDAL("sqlite:memory", enable_typedal_caching=False)
|
|
59
|
+
try:
|
|
60
|
+
yield db
|
|
61
|
+
db.commit()
|
|
62
|
+
except:
|
|
63
|
+
db.rollback()
|
|
64
|
+
finally:
|
|
65
|
+
db.close()
|
|
66
|
+
|
|
67
|
+
def in_scope():
|
|
68
|
+
with typedal_context() as db:
|
|
69
|
+
@db.define
|
|
70
|
+
class RelatedTable(TypedTable):
|
|
71
|
+
value = TypedField(str)
|
|
72
|
+
|
|
73
|
+
@db.define
|
|
74
|
+
class TemporaryTable(TypedTable):
|
|
75
|
+
related: RelatedTable
|
|
76
|
+
value = TypedField(str)
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def __on_define__(cls, db):
|
|
80
|
+
cls.id.requires = IS_NOT_IN_DB(db, cls.id)
|
|
81
|
+
cls.after_insert(lambda _row, _id, db=db: None)
|
|
82
|
+
|
|
83
|
+
query = TemporaryTable.where(id=0)
|
|
84
|
+
assert not query.paginate(limit=1)
|
|
85
|
+
|
|
86
|
+
return weakref.ref(db)
|
|
87
|
+
|
|
88
|
+
db_ref = in_scope()
|
|
89
|
+
|
|
90
|
+
gc.collect()
|
|
91
|
+
|
|
92
|
+
assert db_ref() is None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_database_with_auth_user_is_garbage_collected_after_close():
|
|
96
|
+
db = TypeDAL("sqlite:memory", enable_typedal_caching=False)
|
|
97
|
+
db.define(AuthUser, redefine=True)
|
|
98
|
+
|
|
99
|
+
AuthUser.after_insert(lambda row, db=db: print(db))
|
|
100
|
+
|
|
101
|
+
db_ref = weakref.ref(db)
|
|
102
|
+
|
|
103
|
+
db.close()
|
|
104
|
+
|
|
105
|
+
assert AuthUser._db is None
|
|
106
|
+
assert AuthUser._table is None
|
|
107
|
+
|
|
108
|
+
del db
|
|
109
|
+
gc.collect()
|
|
110
|
+
|
|
111
|
+
assert db_ref() is None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_database_is_garbage_collected_after_close_postgres(dal_psql_uri, tmp_path):
|
|
115
|
+
# Postgres (and Snowflake) dialects register extra Expression methods (e.g. `.dow`) in the
|
|
116
|
+
# process-wide, class-level `Expression._dialect_expressions_` dict. That's a permanent
|
|
117
|
+
# reference living outside of any db-specific object graph, so unlike the sqlite case above,
|
|
118
|
+
# gc.collect() alone can't free it - close() has to explicitly undo the registration.
|
|
119
|
+
# (built directly from the conftest `postgres` container, not the `dal_psql` fixture: a
|
|
120
|
+
# suspended fixture generator would itself keep `db` alive for the duration of the test)
|
|
121
|
+
|
|
122
|
+
db = TypeDAL(dal_psql_uri, attempts=1, migrate=True, enable_typedal_caching=False, folder=str(tmp_path))
|
|
123
|
+
|
|
124
|
+
@db.define
|
|
125
|
+
class TemporaryPsqlTable(TypedTable):
|
|
126
|
+
value = TypedField(str)
|
|
127
|
+
|
|
128
|
+
db_ref = weakref.ref(db)
|
|
129
|
+
adapter_ref = weakref.ref(db._adapter)
|
|
130
|
+
|
|
131
|
+
db.close()
|
|
132
|
+
|
|
133
|
+
assert TemporaryPsqlTable._db is None
|
|
134
|
+
assert TemporaryPsqlTable._table is None
|
|
135
|
+
|
|
136
|
+
del db
|
|
137
|
+
gc.collect()
|
|
138
|
+
|
|
139
|
+
assert db_ref() is None
|
|
140
|
+
# the adapter itself is allowed to leak (pydal has no public API to fully undo the global
|
|
141
|
+
# dialect registration) as long as it no longer drags the rest of the db graph with it:
|
|
142
|
+
assert adapter_ref() is None or adapter_ref().db is None
|
|
143
|
+
|
|
144
|
+
|
|
50
145
|
def test_mixed_defines(capsys):
|
|
51
146
|
### DEFINE
|
|
52
147
|
|
|
@@ -178,12 +178,12 @@ def mypy_test_mixin_typed_table_argument() -> None:
|
|
|
178
178
|
|
|
179
179
|
@pytest.mark.mypy_testing
|
|
180
180
|
def mypy_test_cachefn() -> None:
|
|
181
|
-
def cache_model(
|
|
181
|
+
def cache_model(_key: str, fn: CacheFn, _expire: int) -> Rows:
|
|
182
182
|
return fn()
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
_cache_valid: CacheTuple = (cache_model, 3000)
|
|
185
185
|
|
|
186
|
-
def invalid_cache_model(
|
|
186
|
+
def invalid_cache_model(_key: str, fn: typing.Callable[..., list[str]], _expire: int = None) -> list[str]:
|
|
187
187
|
return fn()
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
_cache_invalid: CacheTuple = (invalid_cache_model, 3000) # E: [assignment]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tempfile
|
|
3
|
+
|
|
4
|
+
from contextlib_chdir import chdir
|
|
5
|
+
from pydal.validators import IS_EMAIL, IS_NOT_IN_DB
|
|
6
|
+
|
|
7
|
+
from src.typedal import TypedTable
|
|
8
|
+
from src.typedal.for_py4web import DAL, AuthUser, setup_py4web_tables
|
|
9
|
+
from src.typedal.serializers import as_json
|
|
10
|
+
from typedal.config import TypeDALConfig
|
|
11
|
+
|
|
12
|
+
db = DAL("sqlite:memory")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_P4W():
|
|
16
|
+
assert hasattr(DAL, "on_request")
|
|
17
|
+
assert hasattr(db, "on_request")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_serialize():
|
|
21
|
+
@db.define()
|
|
22
|
+
class JsonTable(TypedTable):
|
|
23
|
+
field: str
|
|
24
|
+
|
|
25
|
+
row = JsonTable.insert(field="Hey")
|
|
26
|
+
|
|
27
|
+
loaded_data = json.loads(as_json.encode(JsonTable))
|
|
28
|
+
assert loaded_data
|
|
29
|
+
assert isinstance(loaded_data, dict)
|
|
30
|
+
loaded_data = json.loads(as_json.encode(row))
|
|
31
|
+
assert isinstance(loaded_data, dict)
|
|
32
|
+
assert loaded_data
|
|
33
|
+
|
|
34
|
+
loaded_data = json.loads(as_json.encode(JsonTable.paginate(limit=1)))
|
|
35
|
+
|
|
36
|
+
assert loaded_data["pagination"]
|
|
37
|
+
assert loaded_data["data"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_auth_user():
|
|
41
|
+
setup_py4web_tables(db)
|
|
42
|
+
requirements = AuthUser.email.requires
|
|
43
|
+
|
|
44
|
+
assert requirements
|
|
45
|
+
assert isinstance(requirements, list)
|
|
46
|
+
assert len(requirements) == 2
|
|
47
|
+
|
|
48
|
+
assert isinstance(requirements[0], IS_EMAIL)
|
|
49
|
+
assert isinstance(requirements[1], IS_NOT_IN_DB)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_py4web_dal_singleton():
|
|
53
|
+
with tempfile.TemporaryDirectory() as d, chdir(d):
|
|
54
|
+
# note: typedal caching is disabled here because otherwise the cache table might send logs to {d}/log.sql
|
|
55
|
+
# which can lead to problems in other tests :(
|
|
56
|
+
db_1a = DAL("sqlite:memory", enable_typedal_caching=False)
|
|
57
|
+
db_1b = DAL("sqlite:memory", enable_typedal_caching=False)
|
|
58
|
+
|
|
59
|
+
db_2a = DAL("sqlite://test_py4web_dal_singleton", folder=d, enable_typedal_caching=False)
|
|
60
|
+
db_2b = DAL("sqlite://test_py4web_dal_singleton", folder=d, enable_typedal_caching=False)
|
|
61
|
+
|
|
62
|
+
conf = {"database": "sqlite:memory", "dialect": "sqlite", "pyproject": "", "flag_location": f"{d}/flags"}
|
|
63
|
+
db_3a = DAL(config=TypeDALConfig.load(conf))
|
|
64
|
+
db_3b = DAL(config=TypeDALConfig.load(conf))
|
|
65
|
+
|
|
66
|
+
assert db_1a is db_1b
|
|
67
|
+
assert db_1a._uri == db_1b._uri
|
|
68
|
+
assert db_1a._db_uid == db_1b._db_uid
|
|
69
|
+
|
|
70
|
+
assert db_2a is db_2b
|
|
71
|
+
assert db_2a._uri == db_2b._uri
|
|
72
|
+
assert db_2a._db_uid == db_2b._db_uid
|
|
73
|
+
|
|
74
|
+
assert db_1a is not db_2a
|
|
75
|
+
assert db_1a._uri != db_2a._uri
|
|
76
|
+
assert db_1a._db_uid != db_2a._db_uid
|
|
77
|
+
|
|
78
|
+
assert db_1b is not db_2b
|
|
79
|
+
assert db_1b._uri != db_2b._uri
|
|
80
|
+
assert db_1b._db_uid != db_2b._db_uid
|
|
81
|
+
|
|
82
|
+
assert db_3a is db_3b
|
|
83
|
+
assert db_3a is not db_1a
|
|
84
|
+
assert db_3a is not db_2a
|
|
85
|
+
|
|
86
|
+
assert db_1a._uri == db_1b._uri
|
|
87
|
+
assert db_3a._uri == db_1a._uri
|
|
88
|
+
assert db_3a._uri != db_2a._uri
|
|
89
|
+
|
|
90
|
+
# reset singletons for later use:
|
|
91
|
+
DAL._clear()
|
|
@@ -129,8 +129,8 @@ def _setup_data():
|
|
|
129
129
|
|
|
130
130
|
# no relationships:
|
|
131
131
|
new_author = User.insert(name="Untagged Author", roles=[], main_role=writer, extra_roles=[])
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
_untagged1 = Article.insert(title="Untagged Article 1", author=new_author)
|
|
133
|
+
_untagged2 = Article.insert(title="Untagged Article 2", author=new_author)
|
|
134
134
|
|
|
135
135
|
# articles
|
|
136
136
|
|
|
@@ -355,10 +355,9 @@ def test_reprs():
|
|
|
355
355
|
|
|
356
356
|
assert "AND" not in repr(relation)
|
|
357
357
|
|
|
358
|
-
relation = Article.join("author", condition_and=lambda
|
|
358
|
+
relation = Article.join("author", condition_and=lambda _article, author: author.name != "Hank").relationships[
|
|
359
359
|
"author"
|
|
360
360
|
]
|
|
361
|
-
|
|
362
361
|
assert "AND" in repr(relation) and "Hank" in repr(relation)
|
|
363
362
|
|
|
364
363
|
|
|
@@ -436,7 +435,7 @@ def test_join_with_different_condition():
|
|
|
436
435
|
role_with_users = Role.join(
|
|
437
436
|
"users",
|
|
438
437
|
method="inner",
|
|
439
|
-
condition_and=lambda
|
|
438
|
+
condition_and=lambda _role, user: ~user.name.like("Reader%"),
|
|
440
439
|
).first()
|
|
441
440
|
|
|
442
441
|
assert role_with_users.users
|
|
@@ -446,7 +445,7 @@ def test_join_with_different_condition():
|
|
|
446
445
|
role_with_users = Role.join(
|
|
447
446
|
"users",
|
|
448
447
|
method="left",
|
|
449
|
-
condition_and=lambda
|
|
448
|
+
condition_and=lambda _role, user: ~user.name.like("Reader%"),
|
|
450
449
|
).first()
|
|
451
450
|
|
|
452
451
|
assert role_with_users.users
|
|
@@ -729,11 +728,11 @@ def test_memoize_nested_dependencies():
|
|
|
729
728
|
assert len(users) > 0
|
|
730
729
|
|
|
731
730
|
# First memoize - should be fresh
|
|
732
|
-
|
|
731
|
+
_result1, status = db.memoize(process_users, users)
|
|
733
732
|
assert status == "fresh"
|
|
734
733
|
|
|
735
734
|
# Second call - should be cached
|
|
736
|
-
|
|
735
|
+
_result2, status = db.memoize(process_users, users)
|
|
737
736
|
assert status == "cached"
|
|
738
737
|
|
|
739
738
|
# Update a Role that was accessed inside process_users
|
|
@@ -742,7 +741,7 @@ def test_memoize_nested_dependencies():
|
|
|
742
741
|
|
|
743
742
|
# Third call - should be fresh (invalidated by Role change)
|
|
744
743
|
# but currently will still be "cached" because we only tracked User deps
|
|
745
|
-
|
|
744
|
+
_result3, status = db.memoize(process_users, users)
|
|
746
745
|
assert status == "fresh"
|
|
747
746
|
|
|
748
747
|
|
|
@@ -755,25 +754,25 @@ def test_memoize_nested_dependencies2():
|
|
|
755
754
|
# (which happens when using _determine_dependencies_auto)
|
|
756
755
|
return list(User.join())
|
|
757
756
|
|
|
758
|
-
|
|
757
|
+
_bogus, status = db.memoize(something_slow)
|
|
759
758
|
assert status == "fresh"
|
|
760
759
|
|
|
761
760
|
# no change
|
|
762
|
-
|
|
761
|
+
_bogus, status = db.memoize(something_slow)
|
|
763
762
|
assert status == "cached"
|
|
764
763
|
|
|
765
764
|
# user change
|
|
766
765
|
User.first().update_record(name="New Name :)")
|
|
767
|
-
|
|
766
|
+
_bogus, status = db.memoize(something_slow)
|
|
768
767
|
assert status == "fresh"
|
|
769
768
|
|
|
770
769
|
# role change
|
|
771
770
|
Role.first().update_record(name="New Role Name :)")
|
|
772
|
-
|
|
771
|
+
_bogus, status = db.memoize(something_slow)
|
|
773
772
|
assert status == "fresh"
|
|
774
773
|
|
|
775
774
|
# no change
|
|
776
|
-
|
|
775
|
+
_bogus, status = db.memoize(something_slow)
|
|
777
776
|
assert status == "cached"
|
|
778
777
|
|
|
779
778
|
|
|
@@ -931,7 +930,8 @@ def test_accessing_raw_data():
|
|
|
931
930
|
|
|
932
931
|
user = User.where(id=4).join("articles").first()
|
|
933
932
|
|
|
934
|
-
# <User({"id": 4, "name": "Untagged Author", "roles": [],
|
|
933
|
+
# <User({"id": 4, "name": "Untagged Author", "roles": [],
|
|
934
|
+
# "gid": "967da807-eb13-46dc-a0f3-d5c04751edf4", "main_role": 2, "extra_roles": []}) + ['articles']>
|
|
935
935
|
assert user._row
|
|
936
936
|
|
|
937
937
|
# one row per user+article combination like how postgres returns it:
|
|
@@ -1031,9 +1031,9 @@ def test_nested_join_with_shared_foreign_key():
|
|
|
1031
1031
|
tech_corp = Company.insert(name="Tech Corp")
|
|
1032
1032
|
|
|
1033
1033
|
# Create multiple offices in the same city for the same company
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1034
|
+
_office1 = Office.insert(address="123 Market St", city_id=san_francisco.id, company=tech_corp.id)
|
|
1035
|
+
_office2 = Office.insert(address="456 Mission St", city_id=san_francisco.id, company=tech_corp.id)
|
|
1036
|
+
_office3 = Office.insert(address="789 Howard St", city_id=san_francisco.id, company=tech_corp.id)
|
|
1037
1037
|
|
|
1038
1038
|
db.commit()
|
|
1039
1039
|
|
|
@@ -1072,7 +1072,8 @@ def test_nested_join_with_shared_foreign_key():
|
|
|
1072
1072
|
f"Office {idx} ('{office.address}'): city relationship is None (BUG!)"
|
|
1073
1073
|
)
|
|
1074
1074
|
assert isinstance(office.city_alternative, City), (
|
|
1075
|
-
f"Office {idx}: city ({office.city_alternative})
|
|
1075
|
+
f"Office {idx}: city ({office.city_alternative}) "
|
|
1076
|
+
f"is not a City instance but a {type(office.city_alternative)}"
|
|
1076
1077
|
)
|
|
1077
1078
|
assert office.city_alternative.name == "San Francisco", (
|
|
1078
1079
|
f"Office {idx}: expected 'San Francisco', got '{office.city_alternative.name}'"
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import tempfile
|
|
3
|
-
|
|
4
|
-
from contextlib_chdir import chdir
|
|
5
|
-
from pydal.validators import IS_EMAIL, IS_NOT_IN_DB
|
|
6
|
-
|
|
7
|
-
from src.typedal import TypedTable
|
|
8
|
-
from src.typedal.for_py4web import DAL, AuthUser, setup_py4web_tables
|
|
9
|
-
from src.typedal.serializers import as_json
|
|
10
|
-
from typedal.config import TypeDALConfig
|
|
11
|
-
|
|
12
|
-
db = DAL("sqlite:memory")
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def test_P4W():
|
|
16
|
-
assert hasattr(DAL, "on_request")
|
|
17
|
-
assert hasattr(db, "on_request")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_serialize():
|
|
21
|
-
@db.define()
|
|
22
|
-
class JsonTable(TypedTable):
|
|
23
|
-
field: str
|
|
24
|
-
|
|
25
|
-
row = JsonTable.insert(field="Hey")
|
|
26
|
-
|
|
27
|
-
l = json.loads(as_json.encode(JsonTable))
|
|
28
|
-
assert l
|
|
29
|
-
assert isinstance(l, dict)
|
|
30
|
-
l = json.loads(as_json.encode(row))
|
|
31
|
-
assert isinstance(l, dict)
|
|
32
|
-
assert l
|
|
33
|
-
|
|
34
|
-
l = json.loads(as_json.encode(JsonTable.paginate(limit=1)))
|
|
35
|
-
|
|
36
|
-
assert l["pagination"]
|
|
37
|
-
assert l["data"]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_auth_user():
|
|
41
|
-
setup_py4web_tables(db)
|
|
42
|
-
requirements = AuthUser.email.requires
|
|
43
|
-
|
|
44
|
-
assert requirements
|
|
45
|
-
assert isinstance(requirements, list)
|
|
46
|
-
assert len(requirements) == 2
|
|
47
|
-
|
|
48
|
-
assert isinstance(requirements[0], IS_EMAIL)
|
|
49
|
-
assert isinstance(requirements[1], IS_NOT_IN_DB)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def test_py4web_dal_singleton():
|
|
53
|
-
with tempfile.TemporaryDirectory() as d:
|
|
54
|
-
with chdir(d):
|
|
55
|
-
# note: typedal caching is disabled here because otherwise the cache table might send logs to {d}/log.sql
|
|
56
|
-
# which can lead to problems in other tests :(
|
|
57
|
-
db_1a = DAL("sqlite:memory", enable_typedal_caching=False)
|
|
58
|
-
db_1b = DAL("sqlite:memory", enable_typedal_caching=False)
|
|
59
|
-
|
|
60
|
-
db_2a = DAL("sqlite://test_py4web_dal_singleton", folder=d, enable_typedal_caching=False)
|
|
61
|
-
db_2b = DAL("sqlite://test_py4web_dal_singleton", folder=d, enable_typedal_caching=False)
|
|
62
|
-
|
|
63
|
-
conf = {"database": "sqlite:memory", "dialect": "sqlite", "pyproject": "", "flag_location": f"{d}/flags"}
|
|
64
|
-
db_3a = DAL(config=TypeDALConfig.load(conf))
|
|
65
|
-
db_3b = DAL(config=TypeDALConfig.load(conf))
|
|
66
|
-
|
|
67
|
-
assert db_1a is db_1b
|
|
68
|
-
assert db_1a._uri == db_1b._uri
|
|
69
|
-
assert db_1a._db_uid == db_1b._db_uid
|
|
70
|
-
|
|
71
|
-
assert db_2a is db_2b
|
|
72
|
-
assert db_2a._uri == db_2b._uri
|
|
73
|
-
assert db_2a._db_uid == db_2b._db_uid
|
|
74
|
-
|
|
75
|
-
assert db_1a is not db_2a
|
|
76
|
-
assert db_1a._uri != db_2a._uri
|
|
77
|
-
assert db_1a._db_uid != db_2a._db_uid
|
|
78
|
-
|
|
79
|
-
assert db_1b is not db_2b
|
|
80
|
-
assert db_1b._uri != db_2b._uri
|
|
81
|
-
assert db_1b._db_uid != db_2b._db_uid
|
|
82
|
-
|
|
83
|
-
assert db_3a is db_3b
|
|
84
|
-
assert db_3a is not db_1a
|
|
85
|
-
assert db_3a is not db_2a
|
|
86
|
-
|
|
87
|
-
assert db_1a._uri == db_1b._uri
|
|
88
|
-
assert db_3a._uri == db_1a._uri
|
|
89
|
-
assert db_3a._uri != db_2a._uri
|
|
90
|
-
|
|
91
|
-
# reset singletons for later use:
|
|
92
|
-
DAL._clear()
|
|
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
|
|
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
|