TypeDAL 4.8.6__tar.gz → 4.9.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.8.6 → typedal-4.9.0}/CHANGELOG.md +12 -0
- {typedal-4.8.6 → typedal-4.9.0}/PKG-INFO +1 -1
- typedal-4.9.0/coverage.svg +1 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/__about__.py +1 -1
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/core.py +8 -4
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/define.py +4 -2
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/fields.py +1 -3
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/query_builder.py +24 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/tables.py +39 -7
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/types.py +64 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_config.py +1 -3
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_helpers.py +6 -1
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_main.py +41 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_query_builder.py +26 -0
- typedal-4.8.6/coverage.svg +0 -1
- {typedal-4.8.6 → typedal-4.9.0}/.crush/.gitignore +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/.crush/crush.db-shm +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/.crush/crush.db-wal +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/.crush/init +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/.crush/logs/crush.log +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/.github/workflows/su6.yml +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/.gitignore +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/.readthedocs.yml +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/README.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/10_advanced_apis.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/1_getting_started.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/2_defining_tables.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/3_building_queries.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/4_relationships.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/5_py4web.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/6_migrations.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/7_configuration.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/8_mixins.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/9_memoization.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/css/code_blocks.css +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/index.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/docs/requirements.txt +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/example_new.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/example_old.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/mkdocs.yml +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/pyproject.toml +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/__init__.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/caching.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/cli.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/config.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/constants.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/enum_helpers.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/for_py4web.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/for_web2py.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/helpers.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/mixins.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/py.typed +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/relationships.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/rows.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/serializers/as_json.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/serializers/typescript.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/src/typedal/web2py_py4web_shared.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tasks.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/__init__.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/configs/simple.toml +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/configs/valid.env +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/configs/valid.toml +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/py314_tests.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_cli.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_docs_examples.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_json.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_mixins.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_mypy.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_orm.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_py4web.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_relationships.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_row.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_stats.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_table.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_typescript.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_typing_mypy.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_typing_pyright.md +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_web2py.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/test_xx_others.py +0 -0
- {typedal-4.8.6 → typedal-4.9.0}/tests/timings.py +0 -0
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
<!--next-version-placeholder-->
|
|
4
4
|
|
|
5
|
+
## v4.9.0 (2026-06-12)
|
|
6
|
+
|
|
7
|
+
### Feature
|
|
8
|
+
|
|
9
|
+
* **orm:** Add table and query builder permissions ([`e737666`](https://github.com/trialandsuccess/TypeDAL/commit/e737666b5f16a62a7c71ff1e0c21ed9d4f7418f5))
|
|
10
|
+
|
|
11
|
+
## v4.8.7 (2026-06-11)
|
|
12
|
+
|
|
13
|
+
### Fix
|
|
14
|
+
|
|
15
|
+
* Make type checker happy about as_dict and similar methods; reformat ([`f6be55f`](https://github.com/trialandsuccess/TypeDAL/commit/f6be55f253e87d409e0ac017f2bdb89233620391))
|
|
16
|
+
|
|
5
17
|
## v4.8.6 (2026-06-02)
|
|
6
18
|
|
|
7
19
|
### Fix
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img" aria-label="coverage: 100.00%"><title>coverage: 100.00%</title><filter id="blur"><feGaussianBlur stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="122" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="61" height="20" fill="#4b0"/><rect width="122" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="315" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">coverage</text><text x="315" y="150" fill-opacity=".3" textLength="510">coverage</text></g><text x="315" y="140" textLength="510">coverage</text></g><g transform="scale(.1)"><g aria-hidden="true" fill="#010101"><text x="905" y="150" fill-opacity=".8" filter="url(#blur)" textLength="510">100.00%</text><text x="905" y="150" fill-opacity=".3" textLength="510">100.00%</text></g><text x="905" y="140" textLength="510">100.00%</text></g></g></svg>
|
|
@@ -37,7 +37,7 @@ except ImportError: # pragma: no cover
|
|
|
37
37
|
if t.TYPE_CHECKING:
|
|
38
38
|
from .fields import TypedField
|
|
39
39
|
from .query_builder import QueryBuilder
|
|
40
|
-
from .types import AnyDict, Expression, Rows, Set, T_Query, Table
|
|
40
|
+
from .types import AnyDict, DefineKwargs, Expression, Rows, Set, T_Query, Table
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
# note: these functions can not be moved to a different file,
|
|
@@ -304,7 +304,11 @@ class TypeDAL(_TypeDALBase):
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
@t.overload
|
|
307
|
-
def define[T: t.Any](
|
|
307
|
+
def define[T: t.Any](
|
|
308
|
+
self,
|
|
309
|
+
maybe_cls: None = None,
|
|
310
|
+
**kwargs: t.Unpack[DefineKwargs],
|
|
311
|
+
) -> t.Callable[[t.Type[T]], t.Type[T]]:
|
|
308
312
|
"""
|
|
309
313
|
Typing Overload for define without a class.
|
|
310
314
|
|
|
@@ -313,7 +317,7 @@ class TypeDAL(_TypeDALBase):
|
|
|
313
317
|
"""
|
|
314
318
|
|
|
315
319
|
@t.overload
|
|
316
|
-
def define[T: t.Any](self, maybe_cls: t.Type[T], **kwargs: t.
|
|
320
|
+
def define[T: t.Any](self, maybe_cls: t.Type[T], **kwargs: t.Unpack[DefineKwargs]) -> t.Type[T]:
|
|
317
321
|
"""
|
|
318
322
|
Typing Overload for define with a class.
|
|
319
323
|
|
|
@@ -324,7 +328,7 @@ class TypeDAL(_TypeDALBase):
|
|
|
324
328
|
def define[T: t.Any](
|
|
325
329
|
self,
|
|
326
330
|
maybe_cls: t.Type[T] | None = None,
|
|
327
|
-
**kwargs: t.
|
|
331
|
+
**kwargs: t.Unpack[DefineKwargs],
|
|
328
332
|
) -> t.Type[T] | t.Callable[[t.Type[T]], t.Type[T]]:
|
|
329
333
|
"""
|
|
330
334
|
Can be used as a decorator on a class that inherits `TypedTable`, \
|
|
@@ -30,7 +30,7 @@ from .helpers import (
|
|
|
30
30
|
)
|
|
31
31
|
from .relationships import Relationship, to_relationship
|
|
32
32
|
from .tables import TypedTable
|
|
33
|
-
from .types import Field, T_annotation, Table, _Types
|
|
33
|
+
from .types import DefineKwargs, Field, Permissions, T_annotation, Table, _Types
|
|
34
34
|
|
|
35
35
|
try:
|
|
36
36
|
# python 3.14+
|
|
@@ -68,8 +68,9 @@ class TableDefinitionBuilder:
|
|
|
68
68
|
self.db = db
|
|
69
69
|
self.class_map: dict[str, t.Type["TypedTable"]] = {}
|
|
70
70
|
|
|
71
|
-
def define[T: t.Any](self, cls: t.Type[T], **kwargs: t.
|
|
71
|
+
def define[T: t.Any](self, cls: t.Type[T], **kwargs: t.Unpack[DefineKwargs]) -> t.Type[T]:
|
|
72
72
|
"""Build and register a table from a TypedTable class."""
|
|
73
|
+
permissions: Permissions | None = kwargs.pop("permissions", None)
|
|
73
74
|
full_dict = all_dict(cls)
|
|
74
75
|
tablename = to_snake(cls.__name__)
|
|
75
76
|
annotations = all_annotations(cls)
|
|
@@ -116,6 +117,7 @@ class TableDefinitionBuilder:
|
|
|
116
117
|
db=self.db,
|
|
117
118
|
table=table,
|
|
118
119
|
relationships=t.cast(dict[str, Relationship[t.Any]], relationships),
|
|
120
|
+
permissions=permissions,
|
|
119
121
|
)
|
|
120
122
|
self.class_map[str(table)] = cls # tablename - pydal name
|
|
121
123
|
self.class_map[cls.__name__] = cls # TableName - typedal name
|
|
@@ -412,9 +412,7 @@ def UploadField(**kw: t.Unpack[FieldSettings]) -> TypedField[str]:
|
|
|
412
412
|
Upload = UploadField
|
|
413
413
|
|
|
414
414
|
|
|
415
|
-
def ReferenceField[
|
|
416
|
-
T_subclass: (TypedTable, Table)
|
|
417
|
-
](
|
|
415
|
+
def ReferenceField[T_subclass: (TypedTable, Table)](
|
|
418
416
|
other_table: str | t.Type[TypedTable] | TypedTable | Table | T_subclass,
|
|
419
417
|
**kw: t.Unpack[FieldSettings],
|
|
420
418
|
) -> TypedField[int]:
|
|
@@ -33,12 +33,15 @@ from .types import (
|
|
|
33
33
|
Metadata,
|
|
34
34
|
OnQuery,
|
|
35
35
|
OrderBy,
|
|
36
|
+
Permissions,
|
|
36
37
|
Query,
|
|
37
38
|
Row,
|
|
38
39
|
Rows,
|
|
39
40
|
SelectKwargs,
|
|
40
41
|
T_MetaInstance,
|
|
41
42
|
Table,
|
|
43
|
+
merge_permissions,
|
|
44
|
+
require_permission,
|
|
42
45
|
)
|
|
43
46
|
|
|
44
47
|
|
|
@@ -53,6 +56,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
53
56
|
select_kwargs: SelectKwargs
|
|
54
57
|
relationships: dict[str, Relationship[t.Any]]
|
|
55
58
|
metadata: Metadata
|
|
59
|
+
_permissions: Permissions
|
|
56
60
|
|
|
57
61
|
def __init__(
|
|
58
62
|
self,
|
|
@@ -62,6 +66,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
62
66
|
select_kwargs: t.Optional[SelectKwargs] = None,
|
|
63
67
|
relationships: dict[str, Relationship[t.Any]] = None,
|
|
64
68
|
metadata: Metadata = None,
|
|
69
|
+
permissions: Permissions | None = None,
|
|
65
70
|
):
|
|
66
71
|
"""
|
|
67
72
|
Normally, you wouldn't manually initialize a QueryBuilder but start using a method on a TypedTable.
|
|
@@ -77,6 +82,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
77
82
|
self.select_kwargs = select_kwargs or {}
|
|
78
83
|
self.relationships = relationships or {}
|
|
79
84
|
self.metadata = metadata or {}
|
|
85
|
+
self._permissions = merge_permissions(getattr(model, "_permissions", None), permissions)
|
|
80
86
|
|
|
81
87
|
def _ensure_table_defined(self) -> Table:
|
|
82
88
|
model = self.model
|
|
@@ -130,6 +136,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
130
136
|
select_kwargs: t.Optional[SelectKwargs] = None,
|
|
131
137
|
relationships: dict[str, Relationship[t.Any]] = None,
|
|
132
138
|
metadata: Metadata = None,
|
|
139
|
+
permissions: Permissions | None = None,
|
|
133
140
|
) -> "QueryBuilder[T_MetaInstance]":
|
|
134
141
|
return QueryBuilder(
|
|
135
142
|
self.model,
|
|
@@ -138,8 +145,15 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
138
145
|
(self.select_kwargs | select_kwargs) if select_kwargs else self.select_kwargs,
|
|
139
146
|
(self.relationships | relationships) if relationships else self.relationships,
|
|
140
147
|
(self.metadata | (metadata or {})) if metadata else self.metadata,
|
|
148
|
+
permissions=merge_permissions(self._permissions, permissions),
|
|
141
149
|
)
|
|
142
150
|
|
|
151
|
+
def permissions(self, **permissions: t.Unpack[Permissions]) -> "QueryBuilder[T_MetaInstance]":
|
|
152
|
+
"""
|
|
153
|
+
Return a clone of this builder with permission overrides merged in.
|
|
154
|
+
"""
|
|
155
|
+
return self._extend(permissions=t.cast(Permissions, permissions))
|
|
156
|
+
|
|
143
157
|
def select(self, *fields: t.Any, **options: t.Unpack[SelectKwargs]) -> "QueryBuilder[T_MetaInstance]":
|
|
144
158
|
"""
|
|
145
159
|
Fields: database columns by name ('id'), by field reference (table.id) or other (e.g. table.ALL).
|
|
@@ -455,6 +469,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
455
469
|
"""
|
|
456
470
|
Based on the current query, delete rows and return a list of deleted IDs.
|
|
457
471
|
"""
|
|
472
|
+
require_permission(self._permissions, "delete")
|
|
458
473
|
db = self._get_db()
|
|
459
474
|
removed_ids = [_.id for _ in db(self.query).select("id")]
|
|
460
475
|
if db(self.query).delete():
|
|
@@ -472,6 +487,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
472
487
|
Based on the current query, update `fields` and return a list of updated IDs.
|
|
473
488
|
"""
|
|
474
489
|
# todo: limit?
|
|
490
|
+
require_permission(self._permissions, "update")
|
|
475
491
|
db = self._get_db()
|
|
476
492
|
updated_ids = db(self.query).select("id").column("id")
|
|
477
493
|
if db(self.query).update(**fields):
|
|
@@ -553,6 +569,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
553
569
|
"""
|
|
554
570
|
Raw version of .collect which only executes the SQL, without performing t.Any magic afterwards.
|
|
555
571
|
"""
|
|
572
|
+
require_permission(self._permissions, "read")
|
|
556
573
|
db = self._get_db()
|
|
557
574
|
metadata: Metadata = self.metadata.copy()
|
|
558
575
|
|
|
@@ -579,6 +596,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
579
596
|
"""
|
|
580
597
|
Execute the built query and turn it into model instances, while handling relationships.
|
|
581
598
|
"""
|
|
599
|
+
require_permission(self._permissions, "read")
|
|
582
600
|
if _to is None:
|
|
583
601
|
_to = TypedRows
|
|
584
602
|
into = _into or self.model
|
|
@@ -1092,6 +1110,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
1092
1110
|
"""
|
|
1093
1111
|
Return the amount of rows matching the current query.
|
|
1094
1112
|
"""
|
|
1113
|
+
require_permission(self._permissions, "read")
|
|
1095
1114
|
db = self._get_db()
|
|
1096
1115
|
query = self.__count(db, distinct=distinct)
|
|
1097
1116
|
|
|
@@ -1115,6 +1134,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
1115
1134
|
Returns:
|
|
1116
1135
|
bool: A boolean indicating whether t.Any records exist.
|
|
1117
1136
|
"""
|
|
1137
|
+
require_permission(self._permissions, "read")
|
|
1118
1138
|
return bool(self.count())
|
|
1119
1139
|
|
|
1120
1140
|
def __paginate(
|
|
@@ -1146,6 +1166,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
1146
1166
|
Note: when using relationships, this limit is only applied to the 'main' table and t.Any number of extra rows \
|
|
1147
1167
|
can be loaded with relationship data!
|
|
1148
1168
|
"""
|
|
1169
|
+
require_permission(self._permissions, "read")
|
|
1149
1170
|
builder = self.__paginate(limit, page)
|
|
1150
1171
|
|
|
1151
1172
|
rows = t.cast(PaginatedRows[T_MetaInstance], builder.collect(verbose=verbose, _to=PaginatedRows))
|
|
@@ -1176,6 +1197,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
1176
1197
|
pass
|
|
1177
1198
|
```
|
|
1178
1199
|
"""
|
|
1200
|
+
require_permission(self._permissions, "read")
|
|
1179
1201
|
page = 1
|
|
1180
1202
|
|
|
1181
1203
|
while rows := self.__paginate(chunk_size, page).collect():
|
|
@@ -1188,6 +1210,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
1188
1210
|
|
|
1189
1211
|
Also adds paginate, since it would be a waste to select more rows than needed.
|
|
1190
1212
|
"""
|
|
1213
|
+
require_permission(self._permissions, "read")
|
|
1191
1214
|
row = self.paginate(page=1, limit=1, verbose=verbose).first()
|
|
1192
1215
|
if not row:
|
|
1193
1216
|
return None
|
|
@@ -1207,6 +1230,7 @@ class QueryBuilder[T_MetaInstance: _TypedTable]:
|
|
|
1207
1230
|
|
|
1208
1231
|
Basically unwraps t.Optional type.
|
|
1209
1232
|
"""
|
|
1233
|
+
require_permission(self._permissions, "read")
|
|
1210
1234
|
return self.first(verbose=verbose) or throw(exception or ValueError("Nothing found!"))
|
|
1211
1235
|
|
|
1212
1236
|
|
|
@@ -28,6 +28,7 @@ from .types import (
|
|
|
28
28
|
OnQuery,
|
|
29
29
|
OpRow,
|
|
30
30
|
OrderBy,
|
|
31
|
+
Permissions,
|
|
31
32
|
Query,
|
|
32
33
|
QueryLike,
|
|
33
34
|
Reference,
|
|
@@ -37,6 +38,8 @@ from .types import (
|
|
|
37
38
|
T_MetaInstance,
|
|
38
39
|
T_Query,
|
|
39
40
|
Table,
|
|
41
|
+
merge_permissions,
|
|
42
|
+
require_permission,
|
|
40
43
|
)
|
|
41
44
|
|
|
42
45
|
if t.TYPE_CHECKING:
|
|
@@ -97,18 +100,26 @@ class TableMeta(type):
|
|
|
97
100
|
_db: TypeDAL | None = None
|
|
98
101
|
_table: Table | None = None
|
|
99
102
|
_relationships: dict[str, Relationship[t.Any]] | None = None
|
|
103
|
+
_permissions: Permissions | None = None
|
|
100
104
|
|
|
101
105
|
#########################
|
|
102
106
|
# TypeDAL custom logic: #
|
|
103
107
|
#########################
|
|
104
108
|
|
|
105
|
-
def __set_internals__(
|
|
109
|
+
def __set_internals__(
|
|
110
|
+
self,
|
|
111
|
+
db: pydal.DAL,
|
|
112
|
+
table: Table,
|
|
113
|
+
relationships: dict[str, Relationship[t.Any]],
|
|
114
|
+
permissions: Permissions | None = None,
|
|
115
|
+
) -> None:
|
|
106
116
|
"""
|
|
107
117
|
Store the related database and pydal table for later usage.
|
|
108
118
|
"""
|
|
109
119
|
self._db = db
|
|
110
120
|
self._table = table
|
|
111
121
|
self._relationships = relationships
|
|
122
|
+
self._permissions = merge_permissions(permissions)
|
|
112
123
|
|
|
113
124
|
def __getattr__(self, col: str) -> t.Optional[Field]:
|
|
114
125
|
"""
|
|
@@ -186,6 +197,7 @@ class TableMeta(type):
|
|
|
186
197
|
|
|
187
198
|
"""
|
|
188
199
|
table = self._ensure_table_defined()
|
|
200
|
+
require_permission(self._permissions, "insert")
|
|
189
201
|
|
|
190
202
|
result = table.insert(**fields)
|
|
191
203
|
# it already is an int but mypy doesn't understand that
|
|
@@ -201,6 +213,7 @@ class TableMeta(type):
|
|
|
201
213
|
Insert multiple rows, returns a TypedRows set of new instances.
|
|
202
214
|
"""
|
|
203
215
|
table = self._ensure_table_defined()
|
|
216
|
+
require_permission(self._permissions, "insert")
|
|
204
217
|
result = table.bulk_insert(items)
|
|
205
218
|
return self.where(lambda row: row.id.belongs(result)).collect()
|
|
206
219
|
|
|
@@ -239,6 +252,7 @@ class TableMeta(type):
|
|
|
239
252
|
Returns a tuple of (the created instance, a dict of errors).
|
|
240
253
|
"""
|
|
241
254
|
table = self._ensure_table_defined()
|
|
255
|
+
require_permission(self._permissions, "insert")
|
|
242
256
|
result = table.validate_and_insert(**fields)
|
|
243
257
|
if row_id := result.get("id"):
|
|
244
258
|
return self(row_id), None
|
|
@@ -256,6 +270,7 @@ class TableMeta(type):
|
|
|
256
270
|
Returns a tuple of (the updated instance, a dict of errors).
|
|
257
271
|
"""
|
|
258
272
|
table = self._ensure_table_defined()
|
|
273
|
+
require_permission(self._permissions, "update")
|
|
259
274
|
|
|
260
275
|
result = table.validate_and_update(query, **fields)
|
|
261
276
|
|
|
@@ -278,7 +293,14 @@ class TableMeta(type):
|
|
|
278
293
|
Returns a tuple of (the updated/created instance, a dict of errors).
|
|
279
294
|
"""
|
|
280
295
|
table = self._ensure_table_defined()
|
|
281
|
-
|
|
296
|
+
record = table(query)
|
|
297
|
+
|
|
298
|
+
if record:
|
|
299
|
+
require_permission(self._permissions, "update")
|
|
300
|
+
result = table.validate_and_update(query, **fields)
|
|
301
|
+
else:
|
|
302
|
+
require_permission(self._permissions, "insert")
|
|
303
|
+
result = table.validate_and_insert(**fields)
|
|
282
304
|
|
|
283
305
|
if errors := result.get("errors"):
|
|
284
306
|
return None, errors
|
|
@@ -348,6 +370,12 @@ class TableMeta(type):
|
|
|
348
370
|
"""
|
|
349
371
|
return QueryBuilder(self).cache(*deps, **kwargs)
|
|
350
372
|
|
|
373
|
+
def permissions(self: t.Type[T_MetaInstance], **permissions: bool) -> "QueryBuilder[T_MetaInstance]":
|
|
374
|
+
"""
|
|
375
|
+
See QueryBuilder.permissions!
|
|
376
|
+
"""
|
|
377
|
+
return QueryBuilder(self).permissions(**permissions)
|
|
378
|
+
|
|
351
379
|
def count(self: t.Type[T_MetaInstance]) -> int:
|
|
352
380
|
"""
|
|
353
381
|
See QueryBuilder.count!
|
|
@@ -880,12 +908,13 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
|
|
|
880
908
|
}
|
|
881
909
|
|
|
882
910
|
def _setup_instance_methods(self) -> None:
|
|
883
|
-
self.as_dict = self._as_dict # type: ignore
|
|
884
|
-
self.__json__ = self.as_json = self._as_json # type: ignore
|
|
885
|
-
# self.as_yaml = self._as_yaml # type: ignore
|
|
886
|
-
self.as_xml = self._as_xml # type: ignore
|
|
887
|
-
|
|
888
911
|
# use setattr instead of self.x = y to make the typecheckers happier
|
|
912
|
+
setattr(self, "as_dict", self._as_dict)
|
|
913
|
+
setattr(self, "__json__", self._as_json)
|
|
914
|
+
setattr(self, "as_json", self._as_json)
|
|
915
|
+
setattr(self, "as_xml", self._as_xml)
|
|
916
|
+
# setattr(self, "as_yaml", self._as_yaml)
|
|
917
|
+
|
|
889
918
|
setattr(self, "update", self._update)
|
|
890
919
|
setattr(self, "delete_record", self._delete_record)
|
|
891
920
|
setattr(self, "update_record", self._update_record)
|
|
@@ -1253,12 +1282,14 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
|
|
|
1253
1282
|
return None
|
|
1254
1283
|
|
|
1255
1284
|
def _update(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
|
|
1285
|
+
require_permission(getattr(self, "_permissions", None), "update")
|
|
1256
1286
|
row = self._ensure_matching_row()
|
|
1257
1287
|
row.update(**fields)
|
|
1258
1288
|
self.__dict__.update(**fields)
|
|
1259
1289
|
return self
|
|
1260
1290
|
|
|
1261
1291
|
def _update_record(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
|
|
1292
|
+
require_permission(getattr(self, "_permissions", None), "update")
|
|
1262
1293
|
row = self._ensure_matching_row()
|
|
1263
1294
|
new_row = row.update_record(**fields)
|
|
1264
1295
|
self._update(**new_row)
|
|
@@ -1276,6 +1307,7 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
|
|
|
1276
1307
|
"""
|
|
1277
1308
|
Actual logic in `pydal.helpers.classes.RecordDeleter`.
|
|
1278
1309
|
"""
|
|
1310
|
+
require_permission(getattr(self, "_permissions", None), "delete")
|
|
1279
1311
|
row = self._ensure_matching_row()
|
|
1280
1312
|
result = row.delete_record()
|
|
1281
1313
|
self.__dict__ = {} # empty self, since row is no more.
|
|
@@ -44,6 +44,41 @@ Template: t.TypeAlias = TemplateAlias # explicit export for mypy, NOT a `type`
|
|
|
44
44
|
type AnyCallable = t.Callable[..., t.Any]
|
|
45
45
|
type AnyDict = dict[str, t.Any]
|
|
46
46
|
|
|
47
|
+
PermissionType = t.Literal["read", "insert", "update", "delete"]
|
|
48
|
+
|
|
49
|
+
type Permissions = dict[PermissionType, bool]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def merge_permissions(*permission_sets: Permissions | None) -> Permissions:
|
|
53
|
+
"""
|
|
54
|
+
Merge zero or more permission mappings, keeping the most restrictive result.
|
|
55
|
+
|
|
56
|
+
Unspecified flags default to allowed.
|
|
57
|
+
"""
|
|
58
|
+
permission_types = t.get_args(PermissionType)
|
|
59
|
+
merged: dict[str, bool] = {key: True for key in permission_types}
|
|
60
|
+
|
|
61
|
+
for permission_set in permission_sets:
|
|
62
|
+
if not permission_set:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
for key in permission_types:
|
|
66
|
+
if key in permission_set:
|
|
67
|
+
merged[key] = merged[key] and bool(permission_set[key])
|
|
68
|
+
|
|
69
|
+
return t.cast(Permissions, merged)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def permission_allowed(permissions: Permissions | None, action: PermissionType) -> bool:
|
|
73
|
+
"""Return whether the given permission flag is enabled."""
|
|
74
|
+
return merge_permissions(permissions).get(action, False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def require_permission(permissions: Permissions | None, action: PermissionType) -> None:
|
|
78
|
+
"""Raise PermissionError when a permission flag is disabled."""
|
|
79
|
+
if not permission_allowed(permissions, action):
|
|
80
|
+
raise PermissionError(f"{action} is not allowed")
|
|
81
|
+
|
|
47
82
|
|
|
48
83
|
# ---------------------------------------------------------------------------
|
|
49
84
|
# Protocols
|
|
@@ -307,6 +342,35 @@ class FieldSettings(t.TypedDict, total=False):
|
|
|
307
342
|
rname: str
|
|
308
343
|
|
|
309
344
|
|
|
345
|
+
class DefineKwargs(t.TypedDict, total=False):
|
|
346
|
+
"""
|
|
347
|
+
Keyword arguments accepted by `db.define()` and forwarded to `define_table()`.
|
|
348
|
+
|
|
349
|
+
`cache_dependency` is internal to TypeDAL and stripped before calling PyDAL.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
# typedal-specific
|
|
353
|
+
cache_dependency: bool
|
|
354
|
+
permissions: Permissions
|
|
355
|
+
|
|
356
|
+
# common
|
|
357
|
+
fake_migrate: bool
|
|
358
|
+
migrate: bool
|
|
359
|
+
redefine: bool
|
|
360
|
+
rname: str
|
|
361
|
+
singular: str
|
|
362
|
+
plural: str
|
|
363
|
+
format: str
|
|
364
|
+
|
|
365
|
+
# pydal-internals
|
|
366
|
+
common_filter: t.Callable[..., t.Any]
|
|
367
|
+
on_define: t.Callable[["Table"], t.Any]
|
|
368
|
+
primarykey: list[str] | tuple[str, ...]
|
|
369
|
+
sequence_name: str
|
|
370
|
+
table_class: type[t.Any]
|
|
371
|
+
trigger_name: str
|
|
372
|
+
|
|
373
|
+
|
|
310
374
|
# ---------------------------------------------------------------------------
|
|
311
375
|
# Generics & Query Helpers
|
|
312
376
|
# ---------------------------------------------------------------------------
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import datetime as dt
|
|
2
|
-
from datetime import datetime
|
|
3
2
|
import os
|
|
4
3
|
import shutil
|
|
5
4
|
import sqlite3
|
|
6
5
|
import tempfile
|
|
7
6
|
import uuid
|
|
7
|
+
from datetime import datetime
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
import pytest
|
|
@@ -198,7 +198,6 @@ def test_timestamp_fields_sqlite(at_temp_dir):
|
|
|
198
198
|
assert '"ts" timestamp NOT NULL' in Timestamp._sql()
|
|
199
199
|
|
|
200
200
|
|
|
201
|
-
|
|
202
201
|
def test_timestamp_fields_psql(at_temp_dir):
|
|
203
202
|
examples = Path(__file__).parent / "configs"
|
|
204
203
|
shutil.copy(examples / "valid.env", "./.env")
|
|
@@ -229,7 +228,6 @@ def test_timestamp_fields_psql(at_temp_dir):
|
|
|
229
228
|
assert '"ts" timestamp NOT NULL' in Timestamp._sql()
|
|
230
229
|
|
|
231
230
|
|
|
232
|
-
|
|
233
231
|
def test_point_fields_sqlite(at_temp_dir):
|
|
234
232
|
db = TypeDAL("sqlite:memory")
|
|
235
233
|
|
|
@@ -26,7 +26,7 @@ from src.typedal.helpers import (
|
|
|
26
26
|
to_snake,
|
|
27
27
|
unwrap_type,
|
|
28
28
|
)
|
|
29
|
-
from src.typedal.types import Field
|
|
29
|
+
from src.typedal.types import Field, merge_permissions
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def test_is_union():
|
|
@@ -273,3 +273,8 @@ def test_sql_expression_314():
|
|
|
273
273
|
from .py314_tests import test_sql_expression_314
|
|
274
274
|
|
|
275
275
|
test_sql_expression_314(database)
|
|
276
|
+
|
|
277
|
+
def test_merge_permissions():
|
|
278
|
+
combined = merge_permissions({"read": True, "insert": False}, {"read": False, "insert": False})
|
|
279
|
+
assert len(combined) == 4
|
|
280
|
+
assert combined == {"read": False, "insert": False, "update": True, "delete": True}
|
|
@@ -187,6 +187,47 @@ def test_mixed_defines(capsys):
|
|
|
187
187
|
assert db.find_model(SecondNewSyntax._rname) is SecondNewSyntax
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def test_disallow_insert():
|
|
191
|
+
@db.define(permissions={"insert": False})
|
|
192
|
+
class ReadOnlyTable(TypedTable):
|
|
193
|
+
name: str
|
|
194
|
+
|
|
195
|
+
with pytest.raises(PermissionError, match="insert"):
|
|
196
|
+
ReadOnlyTable.insert(name="blocked")
|
|
197
|
+
|
|
198
|
+
with pytest.raises(PermissionError, match="insert"):
|
|
199
|
+
ReadOnlyTable.bulk_insert([{"name": "blocked"}])
|
|
200
|
+
|
|
201
|
+
with pytest.raises(PermissionError, match="insert"):
|
|
202
|
+
ReadOnlyTable.validate_and_insert(name="blocked")
|
|
203
|
+
|
|
204
|
+
with pytest.raises(PermissionError, match="insert"):
|
|
205
|
+
ReadOnlyTable.update_or_insert(name="blocked")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_disallow_update_and_delete():
|
|
209
|
+
@db.define(permissions={"update": False})
|
|
210
|
+
class ReadOnlyUpdateTable(TypedTable):
|
|
211
|
+
name: str
|
|
212
|
+
|
|
213
|
+
update_row = ReadOnlyUpdateTable.insert(name="seed")
|
|
214
|
+
|
|
215
|
+
with pytest.raises(PermissionError, match="update"):
|
|
216
|
+
update_row.update_record(name="changed")
|
|
217
|
+
|
|
218
|
+
with pytest.raises(PermissionError, match="update"):
|
|
219
|
+
ReadOnlyUpdateTable.update(ReadOnlyUpdateTable.id == update_row.id, name="changed")
|
|
220
|
+
|
|
221
|
+
@db.define(permissions={"delete": False})
|
|
222
|
+
class ReadOnlyDeleteTable(TypedTable):
|
|
223
|
+
name: str
|
|
224
|
+
|
|
225
|
+
delete_row = ReadOnlyDeleteTable.insert(name="seed")
|
|
226
|
+
|
|
227
|
+
with pytest.raises(PermissionError, match="delete"):
|
|
228
|
+
delete_row.delete_record()
|
|
229
|
+
|
|
230
|
+
|
|
190
231
|
def test_dont_allow_bool_in_query():
|
|
191
232
|
with pytest.raises(ValueError):
|
|
192
233
|
db(True)
|
|
@@ -627,6 +627,32 @@ def test_minimal_functionality_on_pydal_style_tables():
|
|
|
627
627
|
assert first_or_fail.id == qb1.first().id
|
|
628
628
|
|
|
629
629
|
|
|
630
|
+
def test_query_builder_permissions():
|
|
631
|
+
_setup_data()
|
|
632
|
+
|
|
633
|
+
read_restricted = TestQueryTable.permissions(read=False).where(number=2)
|
|
634
|
+
|
|
635
|
+
# to_sql stays available because it is an internal/debug helper, not a query execution path.
|
|
636
|
+
assert "select" in read_restricted.to_sql().lower()
|
|
637
|
+
|
|
638
|
+
with pytest.raises(PermissionError, match="read"):
|
|
639
|
+
read_restricted.collect()
|
|
640
|
+
|
|
641
|
+
update_only = TestQueryTable.permissions(read=False, update=True, delete=False).where(number=2)
|
|
642
|
+
assert update_only.update(number=20) == [3]
|
|
643
|
+
assert TestQueryTable(3).number == 20
|
|
644
|
+
|
|
645
|
+
delete_only = TestQueryTable.where(number=4).permissions(read=False, update=False, delete=True)
|
|
646
|
+
assert delete_only.delete() == [5]
|
|
647
|
+
assert TestQueryTable(5) is None
|
|
648
|
+
|
|
649
|
+
with pytest.raises(PermissionError, match="update"):
|
|
650
|
+
TestQueryTable.where(number=1).permissions(update=False).update(number=11)
|
|
651
|
+
|
|
652
|
+
with pytest.raises(PermissionError, match="delete"):
|
|
653
|
+
TestQueryTable.where(number=0).permissions(delete=False).delete()
|
|
654
|
+
|
|
655
|
+
|
|
630
656
|
def test_before_after_collect(capsys):
|
|
631
657
|
_setup_data()
|
|
632
658
|
|
typedal-4.8.6/coverage.svg
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="122" height="20" role="img" aria-label="coverage: 100.00%"><title>coverage: 100.00%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="122" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="61" height="20" fill="#4b0"/><rect width="122" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="905" y="150" fill="#010101" fill-opacity=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">100.00%</text><text aria-hidden="true" x="905" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">100.00%</text><text x="905" y="140" transform="scale(.1)" fill="#fff" textLength="510">100.00%</text></g></svg>
|
|
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
|