python-flashapi 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- flashapi/__init__.py +8 -7
- flashapi/adapters/base.py +13 -12
- flashapi/adapters/django.py +771 -165
- flashapi/adapters/fastapi.py +879 -293
- flashapi/adapters/flask.py +728 -232
- flashapi/core/__init__.py +11 -3
- flashapi/core/custom_routes.py +5 -5
- flashapi/core/pluralize.py +80 -80
- flashapi/core/relations.py +59 -61
- flashapi/core/response.py +36 -33
- flashapi/core/schema.py +145 -83
- flashapi/core/visibility.py +46 -0
- flashapi/django.py +5 -5
- flashapi/docs/openapi.py +298 -223
- flashapi/fastapi.py +5 -5
- flashapi/features/__init__.py +6 -6
- flashapi/features/audit.py +84 -0
- flashapi/features/auth.py +134 -0
- flashapi/features/dashboard.py +412 -0
- flashapi/features/export.py +143 -0
- flashapi/features/filtering.py +124 -33
- flashapi/features/pagination.py +20 -20
- flashapi/features/rate_limit.py +45 -0
- flashapi/features/search.py +25 -25
- flashapi/features/sorting.py +23 -21
- flashapi/features/webhooks.py +84 -0
- flashapi/features/websocket.py +129 -0
- flashapi/flask.py +5 -5
- flashapi/inspectors/__init__.py +3 -3
- flashapi/inspectors/base.py +13 -11
- flashapi/inspectors/dataclass.py +50 -39
- flashapi/inspectors/detect.py +54 -48
- flashapi/inspectors/django.py +93 -83
- flashapi/inspectors/pydantic.py +99 -84
- flashapi/inspectors/sqlalchemy.py +83 -75
- flashapi/storage/__init__.py +4 -4
- flashapi/storage/auto.py +189 -106
- flashapi/storage/base.py +32 -26
- flashapi/storage/orm.py +125 -85
- flashapi/storage/sqlalchemy.py +56 -13
- python_flashapi-0.2.0.dist-info/METADATA +314 -0
- python_flashapi-0.2.0.dist-info/RECORD +48 -0
- python_flashapi-0.2.0.dist-info/licenses/LICENSE +190 -0
- python_flashapi-0.2.0.dist-info/licenses/NOTICE +5 -0
- python_flashapi-0.1.2.dist-info/METADATA +0 -259
- python_flashapi-0.1.2.dist-info/RECORD +0 -39
- python_flashapi-0.1.2.dist-info/licenses/LICENSE +0 -21
- {python_flashapi-0.1.2.dist-info → python_flashapi-0.2.0.dist-info}/WHEEL +0 -0
flashapi/inspectors/pydantic.py
CHANGED
|
@@ -1,84 +1,99 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import date, datetime, time
|
|
4
|
-
from decimal import Decimal
|
|
5
|
-
from typing import Any,
|
|
6
|
-
from uuid import UUID
|
|
7
|
-
|
|
8
|
-
from flashapi.core.
|
|
9
|
-
from flashapi.core.
|
|
10
|
-
from flashapi.inspectors.base import Inspector
|
|
11
|
-
|
|
12
|
-
TYPE_MAP: dict[type, FieldType] = {
|
|
13
|
-
str: FieldType.STRING,
|
|
14
|
-
int: FieldType.INTEGER,
|
|
15
|
-
float: FieldType.FLOAT,
|
|
16
|
-
bool: FieldType.BOOLEAN,
|
|
17
|
-
datetime: FieldType.DATETIME,
|
|
18
|
-
date: FieldType.DATE,
|
|
19
|
-
time: FieldType.TIME,
|
|
20
|
-
UUID: FieldType.UUID,
|
|
21
|
-
Decimal: FieldType.FLOAT,
|
|
22
|
-
bytes: FieldType.BINARY,
|
|
23
|
-
dict: FieldType.JSON,
|
|
24
|
-
list: FieldType.JSON,
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class PydanticInspector(Inspector):
|
|
29
|
-
def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
|
|
30
|
-
from pydantic import BaseModel
|
|
31
|
-
|
|
32
|
-
if not issubclass(model_class, BaseModel):
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
fields
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime, time
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Any, get_args, get_origin
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from flashapi.core.pluralize import pluralize
|
|
9
|
+
from flashapi.core.schema import FieldSchema, FieldType, ModelSchema
|
|
10
|
+
from flashapi.inspectors.base import Inspector
|
|
11
|
+
|
|
12
|
+
TYPE_MAP: dict[type, FieldType] = {
|
|
13
|
+
str: FieldType.STRING,
|
|
14
|
+
int: FieldType.INTEGER,
|
|
15
|
+
float: FieldType.FLOAT,
|
|
16
|
+
bool: FieldType.BOOLEAN,
|
|
17
|
+
datetime: FieldType.DATETIME,
|
|
18
|
+
date: FieldType.DATE,
|
|
19
|
+
time: FieldType.TIME,
|
|
20
|
+
UUID: FieldType.UUID,
|
|
21
|
+
Decimal: FieldType.FLOAT,
|
|
22
|
+
bytes: FieldType.BINARY,
|
|
23
|
+
dict: FieldType.JSON,
|
|
24
|
+
list: FieldType.JSON,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PydanticInspector(Inspector):
|
|
29
|
+
def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
|
|
30
|
+
from pydantic import BaseModel
|
|
31
|
+
|
|
32
|
+
if not issubclass(model_class, BaseModel):
|
|
33
|
+
msg = f"{model_class} is not a Pydantic model"
|
|
34
|
+
raise TypeError(msg)
|
|
35
|
+
|
|
36
|
+
fields: list[FieldSchema] = []
|
|
37
|
+
fields.append(FieldSchema(name="id", type=FieldType.INTEGER, required=False, primary_key=True, auto_generated=True))
|
|
38
|
+
|
|
39
|
+
for name, field_info in model_class.model_fields.items():
|
|
40
|
+
field_type = self._resolve_type(field_info.annotation)
|
|
41
|
+
constraints = self._extract_constraints(field_info)
|
|
42
|
+
required = field_info.is_required()
|
|
43
|
+
default = field_info.default if not required else None
|
|
44
|
+
visibility = self._extract_visibility(field_info)
|
|
45
|
+
|
|
46
|
+
fields.append(FieldSchema(
|
|
47
|
+
name=name,
|
|
48
|
+
type=field_type,
|
|
49
|
+
required=required,
|
|
50
|
+
default=default,
|
|
51
|
+
constraints=constraints,
|
|
52
|
+
**visibility,
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
model_name = model_class.__name__
|
|
56
|
+
plural_name = plural or pluralize(model_name)
|
|
57
|
+
|
|
58
|
+
return ModelSchema(name=model_name, plural=plural_name, fields=fields)
|
|
59
|
+
|
|
60
|
+
def _resolve_type(self, annotation: Any) -> FieldType:
|
|
61
|
+
if annotation is None:
|
|
62
|
+
return FieldType.STRING
|
|
63
|
+
|
|
64
|
+
origin = get_origin(annotation)
|
|
65
|
+
if origin is not None:
|
|
66
|
+
args = get_args(annotation)
|
|
67
|
+
non_none = [a for a in args if a is not type(None)]
|
|
68
|
+
if non_none:
|
|
69
|
+
annotation = non_none[0]
|
|
70
|
+
else:
|
|
71
|
+
return FieldType.STRING
|
|
72
|
+
|
|
73
|
+
return TYPE_MAP.get(annotation, FieldType.STRING)
|
|
74
|
+
|
|
75
|
+
def _extract_constraints(self, field_info: Any) -> dict:
|
|
76
|
+
constraints = {}
|
|
77
|
+
metadata = getattr(field_info, "metadata", [])
|
|
78
|
+
for m in metadata:
|
|
79
|
+
if hasattr(m, "max_length"):
|
|
80
|
+
constraints["max_length"] = m.max_length
|
|
81
|
+
if hasattr(m, "min_length"):
|
|
82
|
+
constraints["min_length"] = m.min_length
|
|
83
|
+
if hasattr(m, "ge"):
|
|
84
|
+
constraints["min_value"] = m.ge
|
|
85
|
+
if hasattr(m, "le"):
|
|
86
|
+
constraints["max_value"] = m.le
|
|
87
|
+
return constraints
|
|
88
|
+
|
|
89
|
+
def _extract_visibility(self, field_info: Any) -> dict:
|
|
90
|
+
visibility = {}
|
|
91
|
+
extra = getattr(field_info, "json_schema_extra", None) or {}
|
|
92
|
+
flash = extra.get("flash", {}) if isinstance(extra, dict) else {}
|
|
93
|
+
for key in ("readonly", "writeonly", "hidden", "export_exclude"):
|
|
94
|
+
if flash.get(key):
|
|
95
|
+
visibility[key] = True
|
|
96
|
+
if flash.get("auto"):
|
|
97
|
+
visibility["auto"] = flash["auto"]
|
|
98
|
+
visibility["auto_generated"] = True
|
|
99
|
+
return visibility
|
|
@@ -1,75 +1,83 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from flashapi.core.
|
|
4
|
-
from flashapi.core.
|
|
5
|
-
from flashapi.inspectors.base import Inspector
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _map_sa_type(col_type) -> FieldType:
|
|
9
|
-
type_name = type(col_type).__name__.upper()
|
|
10
|
-
mapping = {
|
|
11
|
-
"VARCHAR": FieldType.STRING,
|
|
12
|
-
"STRING": FieldType.STRING,
|
|
13
|
-
"TEXT": FieldType.TEXT,
|
|
14
|
-
"INTEGER": FieldType.INTEGER,
|
|
15
|
-
"BIGINTEGER": FieldType.INTEGER,
|
|
16
|
-
"SMALLINTEGER": FieldType.INTEGER,
|
|
17
|
-
"FLOAT": FieldType.FLOAT,
|
|
18
|
-
"NUMERIC": FieldType.FLOAT,
|
|
19
|
-
"BOOLEAN": FieldType.BOOLEAN,
|
|
20
|
-
"DATE": FieldType.DATE,
|
|
21
|
-
"DATETIME": FieldType.DATETIME,
|
|
22
|
-
"TIME": FieldType.TIME,
|
|
23
|
-
"UUID": FieldType.UUID,
|
|
24
|
-
"JSON": FieldType.JSON,
|
|
25
|
-
"BLOB": FieldType.BINARY,
|
|
26
|
-
"LARGEBINARY": FieldType.BINARY,
|
|
27
|
-
}
|
|
28
|
-
return mapping.get(type_name, FieldType.STRING)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class SQLAlchemyInspector(Inspector):
|
|
32
|
-
def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
|
|
33
|
-
table = model_class.__table__
|
|
34
|
-
fields: list[FieldSchema] = []
|
|
35
|
-
|
|
36
|
-
for col in table.columns:
|
|
37
|
-
field_type = _map_sa_type(col.type)
|
|
38
|
-
constraints = {}
|
|
39
|
-
|
|
40
|
-
if hasattr(col.type, "length") and col.type.length:
|
|
41
|
-
constraints["max_length"] = col.type.length
|
|
42
|
-
|
|
43
|
-
relation = None
|
|
44
|
-
if col.foreign_keys:
|
|
45
|
-
fk = next(iter(col.foreign_keys))
|
|
46
|
-
target_table = fk.column.table.name
|
|
47
|
-
relation = RelationSchema(type="many_to_one", target=target_table)
|
|
48
|
-
|
|
49
|
-
has_default = col.default is not None or col.server_default is not None
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
and
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flashapi.core.pluralize import pluralize
|
|
4
|
+
from flashapi.core.schema import FieldSchema, FieldType, ModelSchema, RelationSchema
|
|
5
|
+
from flashapi.inspectors.base import Inspector
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _map_sa_type(col_type) -> FieldType:
|
|
9
|
+
type_name = type(col_type).__name__.upper()
|
|
10
|
+
mapping = {
|
|
11
|
+
"VARCHAR": FieldType.STRING,
|
|
12
|
+
"STRING": FieldType.STRING,
|
|
13
|
+
"TEXT": FieldType.TEXT,
|
|
14
|
+
"INTEGER": FieldType.INTEGER,
|
|
15
|
+
"BIGINTEGER": FieldType.INTEGER,
|
|
16
|
+
"SMALLINTEGER": FieldType.INTEGER,
|
|
17
|
+
"FLOAT": FieldType.FLOAT,
|
|
18
|
+
"NUMERIC": FieldType.FLOAT,
|
|
19
|
+
"BOOLEAN": FieldType.BOOLEAN,
|
|
20
|
+
"DATE": FieldType.DATE,
|
|
21
|
+
"DATETIME": FieldType.DATETIME,
|
|
22
|
+
"TIME": FieldType.TIME,
|
|
23
|
+
"UUID": FieldType.UUID,
|
|
24
|
+
"JSON": FieldType.JSON,
|
|
25
|
+
"BLOB": FieldType.BINARY,
|
|
26
|
+
"LARGEBINARY": FieldType.BINARY,
|
|
27
|
+
}
|
|
28
|
+
return mapping.get(type_name, FieldType.STRING)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SQLAlchemyInspector(Inspector):
|
|
32
|
+
def inspect(self, model_class: type, plural: str | None = None) -> ModelSchema:
|
|
33
|
+
table = model_class.__table__
|
|
34
|
+
fields: list[FieldSchema] = []
|
|
35
|
+
|
|
36
|
+
for col in table.columns:
|
|
37
|
+
field_type = _map_sa_type(col.type)
|
|
38
|
+
constraints = {}
|
|
39
|
+
|
|
40
|
+
if hasattr(col.type, "length") and col.type.length:
|
|
41
|
+
constraints["max_length"] = col.type.length
|
|
42
|
+
|
|
43
|
+
relation = None
|
|
44
|
+
if col.foreign_keys:
|
|
45
|
+
fk = next(iter(col.foreign_keys))
|
|
46
|
+
target_table = fk.column.table.name
|
|
47
|
+
relation = RelationSchema(type="many_to_one", target=target_table)
|
|
48
|
+
|
|
49
|
+
has_default = col.default is not None or col.server_default is not None
|
|
50
|
+
has_callable_default = col.default is not None and callable(getattr(col.default, "arg", None))
|
|
51
|
+
is_auto_int = (
|
|
52
|
+
col.primary_key
|
|
53
|
+
and col.autoincrement is not False
|
|
54
|
+
and field_type == FieldType.INTEGER
|
|
55
|
+
)
|
|
56
|
+
auto_generated = is_auto_int or (col.primary_key and has_default) or has_callable_default
|
|
57
|
+
|
|
58
|
+
default_value = None
|
|
59
|
+
if col.default and not callable(getattr(col.default, "arg", None)):
|
|
60
|
+
default_value = col.default.arg
|
|
61
|
+
|
|
62
|
+
visibility = {}
|
|
63
|
+
info = getattr(col, "info", {}) or {}
|
|
64
|
+
for key in ("readonly", "writeonly", "hidden", "export_exclude"):
|
|
65
|
+
if info.get(key):
|
|
66
|
+
visibility[key] = True
|
|
67
|
+
|
|
68
|
+
fields.append(FieldSchema(
|
|
69
|
+
name=col.name,
|
|
70
|
+
type=field_type,
|
|
71
|
+
required=not col.nullable and not col.primary_key and not has_default,
|
|
72
|
+
default=default_value,
|
|
73
|
+
constraints=constraints,
|
|
74
|
+
primary_key=col.primary_key,
|
|
75
|
+
auto_generated=auto_generated,
|
|
76
|
+
relation=relation,
|
|
77
|
+
**visibility,
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
model_name = model_class.__name__
|
|
81
|
+
plural_name = plural or pluralize(model_name)
|
|
82
|
+
|
|
83
|
+
return ModelSchema(name=model_name, plural=plural_name, fields=fields)
|
flashapi/storage/__init__.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from flashapi.storage.
|
|
2
|
-
from flashapi.storage.
|
|
3
|
-
|
|
4
|
-
__all__ = ["
|
|
1
|
+
from flashapi.storage.auto import AutoStorage
|
|
2
|
+
from flashapi.storage.base import Storage
|
|
3
|
+
|
|
4
|
+
__all__ = ["AutoStorage", "Storage"]
|
flashapi/storage/auto.py
CHANGED
|
@@ -1,106 +1,189 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import sqlite3
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
from flashapi.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
FieldType.
|
|
12
|
-
FieldType.
|
|
13
|
-
FieldType.
|
|
14
|
-
FieldType.
|
|
15
|
-
FieldType.
|
|
16
|
-
FieldType.
|
|
17
|
-
FieldType.
|
|
18
|
-
FieldType.
|
|
19
|
-
FieldType.
|
|
20
|
-
FieldType.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
self.
|
|
39
|
-
self._conn.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
columns.append(f"{col_name}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
)
|
|
67
|
-
self._conn.commit()
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
self._conn.commit()
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from flashapi.core.schema import FieldType, ModelSchema
|
|
8
|
+
from flashapi.storage.base import Storage
|
|
9
|
+
|
|
10
|
+
FIELD_TYPE_TO_SQL = {
|
|
11
|
+
FieldType.STRING: "TEXT",
|
|
12
|
+
FieldType.INTEGER: "INTEGER",
|
|
13
|
+
FieldType.FLOAT: "REAL",
|
|
14
|
+
FieldType.BOOLEAN: "INTEGER",
|
|
15
|
+
FieldType.DATE: "TEXT",
|
|
16
|
+
FieldType.DATETIME: "TEXT",
|
|
17
|
+
FieldType.TIME: "TEXT",
|
|
18
|
+
FieldType.UUID: "TEXT",
|
|
19
|
+
FieldType.JSON: "TEXT",
|
|
20
|
+
FieldType.TEXT: "TEXT",
|
|
21
|
+
FieldType.BINARY: "BLOB",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_identifier(name: str) -> str:
|
|
26
|
+
"""Validate and quote a SQL identifier to prevent injection."""
|
|
27
|
+
import re
|
|
28
|
+
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
|
|
29
|
+
msg = f"Invalid SQL identifier: {name!r}"
|
|
30
|
+
raise ValueError(msg)
|
|
31
|
+
return f'"{name}"'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AutoStorage(Storage):
|
|
35
|
+
"""SQLite-backed automatic storage with soft-delete support."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, database: str = "flashapi.db") -> None:
|
|
38
|
+
self._db_path = database
|
|
39
|
+
self._conn = sqlite3.connect(database, check_same_thread=False)
|
|
40
|
+
self._conn.row_factory = sqlite3.Row
|
|
41
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
42
|
+
self._soft_delete_tables: set[str] = set()
|
|
43
|
+
self._auto_fields: dict[str, list[tuple[str, str]]] = {} # table -> [(field_name, auto_type)]
|
|
44
|
+
|
|
45
|
+
def ensure_table(self, schema: ModelSchema, *, soft_delete: bool = True) -> None:
|
|
46
|
+
table = _validate_identifier(schema.plural)
|
|
47
|
+
columns = []
|
|
48
|
+
for field in schema.fields:
|
|
49
|
+
col_name = _validate_identifier(field.name)
|
|
50
|
+
if field.primary_key:
|
|
51
|
+
columns.append(f"{col_name} INTEGER PRIMARY KEY AUTOINCREMENT")
|
|
52
|
+
else:
|
|
53
|
+
sql_type = FIELD_TYPE_TO_SQL.get(field.type, "TEXT")
|
|
54
|
+
not_null = " NOT NULL" if field.required else ""
|
|
55
|
+
columns.append(f"{col_name} {sql_type}{not_null}")
|
|
56
|
+
|
|
57
|
+
if soft_delete:
|
|
58
|
+
columns.append('"deleted_at" TEXT')
|
|
59
|
+
self._soft_delete_tables.add(schema.plural)
|
|
60
|
+
|
|
61
|
+
auto_fields = [(f.name, f.auto) for f in schema.fields if f.auto]
|
|
62
|
+
if auto_fields:
|
|
63
|
+
self._auto_fields[schema.plural] = auto_fields
|
|
64
|
+
|
|
65
|
+
sql = f"CREATE TABLE IF NOT EXISTS {table} ({', '.join(columns)})"
|
|
66
|
+
self._conn.execute(sql)
|
|
67
|
+
self._conn.commit()
|
|
68
|
+
|
|
69
|
+
def _generate_auto_value(self, auto_type: str) -> Any:
|
|
70
|
+
import uuid
|
|
71
|
+
if auto_type == "uuid":
|
|
72
|
+
return str(uuid.uuid4())
|
|
73
|
+
if auto_type == "datetime":
|
|
74
|
+
return datetime.now(timezone.utc).isoformat()
|
|
75
|
+
if auto_type == "date":
|
|
76
|
+
return datetime.now(timezone.utc).date().isoformat()
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def create(self, table: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
data = dict(data)
|
|
81
|
+
for field_name, auto_type in self._auto_fields.get(table, []):
|
|
82
|
+
if field_name not in data:
|
|
83
|
+
data[field_name] = self._generate_auto_value(auto_type)
|
|
84
|
+
|
|
85
|
+
safe_table = _validate_identifier(table)
|
|
86
|
+
columns = [_validate_identifier(k) for k in data]
|
|
87
|
+
placeholders = ", ".join(["?"] * len(columns))
|
|
88
|
+
col_names = ", ".join(columns)
|
|
89
|
+
values = list(data.values())
|
|
90
|
+
|
|
91
|
+
cursor = self._conn.execute(
|
|
92
|
+
f"INSERT INTO {safe_table} ({col_names}) VALUES ({placeholders})", values,
|
|
93
|
+
)
|
|
94
|
+
self._conn.commit()
|
|
95
|
+
|
|
96
|
+
item_id = cursor.lastrowid
|
|
97
|
+
return self._get_raw(table, item_id)
|
|
98
|
+
|
|
99
|
+
def get(self, table: str, item_id: int | str, *, lookup_field: str = "id") -> dict[str, Any] | None:
|
|
100
|
+
row = self._get_raw(table, item_id, lookup_field=lookup_field)
|
|
101
|
+
if row is None:
|
|
102
|
+
return None
|
|
103
|
+
if table in self._soft_delete_tables and row.get("deleted_at"):
|
|
104
|
+
return None
|
|
105
|
+
return self._strip_internal(row, table)
|
|
106
|
+
|
|
107
|
+
def _get_raw(self, table: str, item_id: int | str, *, lookup_field: str = "id") -> dict[str, Any] | None:
|
|
108
|
+
safe_table = _validate_identifier(table)
|
|
109
|
+
safe_field = _validate_identifier(lookup_field)
|
|
110
|
+
cursor = self._conn.execute(f"SELECT * FROM {safe_table} WHERE {safe_field} = ?", (item_id,))
|
|
111
|
+
row = cursor.fetchone()
|
|
112
|
+
if row is None:
|
|
113
|
+
return None
|
|
114
|
+
return dict(row)
|
|
115
|
+
|
|
116
|
+
def list_all(self, table: str, *, include_deleted: bool = False, only_deleted: bool = False) -> list[dict[str, Any]]:
|
|
117
|
+
safe_table = _validate_identifier(table)
|
|
118
|
+
if table in self._soft_delete_tables:
|
|
119
|
+
if only_deleted:
|
|
120
|
+
cursor = self._conn.execute(
|
|
121
|
+
f"SELECT * FROM {safe_table} WHERE deleted_at IS NOT NULL",
|
|
122
|
+
)
|
|
123
|
+
elif not include_deleted:
|
|
124
|
+
cursor = self._conn.execute(
|
|
125
|
+
f"SELECT * FROM {safe_table} WHERE deleted_at IS NULL",
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
cursor = self._conn.execute(f"SELECT * FROM {safe_table}")
|
|
129
|
+
else:
|
|
130
|
+
cursor = self._conn.execute(f"SELECT * FROM {safe_table}")
|
|
131
|
+
return [self._strip_internal(dict(row), table) for row in cursor.fetchall()]
|
|
132
|
+
|
|
133
|
+
def update(self, table: str, item_id: int | str, data: dict[str, Any], *, lookup_field: str = "id") -> dict[str, Any] | None:
|
|
134
|
+
if not self.get(table, item_id, lookup_field=lookup_field):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
safe_table = _validate_identifier(table)
|
|
138
|
+
safe_field = _validate_identifier(lookup_field)
|
|
139
|
+
set_clause = ", ".join([f"{_validate_identifier(k)} = ?" for k in data])
|
|
140
|
+
values = [*list(data.values()), item_id]
|
|
141
|
+
|
|
142
|
+
self._conn.execute(f"UPDATE {safe_table} SET {set_clause} WHERE {safe_field} = ?", values)
|
|
143
|
+
self._conn.commit()
|
|
144
|
+
return self.get(table, item_id, lookup_field=lookup_field)
|
|
145
|
+
|
|
146
|
+
def delete(self, table: str, item_id: int | str, *, soft: bool = True, lookup_field: str = "id") -> bool:
|
|
147
|
+
raw = self._get_raw(table, item_id, lookup_field=lookup_field)
|
|
148
|
+
if raw is None:
|
|
149
|
+
return False
|
|
150
|
+
if table in self._soft_delete_tables and raw.get("deleted_at"):
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
safe_table = _validate_identifier(table)
|
|
154
|
+
safe_field = _validate_identifier(lookup_field)
|
|
155
|
+
if soft and table in self._soft_delete_tables:
|
|
156
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
157
|
+
self._conn.execute(
|
|
158
|
+
f"UPDATE {safe_table} SET deleted_at = ? WHERE {safe_field} = ?", (now, item_id),
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
self._conn.execute(f"DELETE FROM {safe_table} WHERE {safe_field} = ?", (item_id,))
|
|
162
|
+
self._conn.commit()
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def restore(self, table: str, item_id: int | str, *, lookup_field: str = "id") -> bool:
|
|
166
|
+
if table not in self._soft_delete_tables:
|
|
167
|
+
return False
|
|
168
|
+
raw = self._get_raw(table, item_id, lookup_field=lookup_field)
|
|
169
|
+
if raw is None or not raw.get("deleted_at"):
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
safe_table = _validate_identifier(table)
|
|
173
|
+
safe_field = _validate_identifier(lookup_field)
|
|
174
|
+
self._conn.execute(
|
|
175
|
+
f"UPDATE {safe_table} SET deleted_at = NULL WHERE {safe_field} = ?", (item_id,),
|
|
176
|
+
)
|
|
177
|
+
self._conn.commit()
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
def bulk_create(self, table: str, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
181
|
+
return [self.create(table, item) for item in items]
|
|
182
|
+
|
|
183
|
+
def _strip_internal(self, row: dict, table: str) -> dict:
|
|
184
|
+
if table in self._soft_delete_tables:
|
|
185
|
+
row.pop("deleted_at", None)
|
|
186
|
+
return row
|
|
187
|
+
|
|
188
|
+
def close(self) -> None:
|
|
189
|
+
self._conn.close()
|