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.
@@ -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]
@@ -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