TypeDAL 4.8.7__tar.gz → 4.9.1__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.7 → typedal-4.9.1}/CHANGELOG.md +17 -0
- {typedal-4.8.7 → typedal-4.9.1}/PKG-INFO +1 -1
- {typedal-4.8.7 → typedal-4.9.1}/docs/2_defining_tables.md +21 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/3_building_queries.md +22 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/__about__.py +1 -1
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/core.py +8 -4
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/define.py +4 -2
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/query_builder.py +24 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/tables.py +35 -2
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/types.py +72 -1
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_helpers.py +7 -1
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_main.py +41 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_query_builder.py +26 -0
- {typedal-4.8.7 → typedal-4.9.1}/.github/workflows/su6.yml +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/.gitignore +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/.readthedocs.yml +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/README.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/coverage.svg +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/10_advanced_apis.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/1_getting_started.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/4_relationships.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/5_py4web.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/6_migrations.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/7_configuration.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/8_mixins.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/9_memoization.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/css/code_blocks.css +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/index.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/docs/requirements.txt +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/example_new.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/example_old.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/mkdocs.yml +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/pyproject.toml +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/__init__.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/caching.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/cli.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/config.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/constants.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/enum_helpers.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/fields.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/for_py4web.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/for_web2py.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/helpers.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/mixins.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/py.typed +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/relationships.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/rows.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/serializers/as_json.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/serializers/typescript.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/src/typedal/web2py_py4web_shared.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tasks.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/__init__.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/configs/simple.toml +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/configs/valid.env +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/configs/valid.toml +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/py314_tests.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_cli.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_config.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_docs_examples.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_json.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_mixins.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_mypy.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_orm.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_py4web.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_relationships.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_row.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_stats.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_table.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_typescript.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_typing_mypy.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_typing_pyright.md +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_web2py.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/test_xx_others.py +0 -0
- {typedal-4.8.7 → typedal-4.9.1}/tests/timings.py +0 -0
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
<!--next-version-placeholder-->
|
|
4
4
|
|
|
5
|
+
## v4.9.1 (2026-07-02)
|
|
6
|
+
|
|
7
|
+
### Fix
|
|
8
|
+
|
|
9
|
+
* **typing:** `select(distinct=` can also be a str ([`dc05389`](https://github.com/trialandsuccess/TypeDAL/commit/dc05389e89102cfb5de4a3b174857b8a7866a005))
|
|
10
|
+
* Improved typing for Permissions ([`236e4a8`](https://github.com/trialandsuccess/TypeDAL/commit/236e4a8ebfc42ed438dc8f626c192c6baca52f02))
|
|
11
|
+
|
|
12
|
+
### Documentation
|
|
13
|
+
|
|
14
|
+
* Added info about `permissions` in docs ([`a5a9a85`](https://github.com/trialandsuccess/TypeDAL/commit/a5a9a85bb737acf5d5521e8923df56b3608b1ef5))
|
|
15
|
+
|
|
16
|
+
## v4.9.0 (2026-06-12)
|
|
17
|
+
|
|
18
|
+
### Feature
|
|
19
|
+
|
|
20
|
+
* **orm:** Add table and query builder permissions ([`e737666`](https://github.com/trialandsuccess/TypeDAL/commit/e737666b5f16a62a7c71ff1e0c21ed9d4f7418f5))
|
|
21
|
+
|
|
5
22
|
## v4.8.7 (2026-06-11)
|
|
6
23
|
|
|
7
24
|
### Fix
|
|
@@ -89,6 +89,27 @@ Important constraints and behavior:
|
|
|
89
89
|
| `Field('name', 'string', required=True)` | `name: str` | `name: TypedField[str]` | `name = TypedField(str, required=True)` | `name = StringField(required=True)` |
|
|
90
90
|
| `Field('name', 'text', required=False)` | `name: typing.Optional[str]` or <br/> <code>name: str | None</code> | `name: TypedField[typing.Optional[str]]` or <br/> <code>name: TypedField[str | None]</code> | `name = TypedField(str, type="text", required=False)` | `name = StringField(required=False)` |
|
|
91
91
|
|
|
92
|
+
### Table permissions
|
|
93
|
+
|
|
94
|
+
You can restrict table-level operations when defining a model:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
@db.define(permissions={"read": True, "insert": False, "update": True, "delete": False})
|
|
98
|
+
class Article(TypedTable):
|
|
99
|
+
title: str
|
|
100
|
+
body: str
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Permissions are currently available for:
|
|
104
|
+
|
|
105
|
+
- `read`
|
|
106
|
+
- `insert`
|
|
107
|
+
- `update`
|
|
108
|
+
- `delete`
|
|
109
|
+
|
|
110
|
+
Any permission you do not specify is treated as allowed by default.
|
|
111
|
+
Use this when a table should exist in the schema but should not be writable, or when you want to prevent reads from a given model entirely.
|
|
112
|
+
|
|
92
113
|
# Hooks
|
|
93
114
|
|
|
94
115
|
Some logic can be added when data is added/edited/deleted from the database.
|
|
@@ -219,6 +219,28 @@ Be aware doing this might break some caching functionality!
|
|
|
219
219
|
|
|
220
220
|
**Note:** For caching function results (instead of just query results), see [9. Function Memoization](./9_memoization.md).
|
|
221
221
|
|
|
222
|
+
### permissions
|
|
223
|
+
|
|
224
|
+
TypeDAL lets you override permissions on a query builder without changing the underlying table definition.
|
|
225
|
+
This is useful when a query should be more restrictive or more permissive than the model defaults.
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
# deny reads for this query
|
|
229
|
+
restricted = Article.permissions(read=False).where(id=1)
|
|
230
|
+
|
|
231
|
+
# allow a write operation only for this chain
|
|
232
|
+
Article.where(id=1).permissions(update=True, delete=False).update(title="New title")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The same method is available on `TypedTable` as a convenience wrapper:
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
Article.permissions(read=False).where(id=1).collect()
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Permissions merge across the table definition and the query chain, with the most restrictive value winning.
|
|
242
|
+
Supported flags are `read`, `insert`, `update`, and `delete`.
|
|
243
|
+
|
|
222
244
|
### Collecting
|
|
223
245
|
|
|
224
246
|
The Query Builder has a few operations that don't return a new builder instance:
|
|
@@ -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
|
|
@@ -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,14 @@ class TableMeta(type):
|
|
|
348
370
|
"""
|
|
349
371
|
return QueryBuilder(self).cache(*deps, **kwargs)
|
|
350
372
|
|
|
373
|
+
def permissions(
|
|
374
|
+
self: t.Type[T_MetaInstance], **permissions: t.Unpack[Permissions]
|
|
375
|
+
) -> "QueryBuilder[T_MetaInstance]":
|
|
376
|
+
"""
|
|
377
|
+
See QueryBuilder.permissions!
|
|
378
|
+
"""
|
|
379
|
+
return QueryBuilder(self).permissions(**permissions)
|
|
380
|
+
|
|
351
381
|
def count(self: t.Type[T_MetaInstance]) -> int:
|
|
352
382
|
"""
|
|
353
383
|
See QueryBuilder.count!
|
|
@@ -1254,12 +1284,14 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
|
|
|
1254
1284
|
return None
|
|
1255
1285
|
|
|
1256
1286
|
def _update(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
|
|
1287
|
+
require_permission(getattr(self, "_permissions", None), "update")
|
|
1257
1288
|
row = self._ensure_matching_row()
|
|
1258
1289
|
row.update(**fields)
|
|
1259
1290
|
self.__dict__.update(**fields)
|
|
1260
1291
|
return self
|
|
1261
1292
|
|
|
1262
1293
|
def _update_record(self: T_MetaInstance, **fields: t.Any) -> T_MetaInstance:
|
|
1294
|
+
require_permission(getattr(self, "_permissions", None), "update")
|
|
1263
1295
|
row = self._ensure_matching_row()
|
|
1264
1296
|
new_row = row.update_record(**fields)
|
|
1265
1297
|
self._update(**new_row)
|
|
@@ -1277,6 +1309,7 @@ class TypedTable(_TypedTable, metaclass=TableMeta):
|
|
|
1277
1309
|
"""
|
|
1278
1310
|
Actual logic in `pydal.helpers.classes.RecordDeleter`.
|
|
1279
1311
|
"""
|
|
1312
|
+
require_permission(getattr(self, "_permissions", None), "delete")
|
|
1280
1313
|
row = self._ensure_matching_row()
|
|
1281
1314
|
result = row.delete_record()
|
|
1282
1315
|
self.__dict__ = {} # empty self, since row is no more.
|
|
@@ -44,6 +44,48 @@ 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
|
+
|
|
50
|
+
# type Permissions = dict[PermissionType, bool]
|
|
51
|
+
class Permissions(t.TypedDict):
|
|
52
|
+
# note: extra source of truth because the dynamic dict doesn't work for all type checkers
|
|
53
|
+
read: bool
|
|
54
|
+
insert: bool
|
|
55
|
+
update: bool
|
|
56
|
+
delete: bool
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def merge_permissions(*permission_sets: Permissions | None) -> Permissions:
|
|
60
|
+
"""
|
|
61
|
+
Merge zero or more permission mappings, keeping the most restrictive result.
|
|
62
|
+
|
|
63
|
+
Unspecified flags default to allowed.
|
|
64
|
+
"""
|
|
65
|
+
permission_types = t.get_args(PermissionType)
|
|
66
|
+
merged: dict[str, bool] = {key: True for key in permission_types}
|
|
67
|
+
|
|
68
|
+
for permission_set in permission_sets:
|
|
69
|
+
if not permission_set:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
for key in permission_types:
|
|
73
|
+
if key in permission_set:
|
|
74
|
+
merged[key] = merged[key] and bool(permission_set[key])
|
|
75
|
+
|
|
76
|
+
return t.cast(Permissions, merged)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def permission_allowed(permissions: Permissions | None, action: PermissionType) -> bool:
|
|
80
|
+
"""Return whether the given permission flag is enabled."""
|
|
81
|
+
return merge_permissions(permissions).get(action, False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def require_permission(permissions: Permissions | None, action: PermissionType) -> None:
|
|
85
|
+
"""Raise PermissionError when a permission flag is disabled."""
|
|
86
|
+
if not permission_allowed(permissions, action):
|
|
87
|
+
raise PermissionError(f"{action} is not allowed")
|
|
88
|
+
|
|
47
89
|
|
|
48
90
|
# ---------------------------------------------------------------------------
|
|
49
91
|
# Protocols
|
|
@@ -241,7 +283,7 @@ class SelectKwargs(t.TypedDict, total=False):
|
|
|
241
283
|
groupby: "GroupBy | t.Iterable[GroupBy] | None"
|
|
242
284
|
having: "Having | None"
|
|
243
285
|
limitby: t.Optional[tuple[int, int]]
|
|
244
|
-
distinct: bool | Field | Expression
|
|
286
|
+
distinct: bool | Field | Expression | str
|
|
245
287
|
orderby_on_limitby: bool
|
|
246
288
|
cacheable: bool
|
|
247
289
|
cache: "CacheTuple"
|
|
@@ -307,6 +349,35 @@ class FieldSettings(t.TypedDict, total=False):
|
|
|
307
349
|
rname: str
|
|
308
350
|
|
|
309
351
|
|
|
352
|
+
class DefineKwargs(t.TypedDict, total=False):
|
|
353
|
+
"""
|
|
354
|
+
Keyword arguments accepted by `db.define()` and forwarded to `define_table()`.
|
|
355
|
+
|
|
356
|
+
`cache_dependency` is internal to TypeDAL and stripped before calling PyDAL.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
# typedal-specific
|
|
360
|
+
cache_dependency: bool
|
|
361
|
+
permissions: Permissions
|
|
362
|
+
|
|
363
|
+
# common
|
|
364
|
+
fake_migrate: bool
|
|
365
|
+
migrate: bool
|
|
366
|
+
redefine: bool
|
|
367
|
+
rname: str
|
|
368
|
+
singular: str
|
|
369
|
+
plural: str
|
|
370
|
+
format: str
|
|
371
|
+
|
|
372
|
+
# pydal-internals
|
|
373
|
+
common_filter: t.Callable[..., t.Any]
|
|
374
|
+
on_define: t.Callable[["Table"], t.Any]
|
|
375
|
+
primarykey: list[str] | tuple[str, ...]
|
|
376
|
+
sequence_name: str
|
|
377
|
+
table_class: type[t.Any]
|
|
378
|
+
trigger_name: str
|
|
379
|
+
|
|
380
|
+
|
|
310
381
|
# ---------------------------------------------------------------------------
|
|
311
382
|
# Generics & Query Helpers
|
|
312
383
|
# ---------------------------------------------------------------------------
|
|
@@ -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,9 @@ 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
|
+
|
|
278
|
+
def test_merge_permissions():
|
|
279
|
+
combined = merge_permissions({"read": True, "insert": False}, {"read": False, "insert": False})
|
|
280
|
+
assert len(combined) == 4
|
|
281
|
+
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
|
|
|
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
|