sdata 1.5.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.
- sdata/__init__.py +140 -0
- sdata/base.py +684 -0
- sdata/contrib/__init__.py +0 -0
- sdata/contrib/piexif/__init__.py +12 -0
- sdata/contrib/piexif/_common.py +94 -0
- sdata/contrib/piexif/_dump.py +346 -0
- sdata/contrib/piexif/_exceptions.py +2 -0
- sdata/contrib/piexif/_exif.py +638 -0
- sdata/contrib/piexif/_insert.py +60 -0
- sdata/contrib/piexif/_load.py +270 -0
- sdata/contrib/piexif/_remove.py +56 -0
- sdata/contrib/piexif/_transplant.py +45 -0
- sdata/contrib/piexif/_webp.py +240 -0
- sdata/contrib/piexif/helper.py +66 -0
- sdata/contrib/ranger/__init__.py +8 -0
- sdata/contrib/ranger/collections/__init__.py +0 -0
- sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
- sdata/contrib/ranger/collections/rangemap.py +396 -0
- sdata/contrib/ranger/collections/rangeset.py +483 -0
- sdata/contrib/ranger/range/__init__.py +0 -0
- sdata/contrib/ranger/range/cut.py +285 -0
- sdata/contrib/ranger/range/range.py +644 -0
- sdata/contrib/sobol_seq.py +394 -0
- sdata/contrib/sortedcontainers/LICENSE +13 -0
- sdata/contrib/sortedcontainers/__init__.py +74 -0
- sdata/contrib/sortedcontainers/sorteddict.py +789 -0
- sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
- sdata/contrib/sortedcontainers/sortedset.py +733 -0
- sdata/contrib/sqlitedict.py +598 -0
- sdata/contrib/timeflake/__init__.py +40 -0
- sdata/contrib/timeflake/flake.py +69 -0
- sdata/contrib/timeflake/utils.py +47 -0
- sdata/deprecated/__init__.py +10 -0
- sdata/deprecated/data.py +1503 -0
- sdata/did/__init__.py +129 -0
- sdata/did/_http.py +67 -0
- sdata/did/base58btc.py +49 -0
- sdata/did/did_github.py +152 -0
- sdata/did/did_peer4.py +155 -0
- sdata/did/did_web.py +222 -0
- sdata/did/diddoc.py +75 -0
- sdata/did/ed25519.py +225 -0
- sdata/did/eddsa.py +107 -0
- sdata/did/errors.py +40 -0
- sdata/did/issue_vc.py +193 -0
- sdata/did/jose.py +126 -0
- sdata/did/keys.py +104 -0
- sdata/did/utils_didkey.py +161 -0
- sdata/did/verify_vp.py +158 -0
- sdata/doe.py +101 -0
- sdata/dtypes.py +510 -0
- sdata/experiments/__init__.py +331 -0
- sdata/experiments/blob0.py +131 -0
- sdata/experiments/file.py +42 -0
- sdata/experiments/filegroup.py +144 -0
- sdata/experiments/image_old.py +313 -0
- sdata/experiments/ks2.py +15 -0
- sdata/experiments/lapshear.py +8 -0
- sdata/experiments/material.py +7 -0
- sdata/experiments/parameters_deprecated.py +394 -0
- sdata/experiments/workbook.py +226 -0
- sdata/format.py +125 -0
- sdata/imagemeta.py +533 -0
- sdata/interactive.py +118 -0
- sdata/iolib/__init__.py +51 -0
- sdata/iolib/hashtree.py +264 -0
- sdata/iolib/json1sqlitestore.py +554 -0
- sdata/iolib/jsonsqlitestore.py +704 -0
- sdata/iolib/pud.py +242 -0
- sdata/iolib/reader.py +262 -0
- sdata/iolib/writer.py +419 -0
- sdata/metadata.py +882 -0
- sdata/parameter.py +414 -0
- sdata/schema.py +273 -0
- sdata/sclass/__init__.py +111 -0
- sdata/sclass/bfo_classes.py +362 -0
- sdata/sclass/blob.py +362 -0
- sdata/sclass/content.py +87 -0
- sdata/sclass/dataframe.py +1251 -0
- sdata/sclass/dataframegroup.py +187 -0
- sdata/sclass/dependency_graph.py +85 -0
- sdata/sclass/filereference.py +124 -0
- sdata/sclass/image.py +218 -0
- sdata/sclass/lazy_namespace.py +155 -0
- sdata/sclass/process.py +383 -0
- sdata/sclass/process_graph.py +221 -0
- sdata/sclass/zipfile.py +135 -0
- sdata/semantic.py +346 -0
- sdata/suuid.py +136 -0
- sdata/timestamp.py +336 -0
- sdata/tools.py +14 -0
- sdata/units.py +602 -0
- sdata/vocab.py +149 -0
- sdata-1.5.0.dist-info/METADATA +264 -0
- sdata-1.5.0.dist-info/RECORD +98 -0
- sdata-1.5.0.dist-info/WHEEL +5 -0
- sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
- sdata-1.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
|
|
7
|
+
import re
|
|
8
|
+
import warnings
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
class JSON1SQLiteStore:
|
|
12
|
+
"""
|
|
13
|
+
JSON store based on SQLite with extensive functionality:
|
|
14
|
+
- CRUD operations
|
|
15
|
+
- JSON1 functions (json_extract, json_set, ...)
|
|
16
|
+
- dynamic index creation
|
|
17
|
+
- wildcard searches
|
|
18
|
+
- transactions (manual & context manager)
|
|
19
|
+
- batch operations
|
|
20
|
+
- existence/count methods
|
|
21
|
+
- pagination
|
|
22
|
+
- schema versioning/migrations
|
|
23
|
+
- backup & restore
|
|
24
|
+
- raw SQL hooks
|
|
25
|
+
- TTL expiration
|
|
26
|
+
- event callbacks
|
|
27
|
+
- type parsing on read
|
|
28
|
+
|
|
29
|
+
Compression is not supported to prioritize speed.
|
|
30
|
+
|
|
31
|
+
Assumes all JSON dicts have _sdata_* attributes like _sdata_suuid, _sdata_sname, _sdata_name, _sdata_class.
|
|
32
|
+
Uses generated columns for these fields to optimize queries.
|
|
33
|
+
|
|
34
|
+
Example:
|
|
35
|
+
store = JSON1SQLiteStore('data.db', index_keys=['user'])
|
|
36
|
+
# Insert an entry
|
|
37
|
+
rid = store.insert({'_sdata_class': 'test', '_sdata_name': 'alice', '_sdata_suuid': 'suuid1', '_sdata_sname': 'sname1', 'scores':[1,2,3]})
|
|
38
|
+
# Extract a value
|
|
39
|
+
username = store.extract(rid, '$._sdata_name')
|
|
40
|
+
print(username) # 'alice'
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
# Kanonische, als generierte Spalten materialisierte _sdata_*-Felder.
|
|
44
|
+
# Konvention: Spaltenname == JSON-Key == Index-Key (Single Source of Truth).
|
|
45
|
+
GENERATED_COLUMNS = (
|
|
46
|
+
"_sdata_class",
|
|
47
|
+
"_sdata_name",
|
|
48
|
+
"_sdata_sname",
|
|
49
|
+
"_sdata_suuid",
|
|
50
|
+
"_sdata_parent_sname",
|
|
51
|
+
"_sdata_project_sname",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
filename: str,
|
|
57
|
+
index_keys: Optional[List[str]] = None,
|
|
58
|
+
unique_index_keys: Optional[List[str]] = None,
|
|
59
|
+
timeout: float = 5.0,
|
|
60
|
+
check_same_thread: bool = True,
|
|
61
|
+
type_parsers: Optional[Dict[str, Callable[[Any], Any]]] = None,
|
|
62
|
+
hooks: Optional[Dict[str, Callable[..., None]]] = None,
|
|
63
|
+
json1_extension: Optional[str] = None,
|
|
64
|
+
):
|
|
65
|
+
self.filename = filename
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
self.check_same_thread = check_same_thread
|
|
68
|
+
self.type_parsers = type_parsers or {}
|
|
69
|
+
self.hooks = hooks or {}
|
|
70
|
+
self.json1_extension = json1_extension
|
|
71
|
+
self._init_conn()
|
|
72
|
+
# Automatically index the _sdata_* fields if not specified
|
|
73
|
+
sdata_keys = ['_sdata_class', '_sdata_name', '_sdata_parent_sname', '_sdata_project_sname']
|
|
74
|
+
sdata_unique_keys = ['_sdata_sname', '_sdata_suuid']
|
|
75
|
+
index_keys = list(set((index_keys or []) + sdata_keys))
|
|
76
|
+
unique_index_keys = list(set((unique_index_keys or []) + sdata_unique_keys))
|
|
77
|
+
for key in index_keys:
|
|
78
|
+
column = key if key in self.GENERATED_COLUMNS else None
|
|
79
|
+
self.create_index(key, unique=False, column=column)
|
|
80
|
+
for key in unique_index_keys:
|
|
81
|
+
column = key if key in self.GENERATED_COLUMNS else None
|
|
82
|
+
self.create_index(key, unique=True, column=column)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _init_conn(self) -> None:
|
|
86
|
+
self.conn = sqlite3.connect(
|
|
87
|
+
self.filename,
|
|
88
|
+
timeout=self.timeout,
|
|
89
|
+
check_same_thread=self.check_same_thread,
|
|
90
|
+
isolation_level=None,
|
|
91
|
+
detect_types=sqlite3.PARSE_DECLTYPES
|
|
92
|
+
)
|
|
93
|
+
# Check if JSON1 is available
|
|
94
|
+
try:
|
|
95
|
+
self.conn.execute("SELECT json('{}')")
|
|
96
|
+
except sqlite3.OperationalError: # pragma: no cover - JSON1 ist in modernem SQLite eingebaut
|
|
97
|
+
if self.json1_extension:
|
|
98
|
+
self.conn.enable_load_extension(True)
|
|
99
|
+
self.conn.load_extension(self.json1_extension)
|
|
100
|
+
self.conn.enable_load_extension(False)
|
|
101
|
+
try:
|
|
102
|
+
self.conn.execute("SELECT json('{}')")
|
|
103
|
+
except sqlite3.OperationalError:
|
|
104
|
+
raise RuntimeError("Failed to load JSON1 extension.")
|
|
105
|
+
else:
|
|
106
|
+
raise RuntimeError("SQLite without JSON1 support. Compile with ENABLE_JSON1 or provide extension.")
|
|
107
|
+
# PRAGMAs for performance
|
|
108
|
+
self.conn.execute("PRAGMA journal_mode = WAL")
|
|
109
|
+
self.conn.execute("PRAGMA synchronous = NORMAL")
|
|
110
|
+
self.conn.execute("PRAGMA temp_store = MEMORY")
|
|
111
|
+
self.conn.row_factory = sqlite3.Row
|
|
112
|
+
self._ensure_table()
|
|
113
|
+
|
|
114
|
+
def _ensure_table(self) -> None:
|
|
115
|
+
generated = ",\n ".join(
|
|
116
|
+
"{col} TEXT GENERATED ALWAYS AS (json_extract(payload, '$.{col}')) STORED".format(col=col)
|
|
117
|
+
for col in self.GENERATED_COLUMNS
|
|
118
|
+
)
|
|
119
|
+
self.conn.execute(
|
|
120
|
+
"""
|
|
121
|
+
CREATE TABLE IF NOT EXISTS data (
|
|
122
|
+
id INTEGER PRIMARY KEY,
|
|
123
|
+
payload TEXT NOT NULL,
|
|
124
|
+
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
125
|
+
{generated}
|
|
126
|
+
);
|
|
127
|
+
""".format(generated=generated)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _serialize(self, obj: Dict[str, Any]) -> str:
|
|
131
|
+
return json.dumps(obj, separators=(',', ':'))
|
|
132
|
+
|
|
133
|
+
def _deserialize(self, payload: str) -> Dict[str, Any]:
|
|
134
|
+
obj = json.loads(payload)
|
|
135
|
+
for key, parser in self.type_parsers.items():
|
|
136
|
+
if key in obj:
|
|
137
|
+
obj[key] = parser(obj[key])
|
|
138
|
+
return obj
|
|
139
|
+
|
|
140
|
+
def _get_by_path(self, obj: Any, path: str) -> Any:
|
|
141
|
+
if not path.startswith('$'):
|
|
142
|
+
path = '$' + path
|
|
143
|
+
parts = re.split(r'\.|\[|\]', path[1:].lstrip('.')) # Verbessert: Besserer Split für Pfade
|
|
144
|
+
parts = [p for p in parts if p] # Entferne leere Teile von []
|
|
145
|
+
current = obj
|
|
146
|
+
try:
|
|
147
|
+
for part in parts:
|
|
148
|
+
if part.isdigit():
|
|
149
|
+
current = current[int(part)]
|
|
150
|
+
else:
|
|
151
|
+
current = current[part]
|
|
152
|
+
return current
|
|
153
|
+
except (KeyError, IndexError, TypeError) as e:
|
|
154
|
+
raise KeyError(f"Path '{path}' not found or type mismatch: {e}")
|
|
155
|
+
|
|
156
|
+
def _set_by_path(self, obj: Any, path: str, value: Any, mode: str = 'set') -> None:
|
|
157
|
+
# Verbessert: Sauberere Handhabung, mit Default-Werten für fehlende Container
|
|
158
|
+
if not path.startswith('$'):
|
|
159
|
+
path = '$' + path
|
|
160
|
+
parts = re.split(r'\.|\[|\]', path[1:].lstrip('.'))
|
|
161
|
+
parts = [p for p in parts if p]
|
|
162
|
+
current = obj
|
|
163
|
+
for i, part in enumerate(parts[:-1]):
|
|
164
|
+
if part.isdigit():
|
|
165
|
+
idx = int(part)
|
|
166
|
+
if not isinstance(current, list):
|
|
167
|
+
raise ValueError("Path type mismatch: Expected list")
|
|
168
|
+
while len(current) <= idx:
|
|
169
|
+
current.append(None)
|
|
170
|
+
if current[idx] is None:
|
|
171
|
+
next_part = parts[i + 1]
|
|
172
|
+
current[idx] = [] if next_part.isdigit() else {}
|
|
173
|
+
current = current[idx]
|
|
174
|
+
else:
|
|
175
|
+
if part not in current:
|
|
176
|
+
if mode == 'replace':
|
|
177
|
+
return
|
|
178
|
+
next_part = parts[i + 1]
|
|
179
|
+
current[part] = [] if next_part.isdigit() else {}
|
|
180
|
+
current = current[part]
|
|
181
|
+
# Letzter Teil
|
|
182
|
+
last = parts[-1]
|
|
183
|
+
if last.isdigit():
|
|
184
|
+
idx = int(last)
|
|
185
|
+
if not isinstance(current, list):
|
|
186
|
+
if mode == 'replace':
|
|
187
|
+
return
|
|
188
|
+
current = [] # Konvertiere zu List
|
|
189
|
+
exists = idx < len(current)
|
|
190
|
+
if mode == 'insert' and exists:
|
|
191
|
+
return
|
|
192
|
+
if mode == 'replace' and not exists:
|
|
193
|
+
return
|
|
194
|
+
while len(current) <= idx:
|
|
195
|
+
current.append(None)
|
|
196
|
+
current[idx] = value
|
|
197
|
+
else:
|
|
198
|
+
exists = last in current
|
|
199
|
+
if mode == 'insert' and exists:
|
|
200
|
+
return
|
|
201
|
+
if mode == 'replace' and not exists:
|
|
202
|
+
return
|
|
203
|
+
current[last] = value
|
|
204
|
+
|
|
205
|
+
def _remove_by_path(self, obj: Any, path: str) -> None:
|
|
206
|
+
# Verbessert: Sauberer mit try-except
|
|
207
|
+
try:
|
|
208
|
+
parent = self._get_by_path(obj, path.rsplit('.', 1)[0] if '.' in path else '$')
|
|
209
|
+
key = path.rsplit('.', 1)[-1]
|
|
210
|
+
if '[' in key:
|
|
211
|
+
key, idx_str = key.split('[')
|
|
212
|
+
idx = int(idx_str.rstrip(']'))
|
|
213
|
+
if isinstance(parent.get(key, []), list):
|
|
214
|
+
parent[key].pop(idx)
|
|
215
|
+
else:
|
|
216
|
+
parent.pop(key, None)
|
|
217
|
+
except KeyError:
|
|
218
|
+
pass # Ignoriere, wenn nicht existent (pythonic: EAFP)
|
|
219
|
+
|
|
220
|
+
def _compare(self, a: Any, op: str, b: Any) -> bool:
|
|
221
|
+
op = op.upper()
|
|
222
|
+
ops = {
|
|
223
|
+
'=': lambda: a == b,
|
|
224
|
+
'>': lambda: a > b,
|
|
225
|
+
'<': lambda: a < b,
|
|
226
|
+
'>=': lambda: a >= b,
|
|
227
|
+
'<=': lambda: a <= b,
|
|
228
|
+
'LIKE': lambda: re.match('^' + re.escape(str(b)).replace('%', '.*').replace('_', '.' ) + '$', str(a)) is not None if isinstance(a, str) else False,
|
|
229
|
+
}
|
|
230
|
+
try:
|
|
231
|
+
return ops[op]()
|
|
232
|
+
except KeyError:
|
|
233
|
+
raise ValueError(f"Unsupported operator: {op}")
|
|
234
|
+
|
|
235
|
+
def _each_python(self, obj: Any, json_path: str) -> List[Dict[str, Any]]:
|
|
236
|
+
sub = self._get_by_path(obj, json_path)
|
|
237
|
+
if isinstance(sub, list):
|
|
238
|
+
return [{'key': i, 'value': v} for i, v in enumerate(sub)]
|
|
239
|
+
elif isinstance(sub, dict):
|
|
240
|
+
return [{'key': k, 'value': v} for k, v in sorted(sub.items())]
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
def _tree_python(self, obj: Any, path: str = '$') -> Iterator[Dict[str, Any]]:
|
|
244
|
+
# Verbessert: Generator für Memory-Effizienz bei tiefen Trees
|
|
245
|
+
yield {'path': path, 'key': None, 'value': obj}
|
|
246
|
+
if isinstance(obj, dict):
|
|
247
|
+
for k, v in obj.items():
|
|
248
|
+
yield from self._tree_python(v, f"{path}.{k}")
|
|
249
|
+
elif isinstance(obj, list):
|
|
250
|
+
for i, v in enumerate(obj):
|
|
251
|
+
yield from self._tree_python(v, f"{path}[{i}]")
|
|
252
|
+
|
|
253
|
+
def create_index(self, key: str, unique: bool = False, column: Optional[str] = None) -> None:
|
|
254
|
+
# Neu: Unterstützung für direkte Spalten (für generated)
|
|
255
|
+
expr = column if column else f"json_extract(payload, '$.{key}')"
|
|
256
|
+
idx_name = f"idx_{key.replace('.', '_').replace('[', '_').replace(']', '_').replace('!', '_')}"
|
|
257
|
+
unique_str = 'UNIQUE ' if unique else ''
|
|
258
|
+
sql = f"CREATE {unique_str}INDEX IF NOT EXISTS {idx_name} ON data ({expr});"
|
|
259
|
+
self.conn.execute(sql)
|
|
260
|
+
|
|
261
|
+
def get_indices_columns(self) -> List[Tuple[str, bool]]:
|
|
262
|
+
cur = self.conn.execute("PRAGMA index_list('data')")
|
|
263
|
+
return [row['name'].replace("idx_", "") for row in cur.fetchall()]
|
|
264
|
+
|
|
265
|
+
def list_indices(self) -> List[Tuple[str, bool]]:
|
|
266
|
+
cur = self.conn.execute("PRAGMA index_list('data')")
|
|
267
|
+
return [(row['name'], bool(row['unique'])) for row in cur.fetchall()]
|
|
268
|
+
|
|
269
|
+
def regenerate_index(self, key: str, unique: bool = False, column: Optional[str] = None) -> None:
|
|
270
|
+
idx_name = f"idx_{key.replace('.', '_').replace('[', '_').replace(']', '_').replace('!', '_')}"
|
|
271
|
+
self.conn.execute(f"DROP INDEX IF EXISTS {idx_name}")
|
|
272
|
+
self.create_index(key, unique=unique, column=column)
|
|
273
|
+
|
|
274
|
+
def get_id_by_key(self, key: str, value: Any) -> Optional[int]:
|
|
275
|
+
# Optimiert für generated columns
|
|
276
|
+
column = key if key in self.GENERATED_COLUMNS else None
|
|
277
|
+
if column:
|
|
278
|
+
row = self.conn.execute(f"SELECT id FROM data WHERE {column} = ?", (value,)).fetchone()
|
|
279
|
+
else:
|
|
280
|
+
row = self.conn.execute("SELECT id FROM data WHERE json_extract(payload, ?) = ?", (f'$.{key}', value)).fetchone()
|
|
281
|
+
return row['id'] if row else None
|
|
282
|
+
|
|
283
|
+
def insert(self, obj: Dict[str, Any]) -> int:
|
|
284
|
+
payload = self._serialize(obj)
|
|
285
|
+
sql = "INSERT INTO data (payload) VALUES (json(?))"
|
|
286
|
+
cur = self.conn.execute(sql, (payload,))
|
|
287
|
+
rid = cur.lastrowid
|
|
288
|
+
if 'on_insert' in self.hooks:
|
|
289
|
+
self.hooks['on_insert'](rid, obj)
|
|
290
|
+
return rid
|
|
291
|
+
|
|
292
|
+
def insert_many(self, objs: List[Dict[str, Any]]) -> List[int]:
|
|
293
|
+
arr = [(self._serialize(o),) for o in objs]
|
|
294
|
+
sql = "INSERT INTO data (payload) VALUES (json(?))"
|
|
295
|
+
self.conn.executemany(sql, arr)
|
|
296
|
+
max_id = self.conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
297
|
+
ids = list(range(max_id - len(objs) + 1, max_id + 1))
|
|
298
|
+
if 'on_insert_many' in self.hooks:
|
|
299
|
+
self.hooks['on_insert_many'](list(zip(ids, objs)))
|
|
300
|
+
return ids
|
|
301
|
+
|
|
302
|
+
def get(self, record_id: int) -> Optional[Dict[str, Any]]:
|
|
303
|
+
row = self.conn.execute("SELECT payload FROM data WHERE id = ?", (record_id,)).fetchone()
|
|
304
|
+
return self._deserialize(row['payload']) if row else None
|
|
305
|
+
|
|
306
|
+
def fetch_all(self) -> Iterator[Dict[str, Any]]:
|
|
307
|
+
cur = self.conn.execute("SELECT payload FROM data")
|
|
308
|
+
for row in cur:
|
|
309
|
+
yield self._deserialize(row['payload'])
|
|
310
|
+
|
|
311
|
+
def update(self, identifier: Union[int, str], new_obj: Dict[str, Any]) -> None:
|
|
312
|
+
if isinstance(identifier, str):
|
|
313
|
+
rid = self.get_id_by_key('_sdata_suuid', identifier)
|
|
314
|
+
if rid is None:
|
|
315
|
+
raise ValueError(f"No record with '_sdata_suuid': {identifier}")
|
|
316
|
+
else:
|
|
317
|
+
rid = identifier
|
|
318
|
+
payload = self._serialize(new_obj)
|
|
319
|
+
sql = "UPDATE data SET payload = json(?) WHERE id = ?"
|
|
320
|
+
self.conn.execute(sql, (payload, rid))
|
|
321
|
+
if 'on_update' in self.hooks:
|
|
322
|
+
self.hooks['on_update'](rid, new_obj)
|
|
323
|
+
|
|
324
|
+
def update_many(self, pairs: List[Tuple[int, Dict[str, Any]]]) -> None:
|
|
325
|
+
arr = [(self._serialize(obj), rid) for rid, obj in pairs]
|
|
326
|
+
sql = "UPDATE data SET payload = json(?) WHERE id = ?"
|
|
327
|
+
self.conn.executemany(sql, arr)
|
|
328
|
+
if 'on_update_many' in self.hooks:
|
|
329
|
+
self.hooks['on_update_many'](pairs)
|
|
330
|
+
|
|
331
|
+
def delete(self, record_id: int) -> None:
|
|
332
|
+
self.conn.execute("DELETE FROM data WHERE id = ?", (record_id,))
|
|
333
|
+
if 'on_delete' in self.hooks:
|
|
334
|
+
self.hooks['on_delete'](record_id)
|
|
335
|
+
|
|
336
|
+
def exists(self, record_id: int) -> bool:
|
|
337
|
+
return self.conn.execute("SELECT 1 FROM data WHERE id = ? LIMIT 1", (record_id,)).fetchone() is not None
|
|
338
|
+
|
|
339
|
+
def exists_where(self, key: str, value: Any) -> bool:
|
|
340
|
+
column = key if key in self.GENERATED_COLUMNS else None
|
|
341
|
+
if column:
|
|
342
|
+
return self.conn.execute(f"SELECT 1 FROM data WHERE {column} = ? LIMIT 1", (value,)).fetchone() is not None
|
|
343
|
+
else:
|
|
344
|
+
return self.conn.execute("SELECT 1 FROM data WHERE json_extract(payload, ?) = ? LIMIT 1", (f'$.{key}', value)).fetchone() is not None
|
|
345
|
+
|
|
346
|
+
def count(self) -> int:
|
|
347
|
+
return self.conn.execute("SELECT COUNT(*) FROM data").fetchone()[0]
|
|
348
|
+
|
|
349
|
+
def count_where(self, key: str, op: str, value: Any) -> int:
|
|
350
|
+
op_sql = op.upper()
|
|
351
|
+
val = value if '%' not in str(value) else value.replace('*', '%')
|
|
352
|
+
if op_sql == 'LIKE':
|
|
353
|
+
val = val.replace('*', '%')
|
|
354
|
+
else:
|
|
355
|
+
val = value
|
|
356
|
+
column = key if key in self.GENERATED_COLUMNS else None
|
|
357
|
+
if column:
|
|
358
|
+
sql = f"SELECT COUNT(*) FROM data WHERE {column} {op_sql} ?"
|
|
359
|
+
else:
|
|
360
|
+
sql = f"SELECT COUNT(*) FROM data WHERE json_extract(payload, '$.{key}') {op_sql} ?"
|
|
361
|
+
return self.conn.execute(sql, (val,)).fetchone()[0]
|
|
362
|
+
|
|
363
|
+
def fetch_page(
|
|
364
|
+
self,
|
|
365
|
+
limit: int,
|
|
366
|
+
offset: int,
|
|
367
|
+
key: Optional[str] = None,
|
|
368
|
+
op: str = '=',
|
|
369
|
+
value: Any = None,
|
|
370
|
+
sort_by: str = 'id ASC'
|
|
371
|
+
) -> List[Dict[str, Any]]:
|
|
372
|
+
where_clause = ""
|
|
373
|
+
params = (limit, offset)
|
|
374
|
+
if key and value is not None:
|
|
375
|
+
op_sql = op.upper()
|
|
376
|
+
val = value if '%' not in str(value) else value.replace('*', '%')
|
|
377
|
+
if op_sql == 'LIKE':
|
|
378
|
+
val = val.replace('*', '%')
|
|
379
|
+
else:
|
|
380
|
+
val = value
|
|
381
|
+
column = key if key in self.GENERATED_COLUMNS else None
|
|
382
|
+
if column:
|
|
383
|
+
where_clause = f"WHERE {column} {op_sql} ? "
|
|
384
|
+
else:
|
|
385
|
+
where_clause = f"WHERE json_extract(payload,'$.{key}') {op_sql} ? "
|
|
386
|
+
params = (val, *params)
|
|
387
|
+
sql = f"SELECT payload FROM data {where_clause}ORDER BY {sort_by} LIMIT ? OFFSET ?"
|
|
388
|
+
cur = self.conn.execute(sql, params)
|
|
389
|
+
return [self._deserialize(row['payload']) for row in cur]
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def version(self) -> int:
|
|
393
|
+
return self.conn.execute("PRAGMA user_version").fetchone()[0]
|
|
394
|
+
|
|
395
|
+
@version.setter
|
|
396
|
+
def version(self, version: int) -> None:
|
|
397
|
+
self.conn.execute(f"PRAGMA user_version = {version}")
|
|
398
|
+
|
|
399
|
+
def migrate(self, migration_sql: str) -> None:
|
|
400
|
+
self.conn.executescript(migration_sql)
|
|
401
|
+
# Neu: Auto-Regenerate Indizes nach Migration
|
|
402
|
+
for idx, unique in self.list_indices():
|
|
403
|
+
if idx.startswith('idx_'):
|
|
404
|
+
key = idx[len('idx_'):] # nur das 'idx_'-Präfix entfernen
|
|
405
|
+
column = key if key in self.GENERATED_COLUMNS else None
|
|
406
|
+
self.regenerate_index(key, unique, column=column)
|
|
407
|
+
|
|
408
|
+
def backup(self, dest_path: str) -> None:
|
|
409
|
+
with sqlite3.connect(dest_path) as dest:
|
|
410
|
+
self.conn.backup(dest)
|
|
411
|
+
|
|
412
|
+
def restore(self, src_path: str) -> None:
|
|
413
|
+
self.conn.close()
|
|
414
|
+
shutil.copy(src_path, self.filename)
|
|
415
|
+
self._init_conn()
|
|
416
|
+
|
|
417
|
+
def execute_raw(self, sql: str, params: Optional[Tuple[Any, ...]] = None) -> sqlite3.Cursor:
|
|
418
|
+
return self.conn.execute(sql, params or ())
|
|
419
|
+
|
|
420
|
+
@contextmanager
|
|
421
|
+
def transaction(self) -> Iterator[None]:
|
|
422
|
+
self.conn.execute("BEGIN")
|
|
423
|
+
try:
|
|
424
|
+
yield
|
|
425
|
+
self.conn.execute("COMMIT")
|
|
426
|
+
except Exception:
|
|
427
|
+
self.conn.execute("ROLLBACK")
|
|
428
|
+
raise
|
|
429
|
+
|
|
430
|
+
def delete_expired(self, created_before: Union[datetime, str]) -> int:
|
|
431
|
+
ts = created_before if isinstance(created_before, str) else created_before.strftime('%Y-%m-%dT%H:%M:%fZ')
|
|
432
|
+
cur = self.conn.execute("DELETE FROM data WHERE created_at < ?", (ts,))
|
|
433
|
+
if 'on_delete_expired' in self.hooks:
|
|
434
|
+
self.hooks['on_delete_expired'](cur.rowcount)
|
|
435
|
+
return cur.rowcount
|
|
436
|
+
|
|
437
|
+
def extract(self, record_id: int, json_path: str) -> Any:
|
|
438
|
+
return self.conn.execute(
|
|
439
|
+
"SELECT json_extract(payload, ?) FROM data WHERE id = ?", (json_path, record_id)
|
|
440
|
+
).fetchone()[0]
|
|
441
|
+
|
|
442
|
+
def set_path(self, record_id: int, json_path: str, value: Any) -> None:
|
|
443
|
+
val = json.dumps(value, separators=(',', ':'))
|
|
444
|
+
self.conn.execute(
|
|
445
|
+
"UPDATE data SET payload = json_set(payload, ?, json(?)) WHERE id = ?",
|
|
446
|
+
(json_path, val, record_id)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def insert_path(self, record_id: int, json_path: str, value: Any) -> None:
|
|
450
|
+
val = json.dumps(value, separators=(',', ':'))
|
|
451
|
+
self.conn.execute(
|
|
452
|
+
"UPDATE data SET payload = json_insert(payload, ?, json(?)) WHERE id = ?",
|
|
453
|
+
(json_path, val, record_id)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def replace_path(self, record_id: int, json_path: str, value: Any) -> None:
|
|
457
|
+
val = json.dumps(value, separators=(',', ':'))
|
|
458
|
+
self.conn.execute(
|
|
459
|
+
"UPDATE data SET payload = json_replace(payload, ?, json(?)) WHERE id = ?",
|
|
460
|
+
(json_path, val, record_id)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def remove_path(self, record_id: int, *json_paths: str) -> None:
|
|
464
|
+
params = (*json_paths, record_id)
|
|
465
|
+
placeholders = ', '.join('?' for _ in json_paths)
|
|
466
|
+
sql = f"UPDATE data SET payload = json_remove(payload, {placeholders}) WHERE id = ?"
|
|
467
|
+
self.conn.execute(sql, params)
|
|
468
|
+
|
|
469
|
+
def array_length(self, record_id: int, json_path: str) -> int:
|
|
470
|
+
return self.conn.execute(
|
|
471
|
+
"SELECT json_array_length(payload, ?) FROM data WHERE id = ?", (json_path, record_id)
|
|
472
|
+
).fetchone()[0] or 0
|
|
473
|
+
|
|
474
|
+
def each(self, record_id: int, json_path: str) -> List[Dict[str, Any]]:
|
|
475
|
+
cur = self.conn.execute(
|
|
476
|
+
"SELECT key, value FROM data, json_each(payload, ?) WHERE data.id = ?",
|
|
477
|
+
(json_path, record_id)
|
|
478
|
+
)
|
|
479
|
+
return [dict(row) for row in cur]
|
|
480
|
+
|
|
481
|
+
def tree(self, record_id: int) -> List[Dict[str, Any]]:
|
|
482
|
+
cur = self.conn.execute(
|
|
483
|
+
"SELECT fullkey AS path, key, value FROM data, json_tree(payload) WHERE data.id = ?",
|
|
484
|
+
(record_id,)
|
|
485
|
+
)
|
|
486
|
+
return [dict(row) for row in cur]
|
|
487
|
+
|
|
488
|
+
def find_by(self, attribute: str, value: Any, op: str = '=', limit: Optional[int] = None, offset: int = 0) -> List[Dict[str, Any]]:
|
|
489
|
+
"""
|
|
490
|
+
Einfacher Zugriff auf Elemente basierend auf Attribut (z.B. '_sdata_class').
|
|
491
|
+
Nutzt Indizes und generated columns, wenn vorhanden.
|
|
492
|
+
|
|
493
|
+
:param attribute: Attribut-Key (z.B. '_sdata_class')
|
|
494
|
+
:param value: Wert zum Matchen
|
|
495
|
+
:param op: Operator ('=', '>', '<', 'LIKE' etc.)
|
|
496
|
+
:param limit: Max. Ergebnisse (None = alle)
|
|
497
|
+
:param offset: Offset für Pagination
|
|
498
|
+
:return: Liste von passenden Dicts
|
|
499
|
+
|
|
500
|
+
Example:
|
|
501
|
+
classes = store.find_by('_sdata_class', 'MyClass') # Alle mit _sdata_class == 'MyClass'
|
|
502
|
+
names_like = store.find_by('_sdata_name', 'alice*', op='LIKE', limit=10) # Wildcard-Suche
|
|
503
|
+
parents = store.find_by('_sdata_parent', 42, op='>') # Numerische Vergleiche
|
|
504
|
+
"""
|
|
505
|
+
column = attribute if attribute in self.GENERATED_COLUMNS else None
|
|
506
|
+
if column:
|
|
507
|
+
return self._fetch_page_optimized(limit or 999999, offset, column, op, value)
|
|
508
|
+
else:
|
|
509
|
+
warnings.warn(f"Attribute '{attribute}' not optimized with generated column; using json_extract.")
|
|
510
|
+
return self.fetch_page(limit or 999999, offset, key=attribute, op=op, value=value)
|
|
511
|
+
|
|
512
|
+
def _fetch_page_optimized(self, limit: int, offset: int, column: str, op: str = '=', value: Any = None, sort_by: str = 'id ASC') -> List[Dict[str, Any]]:
|
|
513
|
+
where_clause = ""
|
|
514
|
+
params = (limit, offset)
|
|
515
|
+
if value is not None:
|
|
516
|
+
op_sql = op.upper()
|
|
517
|
+
val = value if '%' not in str(value) else value.replace('*', '%')
|
|
518
|
+
if op_sql == 'LIKE':
|
|
519
|
+
val = val.replace('*', '%')
|
|
520
|
+
else:
|
|
521
|
+
val = value
|
|
522
|
+
where_clause = f"WHERE {column} {op_sql} ? "
|
|
523
|
+
params = (val, *params)
|
|
524
|
+
sql = f"SELECT payload FROM data {where_clause}ORDER BY {sort_by} LIMIT ? OFFSET ?"
|
|
525
|
+
cur = self.conn.execute(sql, params)
|
|
526
|
+
return [self._deserialize(row['payload']) for row in cur]
|
|
527
|
+
|
|
528
|
+
def __enter__(self) -> 'JSON1SQLiteStore':
|
|
529
|
+
return self
|
|
530
|
+
|
|
531
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
532
|
+
self.conn.close()
|
|
533
|
+
|
|
534
|
+
def get_index_df(self, class_name=None, project=None, parent=None):
|
|
535
|
+
cols = ["id"]
|
|
536
|
+
cols.extend(self.get_indices_columns())
|
|
537
|
+
cols_string = ", ".join(cols)
|
|
538
|
+
# query = "SELECT * FROM data"
|
|
539
|
+
query = f"SELECT {cols_string} FROM data"
|
|
540
|
+
filter = []
|
|
541
|
+
if class_name:
|
|
542
|
+
filter.append(f"_sdata_class = '{class_name}'")
|
|
543
|
+
if project:
|
|
544
|
+
filter.append(f"_sdata_project_sname = '{project}'")
|
|
545
|
+
if parent:
|
|
546
|
+
filter.append(f"_sdata_parent_sname = '{parent}'")
|
|
547
|
+
|
|
548
|
+
if filter:
|
|
549
|
+
filter_str = " AND ".join(filter)
|
|
550
|
+
query += f" WHERE {filter_str}"
|
|
551
|
+
|
|
552
|
+
df = pd.read_sql_query(query, self.conn)
|
|
553
|
+
df.set_index("id", inplace=True)
|
|
554
|
+
return df
|