sciduckdb 0.1.0__tar.gz → 0.1.9.dev0__tar.gz
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.
- {sciduckdb-0.1.0 → sciduckdb-0.1.9.dev0}/.gitignore +1 -0
- {sciduckdb-0.1.0 → sciduckdb-0.1.9.dev0}/PKG-INFO +1 -1
- {sciduckdb-0.1.0 → sciduckdb-0.1.9.dev0}/pyproject.toml +6 -2
- {sciduckdb-0.1.0 → sciduckdb-0.1.9.dev0}/src/sciduckdb/__init__.py +11 -5
- {sciduckdb-0.1.0 → sciduckdb-0.1.9.dev0}/src/sciduckdb/sciduckdb.py +314 -100
- {sciduckdb-0.1.0 → sciduckdb-0.1.9.dev0}/README.md +0 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
3
|
build-backend = "hatchling.build"
|
|
4
4
|
|
|
5
|
+
[tool.hatch.version]
|
|
6
|
+
source = "vcs"
|
|
7
|
+
raw-options = { search_parent_directories = true, local_scheme = "no-local-version" }
|
|
8
|
+
|
|
5
9
|
[project]
|
|
6
10
|
name = "sciduckdb"
|
|
7
|
-
|
|
11
|
+
dynamic = ["version"]
|
|
8
12
|
description = "A thin DuckDB layer for managing versioned scientific data"
|
|
9
13
|
readme = "README.md"
|
|
10
14
|
license = "MIT"
|
|
@@ -2,27 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
from .sciduckdb import (
|
|
4
4
|
SciDuck,
|
|
5
|
+
_bulk_df_to_storage_rows,
|
|
6
|
+
_dataframe_to_storage_rows,
|
|
7
|
+
_flatten_dict,
|
|
8
|
+
_infer_data_columns,
|
|
5
9
|
_infer_duckdb_type,
|
|
6
10
|
_numpy_dtype_to_duckdb,
|
|
7
11
|
_python_to_storage,
|
|
12
|
+
_record_schema_mismatch,
|
|
13
|
+
_storage_signature,
|
|
8
14
|
_storage_to_python,
|
|
9
15
|
_storage_to_python_column,
|
|
10
|
-
_infer_data_columns,
|
|
11
|
-
_value_to_storage_row,
|
|
12
|
-
_dataframe_to_storage_rows,
|
|
13
|
-
_bulk_df_to_storage_rows,
|
|
14
|
-
_flatten_dict,
|
|
15
16
|
_unflatten_dict,
|
|
17
|
+
_value_to_storage_row,
|
|
18
|
+
schema_keys_from_db,
|
|
16
19
|
)
|
|
17
20
|
|
|
18
21
|
__all__ = [
|
|
19
22
|
"SciDuck",
|
|
23
|
+
"schema_keys_from_db",
|
|
20
24
|
"_infer_duckdb_type",
|
|
21
25
|
"_numpy_dtype_to_duckdb",
|
|
22
26
|
"_python_to_storage",
|
|
23
27
|
"_storage_to_python",
|
|
24
28
|
"_storage_to_python_column",
|
|
25
29
|
"_infer_data_columns",
|
|
30
|
+
"_record_schema_mismatch",
|
|
31
|
+
"_storage_signature",
|
|
26
32
|
"_value_to_storage_row",
|
|
27
33
|
"_dataframe_to_storage_rows",
|
|
28
34
|
"_bulk_df_to_storage_rows",
|
|
@@ -11,15 +11,16 @@ nested LIST, JSON) so the database can be inspected with DBeaver or any
|
|
|
11
11
|
DuckDB-compatible viewer.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
import duckdb
|
|
15
|
-
import logging
|
|
16
|
-
import pandas as pd
|
|
17
|
-
import numpy as np
|
|
18
|
-
import json
|
|
19
14
|
import datetime
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
20
17
|
import threading
|
|
21
18
|
from pathlib import Path
|
|
22
|
-
from typing import Any
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import duckdb
|
|
22
|
+
import numpy as np
|
|
23
|
+
import pandas as pd
|
|
23
24
|
|
|
24
25
|
logger = logging.getLogger("sciduck")
|
|
25
26
|
|
|
@@ -40,6 +41,7 @@ def _schema_str(value):
|
|
|
40
41
|
# Type mapping helpers
|
|
41
42
|
# ---------------------------------------------------------------------------
|
|
42
43
|
|
|
44
|
+
|
|
43
45
|
def _numpy_dtype_to_duckdb(dtype: np.dtype) -> str:
|
|
44
46
|
"""Map a numpy scalar dtype to a DuckDB type string."""
|
|
45
47
|
kind = dtype.kind
|
|
@@ -63,7 +65,7 @@ def _numpy_dtype_to_duckdb(dtype: np.dtype) -> str:
|
|
|
63
65
|
return "VARCHAR"
|
|
64
66
|
|
|
65
67
|
|
|
66
|
-
def _infer_duckdb_type(value: Any) ->
|
|
68
|
+
def _infer_duckdb_type(value: Any) -> tuple[str, dict]:
|
|
67
69
|
"""
|
|
68
70
|
Infer the DuckDB column type and a metadata dict for round-trip
|
|
69
71
|
restoration from a single Python/numpy value.
|
|
@@ -197,6 +199,17 @@ def _python_to_storage(value: Any, meta: dict) -> Any:
|
|
|
197
199
|
"""Convert a Python value to its DuckDB-storable form."""
|
|
198
200
|
ptype = meta.get("python_type", "")
|
|
199
201
|
|
|
202
|
+
# _infer_data_columns unwraps length-1 arrays to scalars when it picks the
|
|
203
|
+
# column type, e.g. {"x": np.array([1.0])} -> DOUBLE column with
|
|
204
|
+
# python_type="float". Mirror that unwrap here so the stored value is a
|
|
205
|
+
# scalar matching the column; otherwise the row carries a DOUBLE[] into a
|
|
206
|
+
# DOUBLE column and DuckDB rejects the cast (DOUBLE[] -> DOUBLE).
|
|
207
|
+
if ptype in ("float", "int", "bool", "str"):
|
|
208
|
+
if isinstance(value, np.ndarray) and value.size == 1:
|
|
209
|
+
value = value.item()
|
|
210
|
+
elif isinstance(value, np.generic):
|
|
211
|
+
value = value.item()
|
|
212
|
+
|
|
200
213
|
if ptype == "ndarray":
|
|
201
214
|
arr = value
|
|
202
215
|
# Scalar in a column typed as ndarray (e.g. ragged vectors): wrap as 1-element list
|
|
@@ -345,9 +358,10 @@ def _unflatten_dict(flat, path_map):
|
|
|
345
358
|
# DatabaseManager)
|
|
346
359
|
# ---------------------------------------------------------------------------
|
|
347
360
|
|
|
361
|
+
|
|
348
362
|
def _infer_data_columns(
|
|
349
|
-
sample_value: Any, data_col_name:
|
|
350
|
-
) ->
|
|
363
|
+
sample_value: Any, data_col_name: str | None = None
|
|
364
|
+
) -> tuple[dict, dict]:
|
|
351
365
|
"""
|
|
352
366
|
From a sample data value, return:
|
|
353
367
|
- data_col_types: dict of {col_name: duckdb_type_str}
|
|
@@ -410,6 +424,93 @@ def _infer_data_columns(
|
|
|
410
424
|
return {col_name: ddb_type}, meta
|
|
411
425
|
|
|
412
426
|
|
|
427
|
+
# DuckDB type categories used by _storage_signature. Records can only share a
|
|
428
|
+
# column if their values reduce to the same (category, array-depth) signature;
|
|
429
|
+
# this is intentionally coarse so benign coercions (BIGINT -> DOUBLE) are
|
|
430
|
+
# allowed while shape changes (scalar vs vector) and category changes
|
|
431
|
+
# (DOUBLE vs VARCHAR) are rejected.
|
|
432
|
+
_NUMERIC_DDB_TYPES = {
|
|
433
|
+
"BOOLEAN",
|
|
434
|
+
"TINYINT",
|
|
435
|
+
"SMALLINT",
|
|
436
|
+
"INTEGER",
|
|
437
|
+
"BIGINT",
|
|
438
|
+
"HUGEINT",
|
|
439
|
+
"UTINYINT",
|
|
440
|
+
"USMALLINT",
|
|
441
|
+
"UINTEGER",
|
|
442
|
+
"UBIGINT",
|
|
443
|
+
"FLOAT",
|
|
444
|
+
"REAL",
|
|
445
|
+
"DOUBLE",
|
|
446
|
+
}
|
|
447
|
+
_TEMPORAL_DDB_TYPES = {"DATE", "TIME", "TIMESTAMP", "INTERVAL"}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _storage_signature(ddb_type: str) -> tuple[str, int]:
|
|
451
|
+
"""Reduce a DuckDB column type string to (base_category, array_depth).
|
|
452
|
+
|
|
453
|
+
array_depth counts trailing ``[]`` (0 = scalar, 1 = vector, 2 = matrix).
|
|
454
|
+
base_category is a coarse bucket so that, e.g., ``BIGINT`` and ``DOUBLE``
|
|
455
|
+
are both ``numeric`` (DuckDB coerces them into one column) but ``DOUBLE``
|
|
456
|
+
and ``VARCHAR`` differ. This is the unit of comparison for deciding
|
|
457
|
+
whether two records can be stored in the same batch column.
|
|
458
|
+
"""
|
|
459
|
+
t = ddb_type.strip().upper()
|
|
460
|
+
depth = 0
|
|
461
|
+
while t.endswith("[]"):
|
|
462
|
+
depth += 1
|
|
463
|
+
t = t[:-2].strip()
|
|
464
|
+
if t in _NUMERIC_DDB_TYPES or t.startswith("DECIMAL"):
|
|
465
|
+
category = "numeric"
|
|
466
|
+
elif t == "VARCHAR":
|
|
467
|
+
category = "string"
|
|
468
|
+
elif t == "JSON":
|
|
469
|
+
category = "json"
|
|
470
|
+
elif t in _TEMPORAL_DDB_TYPES:
|
|
471
|
+
category = "temporal"
|
|
472
|
+
else:
|
|
473
|
+
category = t
|
|
474
|
+
return (category, depth)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _record_schema_mismatch(ref_col_types: dict, rec_col_types: dict) -> str | None:
|
|
478
|
+
"""Return a human-readable reason a record can't join the batch, or None.
|
|
479
|
+
|
|
480
|
+
A record "fits" the batch schema when it has exactly the reference column
|
|
481
|
+
set and every column's storage signature matches. This is the predicate
|
|
482
|
+
used by save_batch to skip (with a warning) records that would otherwise
|
|
483
|
+
abort the atomic batch insert:
|
|
484
|
+
|
|
485
|
+
* empty/partial dicts -> missing keys
|
|
486
|
+
* unexpected dict keys -> extra keys
|
|
487
|
+
* a scalar where the column stores a vector (or vice versa),
|
|
488
|
+
or a string where the column is numeric -> signature mismatch
|
|
489
|
+
"""
|
|
490
|
+
ref_keys = set(ref_col_types)
|
|
491
|
+
rec_keys = set(rec_col_types)
|
|
492
|
+
if ref_keys != rec_keys:
|
|
493
|
+
missing = sorted(ref_keys - rec_keys)
|
|
494
|
+
extra = sorted(rec_keys - ref_keys)
|
|
495
|
+
parts = []
|
|
496
|
+
if missing:
|
|
497
|
+
parts.append(f"missing keys {missing}")
|
|
498
|
+
if extra:
|
|
499
|
+
parts.append(f"unexpected keys {extra}")
|
|
500
|
+
if not parts:
|
|
501
|
+
parts.append("key set differs from batch schema")
|
|
502
|
+
return "; ".join(parts)
|
|
503
|
+
for col in ref_col_types:
|
|
504
|
+
ref_sig = _storage_signature(ref_col_types[col])
|
|
505
|
+
rec_sig = _storage_signature(rec_col_types[col])
|
|
506
|
+
if ref_sig != rec_sig:
|
|
507
|
+
return (
|
|
508
|
+
f"column '{col}' shape/type mismatch: batch column is "
|
|
509
|
+
f"{ref_col_types[col]} but record value is {rec_col_types[col]}"
|
|
510
|
+
)
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
|
|
413
514
|
def _dataframe_to_storage_rows(df: pd.DataFrame, dtype_meta: dict) -> list:
|
|
414
515
|
"""Convert a DataFrame to a list of per-row storage values.
|
|
415
516
|
|
|
@@ -433,9 +534,7 @@ def _dataframe_to_storage_rows(df: pd.DataFrame, dtype_meta: dict) -> list:
|
|
|
433
534
|
return rows
|
|
434
535
|
|
|
435
536
|
|
|
436
|
-
def _bulk_df_to_storage_rows(
|
|
437
|
-
df_list: list, record_ids: list, dtype_meta: dict
|
|
438
|
-
) -> list:
|
|
537
|
+
def _bulk_df_to_storage_rows(df_list: list, record_ids: list, dtype_meta: dict) -> list:
|
|
439
538
|
"""Bulk convert N DataFrames to (record_id, ...storage_values) rows.
|
|
440
539
|
|
|
441
540
|
Equivalent to calling _dataframe_to_storage_rows N times and assembling
|
|
@@ -453,7 +552,7 @@ def _bulk_df_to_storage_rows(
|
|
|
453
552
|
# Fall back to per-row path if schemas differ (shouldn't happen in normal use).
|
|
454
553
|
if not all(list(df.columns) == first_cols for df in df_list):
|
|
455
554
|
rows: list = []
|
|
456
|
-
for rid, df in zip(record_ids, df_list):
|
|
555
|
+
for rid, df in zip(record_ids, df_list, strict=False):
|
|
457
556
|
for storage_row in _dataframe_to_storage_rows(df, dtype_meta):
|
|
458
557
|
rows.append((rid,) + tuple(storage_row))
|
|
459
558
|
return rows
|
|
@@ -461,7 +560,7 @@ def _bulk_df_to_storage_rows(
|
|
|
461
560
|
# Build flat record_id list: one entry per storage row (multi-row records
|
|
462
561
|
# contribute len(df) entries, typical 1-row records contribute 1).
|
|
463
562
|
expanded_rids = []
|
|
464
|
-
for rid, df in zip(record_ids, df_list):
|
|
563
|
+
for rid, df in zip(record_ids, df_list, strict=False):
|
|
465
564
|
expanded_rids.extend([rid] * len(df))
|
|
466
565
|
|
|
467
566
|
# Concat once so column operations don't cross DataFrame boundaries.
|
|
@@ -476,7 +575,8 @@ def _bulk_df_to_storage_rows(
|
|
|
476
575
|
if ptype == "ndarray":
|
|
477
576
|
vals = raw.to_numpy()
|
|
478
577
|
col_arrays[col] = [
|
|
479
|
-
v.tolist()
|
|
578
|
+
v.tolist()
|
|
579
|
+
if isinstance(v, np.ndarray)
|
|
480
580
|
else (v if isinstance(v, list) else [v])
|
|
481
581
|
for v in vals
|
|
482
582
|
]
|
|
@@ -516,10 +616,7 @@ def _value_to_storage_row(value: Any, dtype_meta: dict) -> list:
|
|
|
516
616
|
flat, _ = _flatten_dict(value)
|
|
517
617
|
else:
|
|
518
618
|
flat = value
|
|
519
|
-
return [
|
|
520
|
-
_python_to_storage(flat[col], col_metas[col])
|
|
521
|
-
for col in col_metas
|
|
522
|
-
]
|
|
619
|
+
return [_python_to_storage(flat[col], col_metas[col]) for col in col_metas]
|
|
523
620
|
else:
|
|
524
621
|
# Single column — get the one key (could be "value" or a named column)
|
|
525
622
|
col_name = next(iter(col_metas))
|
|
@@ -531,6 +628,43 @@ def _value_to_storage_row(value: Any, dtype_meta: dict) -> list:
|
|
|
531
628
|
# Main class
|
|
532
629
|
# ---------------------------------------------------------------------------
|
|
533
630
|
|
|
631
|
+
|
|
632
|
+
def schema_keys_from_db(db_path: str | Path) -> list[str]:
|
|
633
|
+
"""Read the dataset schema keys stored in an existing database.
|
|
634
|
+
|
|
635
|
+
Opens ``db_path`` read-only, reads the ``_schema`` table's key columns
|
|
636
|
+
(everything except ``schema_id``/``schema_level``, in ordinal order), and
|
|
637
|
+
closes the connection. This lets tools open a database without knowing
|
|
638
|
+
its schema keys in advance (e.g. the ``scidb`` CLI).
|
|
639
|
+
|
|
640
|
+
Raises ValueError if the file has no ``_schema`` table (not a
|
|
641
|
+
scidb/sciduckdb database) and whatever duckdb raises if the file does not
|
|
642
|
+
exist or is locked.
|
|
643
|
+
"""
|
|
644
|
+
con = duckdb.connect(str(db_path), read_only=True)
|
|
645
|
+
try:
|
|
646
|
+
rows = con.execute(
|
|
647
|
+
"SELECT column_name FROM information_schema.columns "
|
|
648
|
+
"WHERE table_name = '_schema' "
|
|
649
|
+
"AND column_name NOT IN ('schema_id', 'schema_level') "
|
|
650
|
+
"ORDER BY ordinal_position"
|
|
651
|
+
).fetchall()
|
|
652
|
+
has_table = (
|
|
653
|
+
con.execute(
|
|
654
|
+
"SELECT COUNT(*) FROM information_schema.tables "
|
|
655
|
+
"WHERE table_name = '_schema'"
|
|
656
|
+
).fetchall()[0][0]
|
|
657
|
+
> 0
|
|
658
|
+
)
|
|
659
|
+
finally:
|
|
660
|
+
con.close()
|
|
661
|
+
if not has_table:
|
|
662
|
+
raise ValueError(
|
|
663
|
+
f"{db_path} has no _schema table — not a scidb/sciduckdb database"
|
|
664
|
+
)
|
|
665
|
+
return [r[0] for r in rows]
|
|
666
|
+
|
|
667
|
+
|
|
534
668
|
class SciDuck:
|
|
535
669
|
"""
|
|
536
670
|
A thin DuckDB layer for managing versioned, schema-aware scientific data.
|
|
@@ -541,15 +675,28 @@ class SciDuck:
|
|
|
541
675
|
Path to the DuckDB database file. Use ":memory:" for in-memory.
|
|
542
676
|
dataset_schema : list of str
|
|
543
677
|
Ordered hierarchy, e.g. ["subject", "session", "trial"].
|
|
678
|
+
read_only : bool
|
|
679
|
+
Open the underlying DuckDB connection read-only. No DDL is executed
|
|
680
|
+
(the database must already exist); DuckDB rejects every write on the
|
|
681
|
+
connection, so a read-only SciDuck can never contend for the write
|
|
682
|
+
lock beyond DuckDB's shared read lock.
|
|
544
683
|
"""
|
|
545
684
|
|
|
546
|
-
def __init__(
|
|
685
|
+
def __init__(
|
|
686
|
+
self, db_path: str | Path, dataset_schema: list[str], read_only: bool = False
|
|
687
|
+
):
|
|
547
688
|
self.db_path = str(db_path)
|
|
548
689
|
self.dataset_schema = list(dataset_schema)
|
|
690
|
+
self.read_only = bool(read_only)
|
|
549
691
|
self._lock = threading.Lock()
|
|
550
|
-
logger.
|
|
551
|
-
|
|
552
|
-
|
|
692
|
+
logger.debug(
|
|
693
|
+
"DuckDB lock ACQUIRED (read_only=%s): %s", self.read_only, self.db_path
|
|
694
|
+
)
|
|
695
|
+
self.con = duckdb.connect(self.db_path, read_only=self.read_only)
|
|
696
|
+
if self.read_only:
|
|
697
|
+
self._validate_schema_columns()
|
|
698
|
+
else:
|
|
699
|
+
self._init_metadata_tables()
|
|
553
700
|
|
|
554
701
|
# ------------------------------------------------------------------
|
|
555
702
|
# Thin internal interface (future backend swap point)
|
|
@@ -570,6 +717,37 @@ class SciDuck:
|
|
|
570
717
|
with self._lock:
|
|
571
718
|
return self.con.executemany(sql, params_list)
|
|
572
719
|
|
|
720
|
+
def _bulk_insert(self, table: str, columns, rows, conflict_cols=None) -> None:
|
|
721
|
+
"""Insert many rows with a single vectorized ``INSERT ... SELECT``.
|
|
722
|
+
|
|
723
|
+
DuckDB's ``executemany`` re-runs the prepared single-row statement once
|
|
724
|
+
per row; against a PRIMARY-KEY / indexed table that is pathologically
|
|
725
|
+
slow (per-row index probe + maintenance), e.g. ~497s for 8k rows into
|
|
726
|
+
``_record``. This registers the rows as one DataFrame and issues a single
|
|
727
|
+
bulk insert instead (sub-second for the same data).
|
|
728
|
+
|
|
729
|
+
``rows`` is an iterable of value tuples in ``columns`` order. When
|
|
730
|
+
``conflict_cols`` is given, appends ``ON CONFLICT (...) DO NOTHING`` so
|
|
731
|
+
the insert stays idempotent. Safe to call inside an open transaction
|
|
732
|
+
(the register/insert/unregister run on the same connection).
|
|
733
|
+
"""
|
|
734
|
+
rows = list(rows)
|
|
735
|
+
if not rows:
|
|
736
|
+
return
|
|
737
|
+
columns = list(columns)
|
|
738
|
+
df = pd.DataFrame(rows, columns=columns)
|
|
739
|
+
col_str = ", ".join(f'"{c}"' for c in columns)
|
|
740
|
+
sql = f'INSERT INTO "{table}" ({col_str}) SELECT * FROM _bulk_insert_df'
|
|
741
|
+
if conflict_cols:
|
|
742
|
+
conflict_str = ", ".join(f'"{c}"' for c in conflict_cols)
|
|
743
|
+
sql += f" ON CONFLICT ({conflict_str}) DO NOTHING"
|
|
744
|
+
with self._lock:
|
|
745
|
+
self.con.register("_bulk_insert_df", df)
|
|
746
|
+
try:
|
|
747
|
+
self.con.execute(sql)
|
|
748
|
+
finally:
|
|
749
|
+
self.con.unregister("_bulk_insert_df")
|
|
750
|
+
|
|
573
751
|
def _begin(self):
|
|
574
752
|
with self._lock:
|
|
575
753
|
self.con.execute("BEGIN TRANSACTION")
|
|
@@ -600,8 +778,8 @@ class SciDuck:
|
|
|
600
778
|
|
|
601
779
|
def _table_exists(self, name: str) -> bool:
|
|
602
780
|
rows = self._fetchall(
|
|
603
|
-
"SELECT COUNT(*) FROM information_schema.tables "
|
|
604
|
-
|
|
781
|
+
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = ?",
|
|
782
|
+
[name],
|
|
605
783
|
)
|
|
606
784
|
return rows[0][0] > 0
|
|
607
785
|
|
|
@@ -647,25 +825,42 @@ class SciDuck:
|
|
|
647
825
|
|
|
648
826
|
# Validate schema consistency if _schema already has data
|
|
649
827
|
if self._fetchall("SELECT COUNT(*) FROM _schema")[0][0] > 0:
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
828
|
+
self._validate_schema_columns()
|
|
829
|
+
|
|
830
|
+
def _validate_schema_columns(self):
|
|
831
|
+
"""Check that the existing _schema columns match dataset_schema.
|
|
832
|
+
|
|
833
|
+
Read-only companion to _init_metadata_tables: runs the same
|
|
834
|
+
consistency check without any DDL, so it is safe on a read-only
|
|
835
|
+
connection (where the tables must already exist).
|
|
836
|
+
"""
|
|
837
|
+
if not self._table_exists("_schema"):
|
|
838
|
+
if self.read_only:
|
|
659
839
|
raise ValueError(
|
|
660
|
-
f"
|
|
661
|
-
f"
|
|
840
|
+
f"{self.db_path} has no _schema table — not a scidb/sciduckdb "
|
|
841
|
+
f"database (read-only open cannot create it)"
|
|
662
842
|
)
|
|
843
|
+
return
|
|
844
|
+
existing_cols = [
|
|
845
|
+
row[0]
|
|
846
|
+
for row in self._fetchall(
|
|
847
|
+
"SELECT column_name FROM information_schema.columns "
|
|
848
|
+
"WHERE table_name = '_schema' "
|
|
849
|
+
"AND column_name NOT IN ('schema_id', 'schema_level') "
|
|
850
|
+
"ORDER BY ordinal_position"
|
|
851
|
+
)
|
|
852
|
+
]
|
|
853
|
+
if existing_cols != self.dataset_schema:
|
|
854
|
+
raise ValueError(
|
|
855
|
+
f"Database schema mismatch. "
|
|
856
|
+
f"Existing: {existing_cols}, Provided: {self.dataset_schema}"
|
|
857
|
+
)
|
|
663
858
|
|
|
664
859
|
# ------------------------------------------------------------------
|
|
665
860
|
# Schema entry management
|
|
666
861
|
# ------------------------------------------------------------------
|
|
667
862
|
|
|
668
|
-
def _schema_key_columns(self, schema_level: str) ->
|
|
863
|
+
def _schema_key_columns(self, schema_level: str) -> list[str]:
|
|
669
864
|
"""Return schema columns from the top down to (and including) schema_level."""
|
|
670
865
|
idx = self.dataset_schema.index(schema_level)
|
|
671
866
|
return self.dataset_schema[: idx + 1]
|
|
@@ -688,16 +883,16 @@ class SciDuck:
|
|
|
688
883
|
|
|
689
884
|
where = " AND ".join(conditions)
|
|
690
885
|
rows = self._fetchall(
|
|
691
|
-
f
|
|
886
|
+
f"SELECT schema_id FROM _schema WHERE schema_level = ? AND {where}",
|
|
692
887
|
params,
|
|
693
888
|
)
|
|
694
889
|
if rows:
|
|
695
890
|
return rows[0][0]
|
|
696
891
|
|
|
697
892
|
# Insert new entry — use MAX+1 for consistency with batch path
|
|
698
|
-
new_id = self._fetchall(
|
|
699
|
-
|
|
700
|
-
|
|
893
|
+
new_id = self._fetchall("SELECT COALESCE(MAX(schema_id), 0) + 1 FROM _schema")[
|
|
894
|
+
0
|
|
895
|
+
][0]
|
|
701
896
|
col_names = ["schema_id", "schema_level"] + key_cols
|
|
702
897
|
placeholders = ", ".join(["?"] * len(col_names))
|
|
703
898
|
col_str = ", ".join(f'"{c}"' for c in col_names)
|
|
@@ -744,16 +939,14 @@ class SciDuck:
|
|
|
744
939
|
|
|
745
940
|
# Build a single query to find all existing matches at this level
|
|
746
941
|
# We fetch all rows for this schema_level and match in Python
|
|
747
|
-
null_conditions = " AND ".join(
|
|
748
|
-
|
|
749
|
-
)
|
|
750
|
-
where_clause = f'schema_level = ?'
|
|
942
|
+
null_conditions = " AND ".join(f'"{col}" IS NULL' for col in null_cols)
|
|
943
|
+
where_clause = "schema_level = ?"
|
|
751
944
|
if null_conditions:
|
|
752
|
-
where_clause += f
|
|
945
|
+
where_clause += f" AND {null_conditions}"
|
|
753
946
|
|
|
754
947
|
col_select = ", ".join(f'"{c}"' for c in key_cols)
|
|
755
948
|
rows = self._fetchall(
|
|
756
|
-
f
|
|
949
|
+
f"SELECT schema_id, {col_select} FROM _schema WHERE {where_clause}",
|
|
757
950
|
[schema_level],
|
|
758
951
|
)
|
|
759
952
|
|
|
@@ -761,7 +954,9 @@ class SciDuck:
|
|
|
761
954
|
existing_lookup = {}
|
|
762
955
|
for row in rows:
|
|
763
956
|
sid = row[0]
|
|
764
|
-
row_key = tuple(
|
|
957
|
+
row_key = tuple(
|
|
958
|
+
_schema_str(v) if v is not None else "" for v in row[1:]
|
|
959
|
+
)
|
|
765
960
|
existing_lookup[row_key] = sid
|
|
766
961
|
|
|
767
962
|
# Match entries against existing rows
|
|
@@ -782,7 +977,6 @@ class SciDuck:
|
|
|
782
977
|
first_id = max_row[0][0] + 1
|
|
783
978
|
|
|
784
979
|
col_names = ["schema_id", "schema_level"] + key_cols
|
|
785
|
-
col_str = ", ".join(f'"{c}"' for c in col_names)
|
|
786
980
|
|
|
787
981
|
insert_rows = []
|
|
788
982
|
for idx, (combo_key, key_values, _) in enumerate(missing):
|
|
@@ -793,11 +987,7 @@ class SciDuck:
|
|
|
793
987
|
insert_rows.append(row)
|
|
794
988
|
result[combo_key] = new_id
|
|
795
989
|
|
|
796
|
-
|
|
797
|
-
insert_df = pd.DataFrame(insert_rows, columns=col_names)
|
|
798
|
-
self.con.execute(
|
|
799
|
-
f"INSERT INTO _schema ({col_str}) SELECT * FROM insert_df"
|
|
800
|
-
)
|
|
990
|
+
self._bulk_insert("_schema", col_names, insert_rows)
|
|
801
991
|
|
|
802
992
|
return result
|
|
803
993
|
|
|
@@ -809,7 +999,7 @@ class SciDuck:
|
|
|
809
999
|
self,
|
|
810
1000
|
name: str,
|
|
811
1001
|
data: Any,
|
|
812
|
-
schema_level:
|
|
1002
|
+
schema_level: str | None = None,
|
|
813
1003
|
description: str = "",
|
|
814
1004
|
force: bool = False,
|
|
815
1005
|
**schema_keys,
|
|
@@ -844,16 +1034,22 @@ class SciDuck:
|
|
|
844
1034
|
if schema_keys:
|
|
845
1035
|
provided_schema_cols = [k for k in self.dataset_schema if k in schema_keys]
|
|
846
1036
|
if schema_level is None:
|
|
847
|
-
schema_level =
|
|
1037
|
+
schema_level = (
|
|
1038
|
+
provided_schema_cols[-1]
|
|
1039
|
+
if provided_schema_cols
|
|
1040
|
+
else self.dataset_schema[-1]
|
|
1041
|
+
)
|
|
848
1042
|
if schema_level not in self.dataset_schema:
|
|
849
1043
|
raise ValueError(
|
|
850
1044
|
f"schema_level '{schema_level}' not in {self.dataset_schema}"
|
|
851
1045
|
)
|
|
852
1046
|
key_cols = provided_schema_cols
|
|
853
|
-
entries = [
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1047
|
+
entries = [
|
|
1048
|
+
(
|
|
1049
|
+
{k: schema_keys[k] for k in key_cols},
|
|
1050
|
+
data,
|
|
1051
|
+
)
|
|
1052
|
+
]
|
|
857
1053
|
|
|
858
1054
|
else:
|
|
859
1055
|
if schema_level is None:
|
|
@@ -865,11 +1061,19 @@ class SciDuck:
|
|
|
865
1061
|
key_cols = self._schema_key_columns(schema_level)
|
|
866
1062
|
|
|
867
1063
|
# Mode A: DataFrame with schema columns
|
|
868
|
-
if isinstance(data, pd.DataFrame) and all(
|
|
869
|
-
|
|
1064
|
+
if isinstance(data, pd.DataFrame) and all(
|
|
1065
|
+
c in data.columns for c in key_cols
|
|
1066
|
+
):
|
|
1067
|
+
entries, data_col_name = self._entries_from_dataframe(
|
|
1068
|
+
data, key_cols, schema_level
|
|
1069
|
+
)
|
|
870
1070
|
|
|
871
1071
|
# Mode C: dict with tuple keys
|
|
872
|
-
elif
|
|
1072
|
+
elif (
|
|
1073
|
+
isinstance(data, dict)
|
|
1074
|
+
and data
|
|
1075
|
+
and isinstance(next(iter(data.keys())), tuple)
|
|
1076
|
+
):
|
|
873
1077
|
entries = []
|
|
874
1078
|
for key_tuple, value in data.items():
|
|
875
1079
|
if len(key_tuple) != len(key_cols):
|
|
@@ -877,7 +1081,7 @@ class SciDuck:
|
|
|
877
1081
|
f"Key tuple length {len(key_tuple)} != "
|
|
878
1082
|
f"expected {len(key_cols)} for level '{schema_level}'"
|
|
879
1083
|
)
|
|
880
|
-
key_dict = dict(zip(key_cols, key_tuple))
|
|
1084
|
+
key_dict = dict(zip(key_cols, key_tuple, strict=False))
|
|
881
1085
|
entries.append((key_dict, value))
|
|
882
1086
|
|
|
883
1087
|
else:
|
|
@@ -890,12 +1094,15 @@ class SciDuck:
|
|
|
890
1094
|
|
|
891
1095
|
# --- Determine column types from the first entry's data ---
|
|
892
1096
|
sample_value = entries[0][1]
|
|
893
|
-
data_col_types, dtype_meta = self._infer_data_columns(
|
|
1097
|
+
data_col_types, dtype_meta = self._infer_data_columns(
|
|
1098
|
+
sample_value, data_col_name
|
|
1099
|
+
)
|
|
894
1100
|
|
|
895
1101
|
# --- Ensure the variable table exists ---
|
|
896
1102
|
is_dataframe = dtype_meta.get("mode") == "dataframe"
|
|
897
|
-
self._ensure_variable_table(
|
|
898
|
-
|
|
1103
|
+
self._ensure_variable_table(
|
|
1104
|
+
name, data_col_types, schema_level, is_dataframe=is_dataframe
|
|
1105
|
+
)
|
|
899
1106
|
|
|
900
1107
|
# --- Insert rows (INSERT OR REPLACE for "latest wins" semantics) ---
|
|
901
1108
|
col_names = ["schema_id"] + list(data_col_types.keys())
|
|
@@ -916,7 +1123,8 @@ class SciDuck:
|
|
|
916
1123
|
storage_values = self._value_to_storage_row(value, dtype_meta)
|
|
917
1124
|
row = [schema_id] + storage_values
|
|
918
1125
|
self._execute(
|
|
919
|
-
f'INSERT OR REPLACE INTO "{name}" ({col_str}) VALUES ({placeholders})',
|
|
1126
|
+
f'INSERT OR REPLACE INTO "{name}" ({col_str}) VALUES ({placeholders})',
|
|
1127
|
+
row,
|
|
920
1128
|
)
|
|
921
1129
|
|
|
922
1130
|
# --- Register in _variables (one row per variable) ---
|
|
@@ -928,8 +1136,8 @@ class SciDuck:
|
|
|
928
1136
|
)
|
|
929
1137
|
|
|
930
1138
|
def _entries_from_dataframe(
|
|
931
|
-
self, df: pd.DataFrame, key_cols:
|
|
932
|
-
) ->
|
|
1139
|
+
self, df: pd.DataFrame, key_cols: list[str], schema_level: str
|
|
1140
|
+
) -> tuple[list[tuple[dict, Any]], str | None]:
|
|
933
1141
|
"""
|
|
934
1142
|
Convert a DataFrame (Mode A) into a list of (key_dict, row_data) entries.
|
|
935
1143
|
|
|
@@ -959,8 +1167,8 @@ class SciDuck:
|
|
|
959
1167
|
return entries, single_col_name
|
|
960
1168
|
|
|
961
1169
|
def _infer_data_columns(
|
|
962
|
-
self, sample_value: Any, data_col_name:
|
|
963
|
-
) ->
|
|
1170
|
+
self, sample_value: Any, data_col_name: str | None = None
|
|
1171
|
+
) -> tuple[dict, dict]:
|
|
964
1172
|
"""Delegate to module-level _infer_data_columns."""
|
|
965
1173
|
return _infer_data_columns(sample_value, data_col_name)
|
|
966
1174
|
|
|
@@ -968,8 +1176,13 @@ class SciDuck:
|
|
|
968
1176
|
"""Delegate to module-level _value_to_storage_row."""
|
|
969
1177
|
return _value_to_storage_row(value, dtype_meta)
|
|
970
1178
|
|
|
971
|
-
def _ensure_variable_table(
|
|
972
|
-
|
|
1179
|
+
def _ensure_variable_table(
|
|
1180
|
+
self,
|
|
1181
|
+
name: str,
|
|
1182
|
+
data_col_types: dict,
|
|
1183
|
+
schema_level: str,
|
|
1184
|
+
is_dataframe: bool = False,
|
|
1185
|
+
):
|
|
973
1186
|
"""Create the variable table if it doesn't exist."""
|
|
974
1187
|
if self._table_exists(name):
|
|
975
1188
|
return
|
|
@@ -999,7 +1212,7 @@ class SciDuck:
|
|
|
999
1212
|
name: str,
|
|
1000
1213
|
raw: bool = True,
|
|
1001
1214
|
**schema_keys,
|
|
1002
|
-
) ->
|
|
1215
|
+
) -> pd.DataFrame | Any:
|
|
1003
1216
|
"""
|
|
1004
1217
|
Load a variable from the database.
|
|
1005
1218
|
|
|
@@ -1037,9 +1250,9 @@ class SciDuck:
|
|
|
1037
1250
|
data_select = ", ".join(f'v."{c}"' for c in data_cols)
|
|
1038
1251
|
|
|
1039
1252
|
sql = (
|
|
1040
|
-
f
|
|
1253
|
+
f"SELECT {schema_select}, {data_select} "
|
|
1041
1254
|
f'FROM "{name}" v '
|
|
1042
|
-
f
|
|
1255
|
+
f"JOIN _schema s ON v.schema_id = s.schema_id"
|
|
1043
1256
|
)
|
|
1044
1257
|
params: list = []
|
|
1045
1258
|
|
|
@@ -1050,7 +1263,7 @@ class SciDuck:
|
|
|
1050
1263
|
conditions.append(f's."{col}" = ?')
|
|
1051
1264
|
params.append(_schema_str(val))
|
|
1052
1265
|
if conditions:
|
|
1053
|
-
sql +=
|
|
1266
|
+
sql += " WHERE " + " AND ".join(conditions)
|
|
1054
1267
|
|
|
1055
1268
|
df = self._fetchdf(sql, params or None)
|
|
1056
1269
|
|
|
@@ -1064,8 +1277,9 @@ class SciDuck:
|
|
|
1064
1277
|
result = {}
|
|
1065
1278
|
for c, meta in columns_meta.items():
|
|
1066
1279
|
if c in df.columns:
|
|
1067
|
-
result[c] = [
|
|
1068
|
-
|
|
1280
|
+
result[c] = [
|
|
1281
|
+
_storage_to_python(df[c].iloc[i], meta) for i in range(len(df))
|
|
1282
|
+
]
|
|
1069
1283
|
df_columns = dtype_meta.get("df_columns", data_cols)
|
|
1070
1284
|
return pd.DataFrame(result, columns=df_columns)
|
|
1071
1285
|
|
|
@@ -1139,18 +1353,14 @@ class SciDuck:
|
|
|
1139
1353
|
"""
|
|
1140
1354
|
if self._table_exists(name):
|
|
1141
1355
|
self._execute(f'DROP TABLE "{name}"')
|
|
1142
|
-
self._execute(
|
|
1143
|
-
|
|
1144
|
-
)
|
|
1145
|
-
self._execute(
|
|
1146
|
-
"DELETE FROM _variable_groups WHERE variable_name = ?", [name]
|
|
1147
|
-
)
|
|
1356
|
+
self._execute("DELETE FROM _variables WHERE variable_name = ?", [name])
|
|
1357
|
+
self._execute("DELETE FROM _variable_groups WHERE variable_name = ?", [name])
|
|
1148
1358
|
|
|
1149
1359
|
# ------------------------------------------------------------------
|
|
1150
1360
|
# Groups
|
|
1151
1361
|
# ------------------------------------------------------------------
|
|
1152
1362
|
|
|
1153
|
-
def add_to_group(self, group_name: str, variable_names:
|
|
1363
|
+
def add_to_group(self, group_name: str, variable_names: str | list[str]):
|
|
1154
1364
|
"""Add one or more variables to a group."""
|
|
1155
1365
|
if isinstance(variable_names, str):
|
|
1156
1366
|
variable_names = [variable_names]
|
|
@@ -1161,7 +1371,7 @@ class SciDuck:
|
|
|
1161
1371
|
[group_name, vn],
|
|
1162
1372
|
)
|
|
1163
1373
|
|
|
1164
|
-
def remove_from_group(self, group_name: str, variable_names:
|
|
1374
|
+
def remove_from_group(self, group_name: str, variable_names: str | list[str]):
|
|
1165
1375
|
"""Remove one or more variables from a group."""
|
|
1166
1376
|
if isinstance(variable_names, str):
|
|
1167
1377
|
variable_names = [variable_names]
|
|
@@ -1172,14 +1382,14 @@ class SciDuck:
|
|
|
1172
1382
|
[group_name, vn],
|
|
1173
1383
|
)
|
|
1174
1384
|
|
|
1175
|
-
def list_groups(self) ->
|
|
1385
|
+
def list_groups(self) -> list[str]:
|
|
1176
1386
|
"""List all group names."""
|
|
1177
1387
|
rows = self._fetchall(
|
|
1178
1388
|
"SELECT DISTINCT group_name FROM _variable_groups ORDER BY group_name"
|
|
1179
1389
|
)
|
|
1180
1390
|
return [r[0] for r in rows]
|
|
1181
1391
|
|
|
1182
|
-
def get_group(self, group_name: str) ->
|
|
1392
|
+
def get_group(self, group_name: str) -> list[str]:
|
|
1183
1393
|
"""Get all variable names in a group."""
|
|
1184
1394
|
rows = self._fetchall(
|
|
1185
1395
|
"SELECT variable_name FROM _variable_groups "
|
|
@@ -1192,12 +1402,11 @@ class SciDuck:
|
|
|
1192
1402
|
# Schema introspection
|
|
1193
1403
|
# ------------------------------------------------------------------
|
|
1194
1404
|
|
|
1195
|
-
def distinct_schema_values(self, key: str) ->
|
|
1405
|
+
def distinct_schema_values(self, key: str) -> list:
|
|
1196
1406
|
"""Return all distinct non-null values for a schema column, sorted."""
|
|
1197
1407
|
if key not in self.dataset_schema:
|
|
1198
1408
|
raise ValueError(
|
|
1199
|
-
f"'{key}' is not a schema column. "
|
|
1200
|
-
f"Available: {self.dataset_schema}"
|
|
1409
|
+
f"'{key}' is not a schema column. Available: {self.dataset_schema}"
|
|
1201
1410
|
)
|
|
1202
1411
|
rows = self._fetchall(
|
|
1203
1412
|
f'SELECT DISTINCT "{key}" FROM _schema '
|
|
@@ -1220,8 +1429,7 @@ class SciDuck:
|
|
|
1220
1429
|
for k in keys:
|
|
1221
1430
|
if k not in self.dataset_schema:
|
|
1222
1431
|
raise ValueError(
|
|
1223
|
-
f"'{k}' is not a schema column. "
|
|
1224
|
-
f"Available: {self.dataset_schema}"
|
|
1432
|
+
f"'{k}' is not a schema column. Available: {self.dataset_schema}"
|
|
1225
1433
|
)
|
|
1226
1434
|
col_list = ", ".join(f'"{k}"' for k in keys)
|
|
1227
1435
|
where_clause = " AND ".join(f'"{k}" IS NOT NULL' for k in keys)
|
|
@@ -1248,12 +1456,18 @@ class SciDuck:
|
|
|
1248
1456
|
def close(self):
|
|
1249
1457
|
"""Close the DuckDB connection."""
|
|
1250
1458
|
self.con.close()
|
|
1251
|
-
logger.
|
|
1459
|
+
logger.debug("DuckDB lock RELEASED: %s", self.db_path)
|
|
1252
1460
|
|
|
1253
1461
|
def reopen(self):
|
|
1254
|
-
"""Reopen the DuckDB connection after close()."""
|
|
1255
|
-
logger.
|
|
1256
|
-
|
|
1462
|
+
"""Reopen the DuckDB connection after close(), preserving read_only mode."""
|
|
1463
|
+
logger.debug(
|
|
1464
|
+
"DuckDB lock ACQUIRED (reopen, read_only=%s): %s",
|
|
1465
|
+
getattr(self, "read_only", False),
|
|
1466
|
+
self.db_path,
|
|
1467
|
+
)
|
|
1468
|
+
self.con = duckdb.connect(
|
|
1469
|
+
str(self.db_path), read_only=getattr(self, "read_only", False)
|
|
1470
|
+
)
|
|
1257
1471
|
|
|
1258
1472
|
def __enter__(self):
|
|
1259
1473
|
"""Enter context manager."""
|
|
@@ -1273,4 +1487,4 @@ class SciDuck:
|
|
|
1273
1487
|
return (
|
|
1274
1488
|
f"SciDuck(path='{self.db_path}', "
|
|
1275
1489
|
f"schema={self.dataset_schema}, variables={n_vars})"
|
|
1276
|
-
)
|
|
1490
|
+
)
|
|
File without changes
|