dataclassdb 0.1.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.
- dataclassdb/__init__.py +19 -0
- dataclassdb/builders/function_builder.py +407 -0
- dataclassdb/builders/list_utils.py +48 -0
- dataclassdb/builders/query_builder.py +158 -0
- dataclassdb/builders/statement_builder.py +635 -0
- dataclassdb/builders/string_builder.py +98 -0
- dataclassdb/constants.py +372 -0
- dataclassdb/dataclass_db.py +282 -0
- dataclassdb/dataclass_field_codec.py +216 -0
- dataclassdb/dataclass_sqlite_field.py +193 -0
- dataclassdb/dataclass_sqlite_table.py +152 -0
- dataclassdb/dataclass_types.py +22 -0
- dataclassdb/db_engine.py +126 -0
- dataclassdb/utils.py +74 -0
- dataclassdb-0.1.0.dist-info/METADATA +237 -0
- dataclassdb-0.1.0.dist-info/RECORD +19 -0
- dataclassdb-0.1.0.dist-info/WHEEL +5 -0
- dataclassdb-0.1.0.dist-info/licenses/LICENSE +21 -0
- dataclassdb-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import pickle
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, TypeVar, Union, get_args, get_origin
|
|
7
|
+
|
|
8
|
+
import dacite
|
|
9
|
+
|
|
10
|
+
from dataclassdb.dataclass_types import Codec, IsDataclass
|
|
11
|
+
from dataclassdb.utils import is_enum_class, is_strenum
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
DataclassT = TypeVar("DataclassT", bound=IsDataclass)
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
def create_field_codec(field_class: T, sql_type: str) -> Codec:
|
|
18
|
+
if origin := get_origin(field_class):
|
|
19
|
+
if origin is Union:
|
|
20
|
+
field_class = get_args(field_class)[0]
|
|
21
|
+
else:
|
|
22
|
+
field_class = origin
|
|
23
|
+
|
|
24
|
+
if sql_type == "BLOB":
|
|
25
|
+
if dataclasses.is_dataclass(field_class):
|
|
26
|
+
return DataclassPickleCodec(field_class)
|
|
27
|
+
else:
|
|
28
|
+
return pickle_codec
|
|
29
|
+
elif dataclasses.is_dataclass(field_class):
|
|
30
|
+
return DataclassJsonCodec(field_class)
|
|
31
|
+
elif is_strenum(field_class):
|
|
32
|
+
if sql_type != "TEXT":
|
|
33
|
+
raise ValueError("The SQL type for StrEnum must be TEXT or BLOB")
|
|
34
|
+
return StrEnumCodec(field_class=field_class, sql_type=sql_type)
|
|
35
|
+
elif is_enum_class(field_class):
|
|
36
|
+
if sql_type != "INTEGER":
|
|
37
|
+
raise ValueError("The SQL type for Enums must be INTEGER or BLOB")
|
|
38
|
+
return EnumCodec(field_class=field_class, sql_type=sql_type)
|
|
39
|
+
elif issubclass(field_class, datetime):
|
|
40
|
+
if sql_type == "INTEGER":
|
|
41
|
+
return datetime_int_codec
|
|
42
|
+
else:
|
|
43
|
+
return datetime_text_codec
|
|
44
|
+
|
|
45
|
+
elif sql_type == "TEXT":
|
|
46
|
+
if field_class is str:
|
|
47
|
+
return identity_codec
|
|
48
|
+
else:
|
|
49
|
+
return json_codec
|
|
50
|
+
elif field_class is bool:
|
|
51
|
+
if sql_type != "INTEGER":
|
|
52
|
+
raise ValueError("bool fields must be BLOB, TEXT, or INTEGER")
|
|
53
|
+
return bool_codec
|
|
54
|
+
return identity_codec
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DataclassJsonCodec(Codec):
|
|
58
|
+
def __init__(self, field_class, *args, **kwargs):
|
|
59
|
+
self.field_class = field_class
|
|
60
|
+
|
|
61
|
+
def encode(self, data: any) -> str:
|
|
62
|
+
if data is None:
|
|
63
|
+
return ""
|
|
64
|
+
data_dict = None
|
|
65
|
+
if isinstance(data, dict):
|
|
66
|
+
data_dict = data
|
|
67
|
+
else:
|
|
68
|
+
data_dict = dataclasses.asdict(data)
|
|
69
|
+
return json.dumps(data_dict)
|
|
70
|
+
|
|
71
|
+
def decode(self, data: str | dict):
|
|
72
|
+
if data is None or data == "":
|
|
73
|
+
return None
|
|
74
|
+
if isinstance(data, dict):
|
|
75
|
+
data_dict = data
|
|
76
|
+
else:
|
|
77
|
+
data_dict = json.loads(data)
|
|
78
|
+
return dacite.from_dict(self.field_class, data_dict)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DataclassPickleCodec(Codec):
|
|
82
|
+
def __init__(self, field_class, *args, **kwargs):
|
|
83
|
+
self.field_class = field_class
|
|
84
|
+
|
|
85
|
+
def encode(self, data):
|
|
86
|
+
if data is None:
|
|
87
|
+
return None
|
|
88
|
+
data_dict = None
|
|
89
|
+
if isinstance(data, dict):
|
|
90
|
+
data_dict = data
|
|
91
|
+
else:
|
|
92
|
+
data_dict = dataclasses.asdict(data)
|
|
93
|
+
return pickle.dumps(data_dict)
|
|
94
|
+
|
|
95
|
+
def decode(self, data):
|
|
96
|
+
if data is None:
|
|
97
|
+
return None
|
|
98
|
+
data_dict = None
|
|
99
|
+
if isinstance(data, dict):
|
|
100
|
+
data_dict = data
|
|
101
|
+
else:
|
|
102
|
+
data_dict = pickle.loads(data)
|
|
103
|
+
return dacite.from_dict(self.field_class, data_dict)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class StrEnumCodec(Codec):
|
|
107
|
+
def __init__(self, field_class: T, *args, **kwargs):
|
|
108
|
+
self.field_class = field_class
|
|
109
|
+
|
|
110
|
+
def encode(self, data: T) -> str | int:
|
|
111
|
+
if data is None:
|
|
112
|
+
return None
|
|
113
|
+
return data.value
|
|
114
|
+
|
|
115
|
+
def decode(self, data: str | int) -> T:
|
|
116
|
+
if data is None:
|
|
117
|
+
return None
|
|
118
|
+
return self.field_class(data)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class EnumCodec(Codec):
|
|
122
|
+
def __init__(self, field_class: T, *args, **kwargs):
|
|
123
|
+
self.field_class = field_class
|
|
124
|
+
|
|
125
|
+
def encode(self, data: T) -> str | int:
|
|
126
|
+
if data is None:
|
|
127
|
+
return None
|
|
128
|
+
return data.value
|
|
129
|
+
|
|
130
|
+
def decode(self, data: str | int) -> T:
|
|
131
|
+
if data is None:
|
|
132
|
+
return None
|
|
133
|
+
return self.field_class(data)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class JsonCodec(Codec):
|
|
137
|
+
def encode(self, data: any) -> str:
|
|
138
|
+
if data is None:
|
|
139
|
+
return None
|
|
140
|
+
if dataclasses.is_dataclass(data):
|
|
141
|
+
return json.dumps(dataclasses.asdict(data))
|
|
142
|
+
return json.dumps(data)
|
|
143
|
+
|
|
144
|
+
def decode(self, data: str) -> any:
|
|
145
|
+
if data is None:
|
|
146
|
+
return None
|
|
147
|
+
return json.loads(data)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PickleCodec(Codec):
|
|
151
|
+
def encode(self, data: Any) -> bytes:
|
|
152
|
+
if data is None:
|
|
153
|
+
return None
|
|
154
|
+
return pickle.dumps(data)
|
|
155
|
+
|
|
156
|
+
def decode(self, data: bytes) -> Any:
|
|
157
|
+
if data is None:
|
|
158
|
+
return None
|
|
159
|
+
return pickle.loads(data)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class IdentityCodec(Codec):
|
|
163
|
+
def encode(self, data: T) -> T:
|
|
164
|
+
return data
|
|
165
|
+
|
|
166
|
+
def decode(self, data: T) -> T:
|
|
167
|
+
return data
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class BoolCodec(Codec):
|
|
171
|
+
def encode(self, data: bool) -> int:
|
|
172
|
+
if data is None:
|
|
173
|
+
return None
|
|
174
|
+
return 1 if data is True else 0
|
|
175
|
+
|
|
176
|
+
def decode(self, data: int) -> bool:
|
|
177
|
+
if data is None:
|
|
178
|
+
return None
|
|
179
|
+
return data >= 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class DatetimeIntCodec(Codec):
|
|
183
|
+
"""Converts between python datetime(utc) to sqlite3 INTEGER"""
|
|
184
|
+
|
|
185
|
+
def encode(self, data: datetime) -> int:
|
|
186
|
+
if data is None:
|
|
187
|
+
return None
|
|
188
|
+
return int(data.timestamp())
|
|
189
|
+
|
|
190
|
+
def decode(self, data: int) -> datetime:
|
|
191
|
+
if data is None:
|
|
192
|
+
return None
|
|
193
|
+
return datetime.fromtimestamp(timestamp=data, tz=timezone.utc)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class DatetimeTextCodec(Codec):
|
|
197
|
+
"""Converts between python datetime to sqlite3 TEXT(ISO 8601)"""
|
|
198
|
+
|
|
199
|
+
def encode(self, data: datetime) -> int:
|
|
200
|
+
if data is None:
|
|
201
|
+
return None
|
|
202
|
+
return data.isoformat()
|
|
203
|
+
|
|
204
|
+
def decode(self, data: int) -> datetime:
|
|
205
|
+
if data is None:
|
|
206
|
+
return None
|
|
207
|
+
return datetime.fromisoformat(data)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Reusable codecs:
|
|
211
|
+
bool_codec = BoolCodec()
|
|
212
|
+
identity_codec = IdentityCodec()
|
|
213
|
+
json_codec = JsonCodec()
|
|
214
|
+
pickle_codec = PickleCodec()
|
|
215
|
+
datetime_int_codec = DatetimeIntCodec()
|
|
216
|
+
datetime_text_codec = DatetimeTextCodec()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import MISSING, Field, is_dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum, StrEnum
|
|
6
|
+
from types import NoneType, UnionType
|
|
7
|
+
from typing import Annotated, Union, get_args, get_origin
|
|
8
|
+
|
|
9
|
+
from dataclassdb.builders.query_builder import QueryBuilder
|
|
10
|
+
from dataclassdb.dataclass_field_codec import create_field_codec
|
|
11
|
+
from dataclassdb.dataclass_types import Codec
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DataclassSqliteField:
|
|
17
|
+
"""This class reads annotations and determines the provided type, sqlite constraints, and sqlite type
|
|
18
|
+
It also provides an encoder/decoder to and from sqlite.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
_type_map = {
|
|
22
|
+
int: "INTEGER",
|
|
23
|
+
StrEnum: "TEXT",
|
|
24
|
+
Enum: "INTEGER",
|
|
25
|
+
str: "TEXT",
|
|
26
|
+
float: "REAL",
|
|
27
|
+
bool: "INTEGER",
|
|
28
|
+
list: "TEXT",
|
|
29
|
+
dict: "TEXT",
|
|
30
|
+
datetime: "TEXT",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def unique(self) -> bool:
|
|
35
|
+
return "UNIQUE" in self.constraints
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def notnull(self) -> bool:
|
|
39
|
+
return "NOT" in self.constraints
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def primary_key(self) -> bool:
|
|
43
|
+
return "PRIMARY" in self.constraints
|
|
44
|
+
|
|
45
|
+
def encode(self, value):
|
|
46
|
+
return self.codec.encode(value)
|
|
47
|
+
|
|
48
|
+
def decode(self, value):
|
|
49
|
+
return self.codec.decode(value)
|
|
50
|
+
|
|
51
|
+
def __init__(self, dc_type: type, dc_field: Field, default_sql_type="TEXT"):
|
|
52
|
+
self.name = dc_field.name
|
|
53
|
+
self.type = None
|
|
54
|
+
self.sql_type = default_sql_type
|
|
55
|
+
self.sql_create_type = ""
|
|
56
|
+
self.constraints = {}
|
|
57
|
+
self.codec: Codec = None
|
|
58
|
+
self.type_override = False
|
|
59
|
+
|
|
60
|
+
# Handle field outer wrapper
|
|
61
|
+
if is_dataclass(dc_type):
|
|
62
|
+
field_type = dc_type
|
|
63
|
+
if get_origin(dc_type) is Annotated:
|
|
64
|
+
field_type = self.parse_annotated(dc_type)
|
|
65
|
+
else:
|
|
66
|
+
field_type = dc_type
|
|
67
|
+
|
|
68
|
+
self.parse_type(field_type)
|
|
69
|
+
if self.codec is None:
|
|
70
|
+
self.codec = create_field_codec(field_type, self.sql_type)
|
|
71
|
+
|
|
72
|
+
self.parse_default(dc_field)
|
|
73
|
+
|
|
74
|
+
def parse_annotated(self, dataclass_type: type):
|
|
75
|
+
args = get_args(dataclass_type)
|
|
76
|
+
field_type = args[0]
|
|
77
|
+
field_args = args[1:]
|
|
78
|
+
for i, arg in enumerate(field_args):
|
|
79
|
+
if isinstance(arg, Codec):
|
|
80
|
+
logger.info("Overloading codec for field %s", self.name)
|
|
81
|
+
self.codec = arg
|
|
82
|
+
continue
|
|
83
|
+
elif not isinstance(arg, str):
|
|
84
|
+
logger.warning(
|
|
85
|
+
"Skipping constraint field %i constraint field for %s",
|
|
86
|
+
i,
|
|
87
|
+
self.name,
|
|
88
|
+
)
|
|
89
|
+
continue
|
|
90
|
+
split_arg = arg.split(" ")
|
|
91
|
+
key = split_arg[0]
|
|
92
|
+
if key == "CONSTRAINT":
|
|
93
|
+
key = split_arg[1]
|
|
94
|
+
arg = " ".join(split_arg[1:])
|
|
95
|
+
|
|
96
|
+
if i == 0:
|
|
97
|
+
no_par_arg = arg.split("(")[0]
|
|
98
|
+
if no_par_arg in QueryBuilder._affinity_map:
|
|
99
|
+
affinity = QueryBuilder.get_sql_col_affinity(arg)
|
|
100
|
+
self.type_override = True
|
|
101
|
+
self.affinity = affinity
|
|
102
|
+
self.sql_type = affinity
|
|
103
|
+
self.sql_create_type = arg
|
|
104
|
+
continue
|
|
105
|
+
if key in [
|
|
106
|
+
"PRIMARY",
|
|
107
|
+
"NOT",
|
|
108
|
+
"UNIQUE",
|
|
109
|
+
"CHECK",
|
|
110
|
+
"DEFAULT",
|
|
111
|
+
"COLLATE",
|
|
112
|
+
"REFERENCES",
|
|
113
|
+
"GENERATED",
|
|
114
|
+
"AS",
|
|
115
|
+
"ON",
|
|
116
|
+
]:
|
|
117
|
+
self.constraints[key] = arg
|
|
118
|
+
else:
|
|
119
|
+
logger.warning("Skipping annotation field %s", key)
|
|
120
|
+
return field_type
|
|
121
|
+
|
|
122
|
+
def parse_type(self, field_type: type):
|
|
123
|
+
# Handle field inner wrapper (e.g. int | None)
|
|
124
|
+
types = (
|
|
125
|
+
get_args(field_type)
|
|
126
|
+
if isinstance(field_type, UnionType)
|
|
127
|
+
else [
|
|
128
|
+
field_type,
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
if "NOT" not in self.constraints and NoneType not in types:
|
|
132
|
+
self.constraints["NOT"] = "NOT NULL"
|
|
133
|
+
self.type = types[0]
|
|
134
|
+
|
|
135
|
+
if self.type_override:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if origin := get_origin(field_type):
|
|
139
|
+
if origin is Union:
|
|
140
|
+
field_type = get_args(field_type)[0]
|
|
141
|
+
self.origin = field_type
|
|
142
|
+
else:
|
|
143
|
+
field_type = origin
|
|
144
|
+
self.origin = field_type
|
|
145
|
+
|
|
146
|
+
if inspect.isclass(self.type) and issubclass(field_type, set):
|
|
147
|
+
raise TypeError(
|
|
148
|
+
"Sets cannot be encoded as text. Either override the type to BLOB, "
|
|
149
|
+
"override the codec, or change the datatype."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
for mapped_type in self._type_map:
|
|
153
|
+
if issubclass(field_type, mapped_type):
|
|
154
|
+
# if isinstance(field_type, mapped_type):
|
|
155
|
+
# if issubclass(field_type, mapped_type):
|
|
156
|
+
self.sql_type = self._type_map[mapped_type]
|
|
157
|
+
break
|
|
158
|
+
# if inspect.isclass(field_type):
|
|
159
|
+
# else:
|
|
160
|
+
# if isinstance(field_type, mapped_type):
|
|
161
|
+
# self.sql_type = self._type_map[mapped_type]
|
|
162
|
+
# break
|
|
163
|
+
|
|
164
|
+
def parse_default(self, dc_field):
|
|
165
|
+
if dc_field.default_factory is not MISSING:
|
|
166
|
+
default = dc_field.default_factory()
|
|
167
|
+
else:
|
|
168
|
+
default = dc_field.default
|
|
169
|
+
|
|
170
|
+
if "DEFAULT" in self.constraints or default is None or default is MISSING:
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
encoded = self.codec.encode(default)
|
|
174
|
+
if self.sql_type == "TEXT":
|
|
175
|
+
self.constraints["DEFAULT"] = f"DEFAULT '{encoded}'"
|
|
176
|
+
elif self.sql_type == "BLOB":
|
|
177
|
+
self.constraints["DEFAULT"] = f"DEFAULT X'{encoded.hex()}'"
|
|
178
|
+
else:
|
|
179
|
+
self.constraints["DEFAULT"] = f"DEFAULT {encoded}"
|
|
180
|
+
|
|
181
|
+
def sql_col_def(self, skip_primary=False, skip_unique=False):
|
|
182
|
+
output = [
|
|
183
|
+
self.name,
|
|
184
|
+
self.sql_create_type if self.sql_create_type else self.sql_type,
|
|
185
|
+
]
|
|
186
|
+
for key, val in self.constraints.items():
|
|
187
|
+
if key == "PRIMARY" and skip_primary:
|
|
188
|
+
pass
|
|
189
|
+
elif key == "UNIQUE" and skip_unique:
|
|
190
|
+
pass
|
|
191
|
+
else:
|
|
192
|
+
output.append(val)
|
|
193
|
+
return " ".join(output)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
__all__ = [
|
|
2
|
+
"DataclassTableCodec",
|
|
3
|
+
"decode_dict",
|
|
4
|
+
"get_class_codec",
|
|
5
|
+
"get_field_codec",
|
|
6
|
+
"encode_field",
|
|
7
|
+
"decode_field",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
13
|
+
from typing import Any, Literal, TypeVar, get_type_hints, overload
|
|
14
|
+
|
|
15
|
+
from dacite import from_dict
|
|
16
|
+
|
|
17
|
+
from dataclassdb.dataclass_field_codec import identity_codec
|
|
18
|
+
from dataclassdb.dataclass_sqlite_field import DataclassSqliteField
|
|
19
|
+
from dataclassdb.dataclass_types import IsDataclass
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decode_dict(
|
|
25
|
+
data: dict[str, Any],
|
|
26
|
+
data_class=None,
|
|
27
|
+
prefix_class_map: dict = None,
|
|
28
|
+
remove_prefix=False,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
if data is None:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
if data_class is None and prefix_class_map is None:
|
|
34
|
+
raise ValueError("Either a data class or prefix map must be provided")
|
|
35
|
+
|
|
36
|
+
if isinstance(data, list):
|
|
37
|
+
output = [
|
|
38
|
+
decode_dict(row, data_class, prefix_class_map, remove_prefix)
|
|
39
|
+
for row in data
|
|
40
|
+
]
|
|
41
|
+
return output
|
|
42
|
+
|
|
43
|
+
output = {}
|
|
44
|
+
for key, val in data.items():
|
|
45
|
+
if "." in key:
|
|
46
|
+
prefix, split_key = key.split(".")
|
|
47
|
+
if mapped_dataclass := prefix_class_map.get(prefix, None):
|
|
48
|
+
decoded = decode_field(mapped_dataclass, split_key, val)
|
|
49
|
+
else:
|
|
50
|
+
decoded = val
|
|
51
|
+
output_key = split_key if remove_prefix else key
|
|
52
|
+
output[output_key] = decoded
|
|
53
|
+
elif data_class:
|
|
54
|
+
field_codec = get_field_codec(data_class, key)
|
|
55
|
+
output[key] = field_codec.decode(val)
|
|
56
|
+
else:
|
|
57
|
+
output[key] = val
|
|
58
|
+
return output
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
T = TypeVar("T", bound=IsDataclass)
|
|
62
|
+
class DataclassTableCodec:
|
|
63
|
+
"""This class takes a dataclass and provides encoder and decoder (codec) from and to sqlite."""
|
|
64
|
+
|
|
65
|
+
def __contains__(self, item):
|
|
66
|
+
return item in self.class_fields
|
|
67
|
+
|
|
68
|
+
def __init__(self, data_class: T) -> None:
|
|
69
|
+
if not data_class or not is_dataclass(data_class):
|
|
70
|
+
raise ValueError(f"Provided class {data_class} is not a dataclass")
|
|
71
|
+
|
|
72
|
+
self.data_class: T = data_class
|
|
73
|
+
self.primary_keys = []
|
|
74
|
+
self.class_fields: dict[str, DataclassSqliteField] = {}
|
|
75
|
+
|
|
76
|
+
types_map: dict = get_type_hints(data_class, include_extras=True)
|
|
77
|
+
for _field in fields(data_class):
|
|
78
|
+
name = _field.name
|
|
79
|
+
field_type = types_map.get(name, None)
|
|
80
|
+
dc_field = DataclassSqliteField(field_type, _field)
|
|
81
|
+
self.class_fields[name] = dc_field
|
|
82
|
+
if dc_field.primary_key:
|
|
83
|
+
self.primary_keys.append(name)
|
|
84
|
+
self.key_match_str = " AND ".join([f"{key} = ?" for key in self.primary_keys])
|
|
85
|
+
|
|
86
|
+
@overload
|
|
87
|
+
def encode(self, obj: T, *cols, as_tuple: Literal[True]) -> tuple: ...
|
|
88
|
+
@overload
|
|
89
|
+
def encode(self, obj: T, *cols, as_tuple: Literal[False]) -> dict: ...
|
|
90
|
+
def encode(
|
|
91
|
+
self, obj: T, *cols, as_tuple: bool = True, ignore_none=True
|
|
92
|
+
) -> tuple | dict:
|
|
93
|
+
if obj is None:
|
|
94
|
+
return None
|
|
95
|
+
output = {}
|
|
96
|
+
obj_dict = asdict(obj)
|
|
97
|
+
if not cols:
|
|
98
|
+
cols = obj_dict.keys()
|
|
99
|
+
|
|
100
|
+
for col in cols:
|
|
101
|
+
v = obj_dict.get(col, None)
|
|
102
|
+
|
|
103
|
+
if col in self.class_fields:
|
|
104
|
+
out = self.class_fields[col].encode(v)
|
|
105
|
+
else:
|
|
106
|
+
out = v
|
|
107
|
+
if out is not None or not ignore_none:
|
|
108
|
+
output[col] = out
|
|
109
|
+
return tuple(output.values()) if as_tuple else output
|
|
110
|
+
|
|
111
|
+
@overload
|
|
112
|
+
def decode(self, row_dict, as_obj: Literal[True]) -> T: ...
|
|
113
|
+
@overload
|
|
114
|
+
def decode(self, row_dict, as_obj: Literal[False]) -> dict: ...
|
|
115
|
+
def decode(self, row_dict, as_obj: bool = False) -> T | dict:
|
|
116
|
+
if row_dict is None:
|
|
117
|
+
return None
|
|
118
|
+
output = {}
|
|
119
|
+
for k, v in row_dict.items():
|
|
120
|
+
if k in self.class_fields:
|
|
121
|
+
output[k] = self.class_fields[k].decode(v)
|
|
122
|
+
else:
|
|
123
|
+
output[k] = v
|
|
124
|
+
if as_obj:
|
|
125
|
+
return from_dict(self.data_class, output)
|
|
126
|
+
else:
|
|
127
|
+
return output
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@functools.lru_cache
|
|
131
|
+
def get_class_codec(data_class: IsDataclass) -> DataclassTableCodec:
|
|
132
|
+
if not data_class or not is_dataclass(data_class):
|
|
133
|
+
return None
|
|
134
|
+
else:
|
|
135
|
+
return DataclassTableCodec(data_class)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_field_codec(data_class, field_name) -> DataclassSqliteField:
|
|
139
|
+
class_codec = get_class_codec(data_class)
|
|
140
|
+
if not class_codec:
|
|
141
|
+
return identity_codec
|
|
142
|
+
return class_codec.class_fields.get(field_name, identity_codec)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def encode_field(data_class, field_name, data):
|
|
146
|
+
codec = get_field_codec(data_class, field_name)
|
|
147
|
+
return codec.encode(data)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def decode_field(data_class, field_name, data):
|
|
151
|
+
codec = get_field_codec(data_class, field_name)
|
|
152
|
+
return codec.decode(data)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import Field
|
|
2
|
+
from typing import Mapping, Protocol, TypeVar, runtime_checkable
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
S = TypeVar("S")
|
|
6
|
+
|
|
7
|
+
@runtime_checkable
|
|
8
|
+
class Codec(Protocol):
|
|
9
|
+
def __init__(self, *args, **kwargs): pass
|
|
10
|
+
@staticmethod
|
|
11
|
+
def encode(data: T) -> S:...
|
|
12
|
+
@staticmethod
|
|
13
|
+
def decode(data: S) -> T:...
|
|
14
|
+
|
|
15
|
+
class CustomCodec(Codec):
|
|
16
|
+
def __init__(self, encode, decode):
|
|
17
|
+
self.encode = encode
|
|
18
|
+
self.decode = decode
|
|
19
|
+
|
|
20
|
+
@runtime_checkable
|
|
21
|
+
class IsDataclass(Protocol):
|
|
22
|
+
__dataclass_fields__: Mapping[str, Field]
|
dataclassdb/db_engine.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
from dataclassdb.builders.string_builder import StringBuilder
|
|
5
|
+
from dataclassdb.utils import dict_factory
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DbEngine(StringBuilder):
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
connection: sqlite3.Connection | str = "", # type: ignore
|
|
14
|
+
**kwargs,
|
|
15
|
+
):
|
|
16
|
+
self.connection: sqlite3.Connection
|
|
17
|
+
self.connection_owner = False
|
|
18
|
+
self.has_connection = False
|
|
19
|
+
self.connection_name = ""
|
|
20
|
+
|
|
21
|
+
if connection:
|
|
22
|
+
self.has_connection = True
|
|
23
|
+
if isinstance(connection, sqlite3.Connection):
|
|
24
|
+
self.connection = connection
|
|
25
|
+
else:
|
|
26
|
+
self.connection = sqlite3.Connection(connection)
|
|
27
|
+
self.connection_owner = True
|
|
28
|
+
self.connection_name = connection
|
|
29
|
+
self.enable_wal()
|
|
30
|
+
logger.info("Connection for %s created.", self.connection_name)
|
|
31
|
+
|
|
32
|
+
super().__init__(connection=connection, **kwargs)
|
|
33
|
+
# super(DbExecutor, self).__init__()
|
|
34
|
+
|
|
35
|
+
def __enter__(self):
|
|
36
|
+
if not self.has_connection:
|
|
37
|
+
raise ValueError("Connection must be set to use context manager")
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
41
|
+
if self.connection_owner and self.has_connection:
|
|
42
|
+
logger.info("Committing DB.")
|
|
43
|
+
self.connection.commit()
|
|
44
|
+
logger.info("Closing DB.")
|
|
45
|
+
self.connection.close()
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def enable_wal(self) -> None:
|
|
49
|
+
self.execute_script(
|
|
50
|
+
"PRAGMA journal_mode=WAL;\n"
|
|
51
|
+
"PRAGMA synchronous=NORMAL;\n"
|
|
52
|
+
"PRAGMA busy_timeout = 5000;\n"
|
|
53
|
+
"PRAGMA cache_size = -20000;\n" # 20MB
|
|
54
|
+
"PRAGMA foreign_keys = true;\n"
|
|
55
|
+
"PRAGMA temp_store = memory;"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def execute_script(self, sql_script=None, as_dict=False, commit=False):
|
|
59
|
+
if not self.has_connection:
|
|
60
|
+
raise ValueError("Connection must be set to use sql commands.")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
if sql_script is None:
|
|
64
|
+
sql_script = str(self)
|
|
65
|
+
if not sql_script:
|
|
66
|
+
raise ValueError("Query is empty")
|
|
67
|
+
cur = self.connection.cursor() # type: ignore
|
|
68
|
+
if as_dict:
|
|
69
|
+
cur.row_factory = dict_factory
|
|
70
|
+
output = cur.executescript(sql_script)
|
|
71
|
+
if commit:
|
|
72
|
+
self.connection.commit() # type: ignore
|
|
73
|
+
return output
|
|
74
|
+
finally:
|
|
75
|
+
self.clear
|
|
76
|
+
|
|
77
|
+
def execute_one(self, *args, sql_str=None, commit=False, as_dict=False):
|
|
78
|
+
return self.execute(
|
|
79
|
+
*args, sql_str=sql_str, commit=commit, single_row=True, as_dict=as_dict
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def execute(
|
|
83
|
+
self, *args, sql_str=None, commit=False, single_row=False, as_dict=False
|
|
84
|
+
):
|
|
85
|
+
if not self.has_connection:
|
|
86
|
+
raise ValueError("Connection must be set to use sql commands.")
|
|
87
|
+
try:
|
|
88
|
+
params = tuple(args) if args else ()
|
|
89
|
+
if sql_str is None:
|
|
90
|
+
query_str = str(self)
|
|
91
|
+
else:
|
|
92
|
+
query_str = str(sql_str)
|
|
93
|
+
|
|
94
|
+
if not query_str:
|
|
95
|
+
raise ValueError("Query is empty")
|
|
96
|
+
|
|
97
|
+
if not query_str.endswith(";"):
|
|
98
|
+
query_str = f"{query_str};"
|
|
99
|
+
|
|
100
|
+
cur = self.connection.cursor()
|
|
101
|
+
if as_dict:
|
|
102
|
+
cur.row_factory = dict_factory
|
|
103
|
+
|
|
104
|
+
if "\n" in query_str:
|
|
105
|
+
logger.debug(
|
|
106
|
+
"Executing Query: \n %s", query_str.replace("\n", "\n ")
|
|
107
|
+
)
|
|
108
|
+
logger.debug(
|
|
109
|
+
"Using Params :\n %s", "\n ".join(map(str, params))
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
logger.debug("Executing Query: %s", query_str)
|
|
113
|
+
logger.debug("Using Params : %s", params)
|
|
114
|
+
|
|
115
|
+
cur.execute(query_str, params)
|
|
116
|
+
|
|
117
|
+
if single_row:
|
|
118
|
+
rows = cur.fetchone()
|
|
119
|
+
return rows
|
|
120
|
+
else:
|
|
121
|
+
rows = cur.fetchall()
|
|
122
|
+
if commit:
|
|
123
|
+
self.connection.commit()
|
|
124
|
+
return rows
|
|
125
|
+
finally:
|
|
126
|
+
self.clear
|