tinymongo 1.0.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.
- tinymongo/__init__.py +4 -0
- tinymongo/errors.py +61 -0
- tinymongo/parquet_storage.py +256 -0
- tinymongo/results.py +115 -0
- tinymongo/serializers.py +22 -0
- tinymongo/storage_backends.py +174 -0
- tinymongo/tinymongo.py +1007 -0
- tinymongo-1.0.0.dist-info/METADATA +234 -0
- tinymongo-1.0.0.dist-info/RECORD +12 -0
- tinymongo-1.0.0.dist-info/WHEEL +5 -0
- tinymongo-1.0.0.dist-info/licenses/LICENSE.txt +10 -0
- tinymongo-1.0.0.dist-info/top_level.txt +1 -0
tinymongo/__init__.py
ADDED
tinymongo/errors.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Exceptions raised by TinyMongo."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TinyMongoError(Exception):
|
|
5
|
+
"""Base class for all TinyMongo exceptions."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConnectionFailure(TinyMongoError):
|
|
9
|
+
"""Raised when a connection to the database file cannot be made or is lost.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigurationError(TinyMongoError):
|
|
14
|
+
"""Raised when something is incorrectly configured.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OperationFailure(TinyMongoError):
|
|
19
|
+
"""Raised when a database operation fails.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, error, code=None, details=None):
|
|
23
|
+
self.__code = code
|
|
24
|
+
self.__details = details
|
|
25
|
+
TinyMongoError.__init__(self, error)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def code(self):
|
|
29
|
+
"""The error code returned by the server, if any.
|
|
30
|
+
"""
|
|
31
|
+
return self.__code
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def details(self):
|
|
35
|
+
"""The complete error document returned by the server.
|
|
36
|
+
|
|
37
|
+
Depending on the error that occurred, the error document
|
|
38
|
+
may include useful information beyond just the error
|
|
39
|
+
message. When connected to a mongos the error document
|
|
40
|
+
may contain one or more subdocuments if errors occurred
|
|
41
|
+
on multiple shards.
|
|
42
|
+
"""
|
|
43
|
+
return self.__details
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CursorNotFound(OperationFailure):
|
|
47
|
+
"""Raised while iterating query results if the cursor is
|
|
48
|
+
invalidated on the server.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WriteError(OperationFailure):
|
|
53
|
+
"""Base exception type for errors raised during write operations."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DuplicateKeyError(WriteError):
|
|
57
|
+
"""Raised when an insert or update fails due to a duplicate key error."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InvalidOperation(TinyMongoError):
|
|
61
|
+
"""Raised when a client attempts to perform an invalid operation."""
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from tinydb.storages import Storage
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import pyarrow as pa
|
|
9
|
+
import pyarrow.parquet as pq
|
|
10
|
+
except Exception: # pragma: no cover - graceful fallback
|
|
11
|
+
pa = None
|
|
12
|
+
pq = None
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import portalocker
|
|
16
|
+
except Exception: # pragma: no cover
|
|
17
|
+
portalocker = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# In-process reentrant locks per lock path to avoid nested portalocker
|
|
21
|
+
# acquisitions causing AlreadyLocked errors when the same process/thread
|
|
22
|
+
# re-enters storage write paths.
|
|
23
|
+
_local_rlocks = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fsync_dir(path):
|
|
27
|
+
try:
|
|
28
|
+
fd = os.open(path, os.O_RDONLY)
|
|
29
|
+
try:
|
|
30
|
+
os.fsync(fd)
|
|
31
|
+
finally:
|
|
32
|
+
os.close(fd)
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _acquire_rlock(rlock):
|
|
38
|
+
try:
|
|
39
|
+
if rlock._is_owned():
|
|
40
|
+
rlock.acquire()
|
|
41
|
+
return False
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
first_acquire = rlock.acquire(blocking=False)
|
|
46
|
+
if not first_acquire:
|
|
47
|
+
rlock.acquire()
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ParquetStorage(Storage):
|
|
53
|
+
"""TinyDB Storage that persists the entire DB as a single Parquet row
|
|
54
|
+
|
|
55
|
+
The DB dict is serialized to JSON and stored in a single column named
|
|
56
|
+
'data'. To coordinate concurrent writers we use a folder-scoped lock
|
|
57
|
+
file named '.tinymongo.lock'."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, path):
|
|
60
|
+
self.path = path
|
|
61
|
+
|
|
62
|
+
def read(self):
|
|
63
|
+
if not os.path.exists(self.path):
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
dname = os.path.dirname(self.path) or "."
|
|
67
|
+
lock_path = os.path.join(dname, ".tinymongo.lock")
|
|
68
|
+
|
|
69
|
+
rlock = _local_rlocks.setdefault(lock_path, threading.RLock())
|
|
70
|
+
first_acquire = _acquire_rlock(rlock)
|
|
71
|
+
|
|
72
|
+
portalocker_lock = None
|
|
73
|
+
try:
|
|
74
|
+
if first_acquire and portalocker is not None:
|
|
75
|
+
portalocker_lock = portalocker.Lock(lock_path, timeout=30)
|
|
76
|
+
portalocker_lock.acquire()
|
|
77
|
+
|
|
78
|
+
if pq is None:
|
|
79
|
+
try:
|
|
80
|
+
with open(self.path, "r", encoding="utf8") as f:
|
|
81
|
+
return json.load(f)
|
|
82
|
+
except Exception:
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
table = pq.read_table(self.path)
|
|
87
|
+
if "data" not in table.column_names:
|
|
88
|
+
return {}
|
|
89
|
+
data_arr = table.column("data").to_pylist()
|
|
90
|
+
if not data_arr:
|
|
91
|
+
return {}
|
|
92
|
+
return json.loads(data_arr[0])
|
|
93
|
+
except Exception:
|
|
94
|
+
return {}
|
|
95
|
+
finally:
|
|
96
|
+
if portalocker_lock is not None:
|
|
97
|
+
try:
|
|
98
|
+
portalocker_lock.release()
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
try:
|
|
102
|
+
rlock.release()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def write(self, data):
|
|
107
|
+
# serialize the entire TinyDB dict to a JSON string
|
|
108
|
+
json_str = json.dumps(data or {}, ensure_ascii=False)
|
|
109
|
+
|
|
110
|
+
# ensure parent dir exists
|
|
111
|
+
dname = os.path.dirname(self.path) or "."
|
|
112
|
+
os.makedirs(dname, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
lock_path = os.path.join(dname, ".tinymongo.lock")
|
|
115
|
+
|
|
116
|
+
# Acquire an in-process reentrant lock to allow nested calls inside
|
|
117
|
+
# the same process to proceed without re-acquiring the OS-level
|
|
118
|
+
# portalocker lock. Only the first acquirer in the process will
|
|
119
|
+
# perform the portalocker acquisition for inter-process safety.
|
|
120
|
+
rlock = _local_rlocks.setdefault(lock_path, threading.RLock())
|
|
121
|
+
first_acquire = _acquire_rlock(rlock)
|
|
122
|
+
|
|
123
|
+
portalocker_lock = None
|
|
124
|
+
try:
|
|
125
|
+
if pq is None:
|
|
126
|
+
# No pyarrow — merge into existing JSON storage
|
|
127
|
+
existing = {}
|
|
128
|
+
if os.path.exists(self.path):
|
|
129
|
+
try:
|
|
130
|
+
with open(self.path, "r", encoding="utf8") as f:
|
|
131
|
+
existing = json.load(f) or {}
|
|
132
|
+
except Exception:
|
|
133
|
+
existing = {}
|
|
134
|
+
|
|
135
|
+
# Merge incoming into existing, matching on logical `_id`.
|
|
136
|
+
merged = {}
|
|
137
|
+
incoming = data or {}
|
|
138
|
+
for tname, table_data in existing.items():
|
|
139
|
+
merged[str(tname)] = {str(k): v for k, v in (table_data or {}).items()}
|
|
140
|
+
|
|
141
|
+
for tname, table_data in incoming.items():
|
|
142
|
+
t = str(tname)
|
|
143
|
+
incoming_table = {str(k): v for k, v in (table_data or {}).items()}
|
|
144
|
+
existing_table = merged.get(t, {})
|
|
145
|
+
|
|
146
|
+
# map existing _id -> eid
|
|
147
|
+
id_to_eid = {v.get('_id'): k for k, v in existing_table.items() if isinstance(v, dict) and '_id' in v}
|
|
148
|
+
try:
|
|
149
|
+
next_eid = max(int(k) for k in existing_table.keys()) + 1
|
|
150
|
+
except Exception:
|
|
151
|
+
next_eid = 1
|
|
152
|
+
|
|
153
|
+
for k, v in incoming_table.items():
|
|
154
|
+
doc_id = v.get('_id') if isinstance(v, dict) else None
|
|
155
|
+
if doc_id is not None and doc_id in id_to_eid:
|
|
156
|
+
existing_table[id_to_eid[doc_id]] = v
|
|
157
|
+
else:
|
|
158
|
+
existing_table[str(next_eid)] = v
|
|
159
|
+
next_eid += 1
|
|
160
|
+
|
|
161
|
+
merged[t] = existing_table
|
|
162
|
+
|
|
163
|
+
json_str = json.dumps(merged, ensure_ascii=False)
|
|
164
|
+
fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
|
|
165
|
+
try:
|
|
166
|
+
with os.fdopen(fd, "w", encoding="utf8") as f:
|
|
167
|
+
f.write(json_str)
|
|
168
|
+
f.flush()
|
|
169
|
+
os.fsync(f.fileno())
|
|
170
|
+
os.replace(tmp, self.path)
|
|
171
|
+
_fsync_dir(dname)
|
|
172
|
+
finally:
|
|
173
|
+
if os.path.exists(tmp):
|
|
174
|
+
try:
|
|
175
|
+
os.remove(tmp)
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
# read existing parquet into dict
|
|
181
|
+
existing = {}
|
|
182
|
+
if os.path.exists(self.path):
|
|
183
|
+
try:
|
|
184
|
+
table = pq.read_table(self.path)
|
|
185
|
+
if "data" in table.column_names:
|
|
186
|
+
data_arr = table.column("data").to_pylist()
|
|
187
|
+
if data_arr:
|
|
188
|
+
existing = json.loads(data_arr[0])
|
|
189
|
+
except Exception:
|
|
190
|
+
existing = {}
|
|
191
|
+
|
|
192
|
+
# Merge incoming into existing, matching on logical `_id`.
|
|
193
|
+
merged = {}
|
|
194
|
+
incoming = data or {}
|
|
195
|
+
for tname, table_data in existing.items():
|
|
196
|
+
merged[str(tname)] = {str(k): v for k, v in (table_data or {}).items()}
|
|
197
|
+
|
|
198
|
+
for tname, table_data in incoming.items():
|
|
199
|
+
t = str(tname)
|
|
200
|
+
incoming_table = {str(k): v for k, v in (table_data or {}).items()}
|
|
201
|
+
existing_table = merged.get(t, {})
|
|
202
|
+
|
|
203
|
+
id_to_eid = {v.get('_id'): k for k, v in existing_table.items() if isinstance(v, dict) and '_id' in v}
|
|
204
|
+
try:
|
|
205
|
+
next_eid = max(int(k) for k in existing_table.keys()) + 1
|
|
206
|
+
except Exception:
|
|
207
|
+
next_eid = 1
|
|
208
|
+
|
|
209
|
+
for k, v in incoming_table.items():
|
|
210
|
+
doc_id = v.get('_id') if isinstance(v, dict) else None
|
|
211
|
+
if doc_id is not None and doc_id in id_to_eid:
|
|
212
|
+
existing_table[id_to_eid[doc_id]] = v
|
|
213
|
+
else:
|
|
214
|
+
existing_table[str(next_eid)] = v
|
|
215
|
+
next_eid += 1
|
|
216
|
+
|
|
217
|
+
merged[t] = existing_table
|
|
218
|
+
|
|
219
|
+
json_str = json.dumps(merged, ensure_ascii=False)
|
|
220
|
+
|
|
221
|
+
# write Parquet with single column 'data'
|
|
222
|
+
arr = pa.array([json_str], type=pa.string())
|
|
223
|
+
table = pa.table({"data": arr})
|
|
224
|
+
|
|
225
|
+
fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
|
|
226
|
+
os.close(fd)
|
|
227
|
+
try:
|
|
228
|
+
# write with Parquet v2 layout
|
|
229
|
+
pq.write_table(table, tmp, version="2.6")
|
|
230
|
+
# fsync file
|
|
231
|
+
try:
|
|
232
|
+
with open(tmp, "rb") as f:
|
|
233
|
+
f.flush()
|
|
234
|
+
os.fsync(f.fileno())
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
os.replace(tmp, self.path)
|
|
238
|
+
_fsync_dir(dname)
|
|
239
|
+
finally:
|
|
240
|
+
if os.path.exists(tmp):
|
|
241
|
+
try:
|
|
242
|
+
os.remove(tmp)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
finally:
|
|
246
|
+
# Release portalocker only if we acquired it here.
|
|
247
|
+
if 'portalocker_lock' in locals() and portalocker_lock is not None:
|
|
248
|
+
try:
|
|
249
|
+
portalocker_lock.release()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
# Release in-process reentrant lock once.
|
|
253
|
+
try:
|
|
254
|
+
rlock.release()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
tinymongo/results.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Result class definitions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class _WriteResult(object):
|
|
5
|
+
"""Base class for write result classes."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, acknowledged=True):
|
|
8
|
+
self.acknowledged = acknowledged # here only to PyMongo compat
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InsertOneResult(_WriteResult):
|
|
12
|
+
"""The return type for :meth:`~tinymongo.TinyMongoCollection.insert_one`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__slots__ = ("__inserted_id", "__acknowledged", "__eid")
|
|
16
|
+
|
|
17
|
+
def __init__(self, eid, inserted_id, acknowledged=True):
|
|
18
|
+
self.__eid = eid
|
|
19
|
+
self.__inserted_id = inserted_id
|
|
20
|
+
super(InsertOneResult, self).__init__(acknowledged)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def inserted_id(self):
|
|
24
|
+
"""The inserted document's _id."""
|
|
25
|
+
return self.__inserted_id
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def eid(self):
|
|
29
|
+
"""The inserted document's tinyDB eid."""
|
|
30
|
+
return self.__eid
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InsertManyResult(_WriteResult):
|
|
34
|
+
"""The return type for :meth:`~tinymongo.TinyMongoCollection.insert_many`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("__inserted_ids", "__acknowledged", "__eids")
|
|
38
|
+
|
|
39
|
+
def __init__(self, eids, inserted_ids, acknowledged=True):
|
|
40
|
+
self.__eids = eids
|
|
41
|
+
self.__inserted_ids = inserted_ids
|
|
42
|
+
super(InsertManyResult, self).__init__(acknowledged)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def inserted_ids(self):
|
|
46
|
+
"""A list of _ids of the inserted documents, in the order provided."""
|
|
47
|
+
return self.__inserted_ids
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def eids(self):
|
|
51
|
+
"""A list of _ids of the inserted documents, in the order provided."""
|
|
52
|
+
return self.__eids
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class UpdateResult(_WriteResult):
|
|
56
|
+
"""The return type for :meth:`~tinymongo.TinyMongoCollection.update_one`,
|
|
57
|
+
:meth:`~tinymongo.TinyMongoCollection.update_many`, and
|
|
58
|
+
:meth:`~tinymongo.TinyMongoCollection.replace_one`.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
__slots__ = ("__raw_result", "__acknowledged")
|
|
62
|
+
|
|
63
|
+
def __init__(self, raw_result, acknowledged=True):
|
|
64
|
+
self.__raw_result = raw_result
|
|
65
|
+
super(UpdateResult, self).__init__(acknowledged)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def raw_result(self):
|
|
69
|
+
"""The raw result document returned by the server."""
|
|
70
|
+
return self.__raw_result
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def matched_count(self):
|
|
74
|
+
"""The number of documents matched for this update."""
|
|
75
|
+
if isinstance(self.raw_result, list):
|
|
76
|
+
return len(self.raw_result)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def modified_count(self):
|
|
81
|
+
"""The number of documents modified."""
|
|
82
|
+
if isinstance(self.raw_result, list):
|
|
83
|
+
return len(self.raw_result)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def upserted_id(self):
|
|
88
|
+
"""The _id of the inserted document if an upsert took place. Otherwise
|
|
89
|
+
``None``.
|
|
90
|
+
"""
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DeleteResult(_WriteResult):
|
|
95
|
+
"""The return type for :meth:`~tinymongo.TinyMongoCollection.delete_one`
|
|
96
|
+
and :meth:`~tinymongo.TinyMongoCollection.delete_many`"""
|
|
97
|
+
|
|
98
|
+
__slots__ = ("__raw_result", "__acknowledged")
|
|
99
|
+
|
|
100
|
+
def __init__(self, raw_result, acknowledged=True):
|
|
101
|
+
self.__raw_result = raw_result
|
|
102
|
+
super(DeleteResult, self).__init__(acknowledged)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def raw_result(self):
|
|
106
|
+
"""The raw result document returned by the server."""
|
|
107
|
+
return self.__raw_result
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def deleted_count(self):
|
|
111
|
+
"""The number of documents deleted."""
|
|
112
|
+
if isinstance(self.raw_result, list):
|
|
113
|
+
return len(self.raw_result)
|
|
114
|
+
else:
|
|
115
|
+
return self.raw_result
|
tinymongo/serializers.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
try:
|
|
3
|
+
from tinydb_serialization import Serializer
|
|
4
|
+
except ImportError:
|
|
5
|
+
raise RuntimeError(
|
|
6
|
+
u'Cannot import tinydb_serialization due to {0} '
|
|
7
|
+
u'you need to run `pip install tinydb_serialization`'
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DateTimeSerializer(Serializer):
|
|
12
|
+
OBJ_CLASS = datetime
|
|
13
|
+
|
|
14
|
+
def __init__(self, dateformat='%Y-%m-%dT%H:%M:%S', *args, **kwargs):
|
|
15
|
+
# super(DateTimeSerializer, self).__init__(*args, **kwargs)
|
|
16
|
+
self._format = dateformat
|
|
17
|
+
|
|
18
|
+
def encode(self, obj):
|
|
19
|
+
return obj.strftime(self._format)
|
|
20
|
+
|
|
21
|
+
def decode(self, s):
|
|
22
|
+
return self.OBJ_CLASS.strptime(s, self._format)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
4
|
+
import tempfile
|
|
5
|
+
from tinydb.storages import Storage
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import duckdb
|
|
9
|
+
except Exception:
|
|
10
|
+
duckdb = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _fsync_dir(path):
|
|
14
|
+
try:
|
|
15
|
+
fd = os.open(path, os.O_RDONLY)
|
|
16
|
+
try:
|
|
17
|
+
os.fsync(fd)
|
|
18
|
+
finally:
|
|
19
|
+
os.close(fd)
|
|
20
|
+
except Exception:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SQLiteStorage(Storage):
|
|
25
|
+
"""TinyDB storage backend using SQLite as a single-row JSON store."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, path):
|
|
28
|
+
self.path = path
|
|
29
|
+
|
|
30
|
+
def read(self):
|
|
31
|
+
if not os.path.exists(self.path):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
conn = sqlite3.connect(self.path)
|
|
36
|
+
cursor = conn.cursor()
|
|
37
|
+
cursor.execute(
|
|
38
|
+
"CREATE TABLE IF NOT EXISTS tinydb(id INTEGER PRIMARY KEY, data TEXT)"
|
|
39
|
+
)
|
|
40
|
+
cursor.execute("SELECT data FROM tinydb WHERE id = 1")
|
|
41
|
+
row = cursor.fetchone()
|
|
42
|
+
conn.close()
|
|
43
|
+
if row is None or row[0] is None:
|
|
44
|
+
return {}
|
|
45
|
+
return json.loads(row[0])
|
|
46
|
+
except Exception:
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
def write(self, data):
|
|
50
|
+
json_str = json.dumps(data or {}, ensure_ascii=False)
|
|
51
|
+
dname = os.path.dirname(self.path) or "."
|
|
52
|
+
os.makedirs(dname, exist_ok=True)
|
|
53
|
+
|
|
54
|
+
fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
|
|
55
|
+
os.close(fd)
|
|
56
|
+
try:
|
|
57
|
+
conn = sqlite3.connect(tmp)
|
|
58
|
+
cursor = conn.cursor()
|
|
59
|
+
cursor.execute(
|
|
60
|
+
"PRAGMA journal_mode = OFF"
|
|
61
|
+
)
|
|
62
|
+
cursor.execute(
|
|
63
|
+
"CREATE TABLE IF NOT EXISTS tinydb(id INTEGER PRIMARY KEY, data TEXT)"
|
|
64
|
+
)
|
|
65
|
+
cursor.execute(
|
|
66
|
+
"INSERT OR REPLACE INTO tinydb(id, data) VALUES(1, ?)"
|
|
67
|
+
, (json_str,)
|
|
68
|
+
)
|
|
69
|
+
conn.commit()
|
|
70
|
+
conn.close()
|
|
71
|
+
os.replace(tmp, self.path)
|
|
72
|
+
_fsync_dir(dname)
|
|
73
|
+
finally:
|
|
74
|
+
if os.path.exists(tmp):
|
|
75
|
+
try:
|
|
76
|
+
os.remove(tmp)
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DuckDBStorage(Storage):
|
|
82
|
+
"""TinyDB storage backend using DuckDB as a single-row JSON store."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, path):
|
|
85
|
+
if duckdb is None:
|
|
86
|
+
raise ImportError(
|
|
87
|
+
"duckdb backend requires the duckdb package to be installed"
|
|
88
|
+
)
|
|
89
|
+
self.path = path
|
|
90
|
+
|
|
91
|
+
def read(self):
|
|
92
|
+
if not os.path.exists(self.path):
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
conn = duckdb.connect(self.path)
|
|
97
|
+
result = conn.execute("SELECT data FROM tinydb WHERE id = 1").fetchone()
|
|
98
|
+
conn.close()
|
|
99
|
+
if result is None or result[0] is None:
|
|
100
|
+
return {}
|
|
101
|
+
return json.loads(result[0])
|
|
102
|
+
except Exception:
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
def write(self, data):
|
|
106
|
+
json_str = json.dumps(data or {}, ensure_ascii=False)
|
|
107
|
+
dname = os.path.dirname(self.path) or "."
|
|
108
|
+
os.makedirs(dname, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
fd, tmp = tempfile.mkstemp(prefix="tmp", dir=dname)
|
|
111
|
+
os.close(fd)
|
|
112
|
+
try:
|
|
113
|
+
conn = duckdb.connect(tmp)
|
|
114
|
+
conn.execute("CREATE TABLE IF NOT EXISTS tinydb(id INTEGER PRIMARY KEY, data TEXT)")
|
|
115
|
+
conn.execute(
|
|
116
|
+
"INSERT OR REPLACE INTO tinydb(id, data) VALUES(1, ?)",
|
|
117
|
+
(json_str,)
|
|
118
|
+
)
|
|
119
|
+
conn.close()
|
|
120
|
+
os.replace(tmp, self.path)
|
|
121
|
+
_fsync_dir(dname)
|
|
122
|
+
finally:
|
|
123
|
+
if os.path.exists(tmp):
|
|
124
|
+
try:
|
|
125
|
+
os.remove(tmp)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_storage_class(name):
|
|
131
|
+
if name is None:
|
|
132
|
+
name = "tinydb"
|
|
133
|
+
|
|
134
|
+
if isinstance(name, type):
|
|
135
|
+
return name
|
|
136
|
+
|
|
137
|
+
backend = str(name).lower()
|
|
138
|
+
|
|
139
|
+
if backend in ("tinydb", "json"):
|
|
140
|
+
from tinydb import TinyDB
|
|
141
|
+
|
|
142
|
+
# TinyDB 3.x exposes DEFAULT_STORAGE instead of default_storage_class.
|
|
143
|
+
return getattr(TinyDB, "default_storage_class", TinyDB.DEFAULT_STORAGE)
|
|
144
|
+
if backend in ("parquet", "parquetv2"):
|
|
145
|
+
from .parquet_storage import ParquetStorage
|
|
146
|
+
|
|
147
|
+
return ParquetStorage
|
|
148
|
+
if backend == "sqlite":
|
|
149
|
+
return SQLiteStorage
|
|
150
|
+
if backend == "duckdb":
|
|
151
|
+
return DuckDBStorage
|
|
152
|
+
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"Unsupported backend '{0}'. Supported backends: tinydb, parquet, parquetv2, sqlite, duckdb.".format(
|
|
155
|
+
name
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def storage_extension(name):
|
|
161
|
+
backend = str(name).lower()
|
|
162
|
+
if backend in ("tinydb", "json"):
|
|
163
|
+
return ".json"
|
|
164
|
+
if backend in ("parquet", "parquetv2"):
|
|
165
|
+
return ".parquet"
|
|
166
|
+
if backend == "sqlite":
|
|
167
|
+
return ".sqlite"
|
|
168
|
+
if backend == "duckdb":
|
|
169
|
+
return ".duckdb"
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"Unsupported backend '{0}'. Supported backends: tinydb, parquet, parquetv2, sqlite, duckdb.".format(
|
|
172
|
+
name
|
|
173
|
+
)
|
|
174
|
+
)
|