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.
Files changed (98) hide show
  1. sdata/__init__.py +140 -0
  2. sdata/base.py +684 -0
  3. sdata/contrib/__init__.py +0 -0
  4. sdata/contrib/piexif/__init__.py +12 -0
  5. sdata/contrib/piexif/_common.py +94 -0
  6. sdata/contrib/piexif/_dump.py +346 -0
  7. sdata/contrib/piexif/_exceptions.py +2 -0
  8. sdata/contrib/piexif/_exif.py +638 -0
  9. sdata/contrib/piexif/_insert.py +60 -0
  10. sdata/contrib/piexif/_load.py +270 -0
  11. sdata/contrib/piexif/_remove.py +56 -0
  12. sdata/contrib/piexif/_transplant.py +45 -0
  13. sdata/contrib/piexif/_webp.py +240 -0
  14. sdata/contrib/piexif/helper.py +66 -0
  15. sdata/contrib/ranger/__init__.py +8 -0
  16. sdata/contrib/ranger/collections/__init__.py +0 -0
  17. sdata/contrib/ranger/collections/rangebucketmap.py +346 -0
  18. sdata/contrib/ranger/collections/rangemap.py +396 -0
  19. sdata/contrib/ranger/collections/rangeset.py +483 -0
  20. sdata/contrib/ranger/range/__init__.py +0 -0
  21. sdata/contrib/ranger/range/cut.py +285 -0
  22. sdata/contrib/ranger/range/range.py +644 -0
  23. sdata/contrib/sobol_seq.py +394 -0
  24. sdata/contrib/sortedcontainers/LICENSE +13 -0
  25. sdata/contrib/sortedcontainers/__init__.py +74 -0
  26. sdata/contrib/sortedcontainers/sorteddict.py +789 -0
  27. sdata/contrib/sortedcontainers/sortedlist.py +2646 -0
  28. sdata/contrib/sortedcontainers/sortedset.py +733 -0
  29. sdata/contrib/sqlitedict.py +598 -0
  30. sdata/contrib/timeflake/__init__.py +40 -0
  31. sdata/contrib/timeflake/flake.py +69 -0
  32. sdata/contrib/timeflake/utils.py +47 -0
  33. sdata/deprecated/__init__.py +10 -0
  34. sdata/deprecated/data.py +1503 -0
  35. sdata/did/__init__.py +129 -0
  36. sdata/did/_http.py +67 -0
  37. sdata/did/base58btc.py +49 -0
  38. sdata/did/did_github.py +152 -0
  39. sdata/did/did_peer4.py +155 -0
  40. sdata/did/did_web.py +222 -0
  41. sdata/did/diddoc.py +75 -0
  42. sdata/did/ed25519.py +225 -0
  43. sdata/did/eddsa.py +107 -0
  44. sdata/did/errors.py +40 -0
  45. sdata/did/issue_vc.py +193 -0
  46. sdata/did/jose.py +126 -0
  47. sdata/did/keys.py +104 -0
  48. sdata/did/utils_didkey.py +161 -0
  49. sdata/did/verify_vp.py +158 -0
  50. sdata/doe.py +101 -0
  51. sdata/dtypes.py +510 -0
  52. sdata/experiments/__init__.py +331 -0
  53. sdata/experiments/blob0.py +131 -0
  54. sdata/experiments/file.py +42 -0
  55. sdata/experiments/filegroup.py +144 -0
  56. sdata/experiments/image_old.py +313 -0
  57. sdata/experiments/ks2.py +15 -0
  58. sdata/experiments/lapshear.py +8 -0
  59. sdata/experiments/material.py +7 -0
  60. sdata/experiments/parameters_deprecated.py +394 -0
  61. sdata/experiments/workbook.py +226 -0
  62. sdata/format.py +125 -0
  63. sdata/imagemeta.py +533 -0
  64. sdata/interactive.py +118 -0
  65. sdata/iolib/__init__.py +51 -0
  66. sdata/iolib/hashtree.py +264 -0
  67. sdata/iolib/json1sqlitestore.py +554 -0
  68. sdata/iolib/jsonsqlitestore.py +704 -0
  69. sdata/iolib/pud.py +242 -0
  70. sdata/iolib/reader.py +262 -0
  71. sdata/iolib/writer.py +419 -0
  72. sdata/metadata.py +882 -0
  73. sdata/parameter.py +414 -0
  74. sdata/schema.py +273 -0
  75. sdata/sclass/__init__.py +111 -0
  76. sdata/sclass/bfo_classes.py +362 -0
  77. sdata/sclass/blob.py +362 -0
  78. sdata/sclass/content.py +87 -0
  79. sdata/sclass/dataframe.py +1251 -0
  80. sdata/sclass/dataframegroup.py +187 -0
  81. sdata/sclass/dependency_graph.py +85 -0
  82. sdata/sclass/filereference.py +124 -0
  83. sdata/sclass/image.py +218 -0
  84. sdata/sclass/lazy_namespace.py +155 -0
  85. sdata/sclass/process.py +383 -0
  86. sdata/sclass/process_graph.py +221 -0
  87. sdata/sclass/zipfile.py +135 -0
  88. sdata/semantic.py +346 -0
  89. sdata/suuid.py +136 -0
  90. sdata/timestamp.py +336 -0
  91. sdata/tools.py +14 -0
  92. sdata/units.py +602 -0
  93. sdata/vocab.py +149 -0
  94. sdata-1.5.0.dist-info/METADATA +264 -0
  95. sdata-1.5.0.dist-info/RECORD +98 -0
  96. sdata-1.5.0.dist-info/WHEEL +5 -0
  97. sdata-1.5.0.dist-info/licenses/LICENSE-MIT +21 -0
  98. 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