boti-data 1.3.6__tar.gz → 1.3.7__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.
- {boti_data-1.3.6/src/boti_data.egg-info → boti_data-1.3.7}/PKG-INFO +22 -1
- boti_data-1.3.6/PKG-INFO → boti_data-1.3.7/README.md +17 -35
- {boti_data-1.3.6 → boti_data-1.3.7}/pyproject.toml +32 -1
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_loader.py +15 -2
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_engine.py +66 -3
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_registry.py +39 -1
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_repository.py +82 -6
- boti_data-1.3.6/README.md → boti_data-1.3.7/src/boti_data.egg-info/PKG-INFO +56 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data.egg-info/requires.txt +6 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/setup.cfg +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/arrow_schema.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/connection_catalog.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/artifact.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/base.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/contract.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/resource.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/dataset/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/dataset/hybrid.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/dataset/hybrid_support.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/arrow_schema_mapper.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/engine_registry.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_execution.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_planner.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_planner_async.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_planning_math.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_row_materialization.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_statements.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_types.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_config.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_manager.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_builder.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_module_injection.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_registry_types.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_readonly.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_resource.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sqlalchemy_async.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/vector_search.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/async_enricher.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/composite.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/protocol.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/specs.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/transformers.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/field_map.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels_string.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/backend_ops.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/expressions.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/handler.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/parquet_compiler.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/utils.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/value_parsing.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_backend_strategies.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_factory.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_gateway_init.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_payloads.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_series_filters.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/arrow_adapters.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/build_context.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/chunking.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/configured_load.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/core.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/core_describe.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/core_load.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/datacube_strategy.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/frame_convert.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/frame_strategies.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/incremental.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/load_execution.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/load_request.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/loaders.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/normalization.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/parquet_strategy.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/planning.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/policies.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/post_process.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/requests.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/return_type.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/select_cache.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/semi_join.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_describe.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_guard.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_size_estimation.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_strategy.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/helper.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/joins.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/config.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/discovery.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/file_info.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/filesystem.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/reader.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/reader_config.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/resource.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/schema_filters.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/base.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/registry.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks_common.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks_parquet.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks_text.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/py.typed +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/schema.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/watermark/__init__.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/watermark/incremental.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/watermark/store.py +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data.egg-info/SOURCES.txt +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data.egg-info/dependency_links.txt +0 -0
- {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: boti-data
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.7
|
|
4
4
|
Summary: Data infrastructure for the Boti ecosystem
|
|
5
5
|
Author: Luis Valverde
|
|
6
6
|
Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
|
|
@@ -27,6 +27,10 @@ Requires-Dist: asyncpg<0.32.0,>=0.30.0; extra == "postgres"
|
|
|
27
27
|
Requires-Dist: psycopg[binary]<4.0.0,>=3.2.0; extra == "postgres"
|
|
28
28
|
Provides-Extra: pgvector
|
|
29
29
|
Requires-Dist: pgvector<0.6.0,>=0.3.0; extra == "pgvector"
|
|
30
|
+
Provides-Extra: clickhouse
|
|
31
|
+
Requires-Dist: clickhouse-connect<2.0.0,>=1.6.0; extra == "clickhouse"
|
|
32
|
+
Provides-Extra: duckdb
|
|
33
|
+
Requires-Dist: duckdb-engine<0.18.0,>=0.17.0; extra == "duckdb"
|
|
30
34
|
Provides-Extra: dataframes
|
|
31
35
|
Requires-Dist: boti-dask<2.0.0,>=1.0.0; extra == "dataframes"
|
|
32
36
|
Requires-Dist: dask[dataframe,distributed]<2027.0.0,>=2026.3.0; extra == "dataframes"
|
|
@@ -131,8 +135,25 @@ pip install "boti[data]"
|
|
|
131
135
|
```bash
|
|
132
136
|
pip install "boti-data[mysql]" # asyncmy + pymysql
|
|
133
137
|
pip install "boti-data[postgres]" # asyncpg + psycopg[binary]
|
|
138
|
+
pip install "boti-data[clickhouse]" # clickhouse-connect (sync only)
|
|
139
|
+
pip install "boti-data[duckdb]" # duckdb-engine (sync only)
|
|
134
140
|
```
|
|
135
141
|
|
|
142
|
+
ClickHouse support is sync-only: there is no maintained async SQLAlchemy driver for ClickHouse, so `AsyncSqlDatabaseResource` rejects a ClickHouse connection URL — use `SqlDatabaseResource` instead. `SqlUnitOfWork`/`AsyncSqlUnitOfWork` also reject ClickHouse outright, since ClickHouse's transaction support is experimental, single-node, and MergeTree-only, with no real cross-table ACID guarantees to back a multi-table unit of work — use `SqlRepository`/`AsyncSqlRepository` directly, one table at a time, instead.
|
|
143
|
+
|
|
144
|
+
Verified against a real ClickHouse instance:
|
|
145
|
+
|
|
146
|
+
- `SqlRepository`/`AsyncSqlRepository` are a get/insert/update/delete-**by-primary-key** API, but MergeTree's `ORDER BY` is a sort/index prefix, not a uniqueness constraint — many real ClickHouse tables have several rows per `ORDER BY` key. Because of that, `get()`/`update()`/`delete()` each **refuse to run against a ClickHouse table unless called with `assume_unique_key=True`** (e.g. `repo.get(pk, assume_unique_key=True)`). This flag is a per-call caller assertion, not something boti-data verifies or enforces: it does not check the data, and if the key genuinely isn't unique, `get(key, assume_unique_key=True)` will still return one arbitrary matching row and `delete(key, assume_unique_key=True)`/`update(key, ..., assume_unique_key=True)` will still affect *every* row sharing that key, not just one. Only pass it once you've confirmed the key is actually unique for that table. `insert()` has no such parameter — appending a row is never ambiguous.
|
|
147
|
+
- `.update()` also fails outright on a table without `enable_block_number_column` enabled (`ALTER TABLE ... MODIFY SETTING enable_block_number_column = 1`, off by default) — ClickHouse raises `Lightweight updates are not supported` otherwise.
|
|
148
|
+
- `query_only=True` is enforced server-side (ClickHouse's `readonly` setting, applied via `set_client_setting` on connect), not just by boti-data's own read-only session guard.
|
|
149
|
+
|
|
150
|
+
DuckDB support is also sync-only (no maintained async driver, same as ClickHouse — `AsyncSqlDatabaseResource` rejects a DuckDB connection URL), but is otherwise a better fit than ClickHouse for `SqlRepository`'s by-primary-key model:
|
|
151
|
+
|
|
152
|
+
- **`SqlUnitOfWork`/`AsyncSqlUnitOfWork` are fully supported** — DuckDB has real multi-table ACID transactions (unlike ClickHouse), verified with an actual mid-transaction constraint violation correctly rolling back writes across two tables.
|
|
153
|
+
- **No `assume_unique_key`-style flag is needed** — DuckDB genuinely enforces `PRIMARY KEY`/`UNIQUE` constraints at the database level, and boti-data recovers the real key columns from DuckDB's catalog even though `duckdb-engine`'s own reflection can't see them (a known upstream limitation — it warns "doesn't yet support reflection on indices"). Without that recovery step, every DuckDB table would look keyless and `SqlRepository` would silently bind to an arbitrary first column instead of the real key — this was caught and fixed during implementation, not just assumed away.
|
|
154
|
+
- **`query_only=True` is enforced via a connect-time flag**, not a runtime statement: DuckDB's read-only mode (`duckdb.connect(read_only=True)`) can only be set when a connection is opened, so boti-data injects `connect_args={"read_only": True}` itself before the engine is even constructed, rather than configuring it afterward the way Postgres/MySQL/ClickHouse do.
|
|
155
|
+
- **DuckDB allows only one process to hold a database file open for read-write at a time** — opening a file in its default mode claims an exclusive lock on connect, even for a connection that only ever reads. `SqlPartitionedLoader` (which spawns one engine per worker process) requires `query_only=True` for a file-backed DuckDB DSN for exactly this reason — otherwise the first worker to connect would block every other worker, regardless of whether any of them write. Concurrent read-only access from multiple processes works fine. In-memory (`duckdb:///:memory:`) DSNs are rejected for distributed loading the same way SQLite's are, since each worker process would get its own independent, empty database.
|
|
156
|
+
|
|
136
157
|
If a Postgres table has a [pgvector](https://github.com/pgvector/pgvector) `vector` column, install `boti-data[pgvector]` too — this registers pgvector's `Vector` type with SQLAlchemy's reflection so `SqlAlchemyModelBuilder`/`SqlRepository`/`DataGateway` map that column to a usable `list[float]` (with its dimension preserved) instead of an unreflectable `NullType`:
|
|
137
158
|
|
|
138
159
|
```bash
|
|
@@ -1,38 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: boti-data
|
|
3
|
-
Version: 1.3.6
|
|
4
|
-
Summary: Data infrastructure for the Boti ecosystem
|
|
5
|
-
Author: Luis Valverde
|
|
6
|
-
Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
|
|
7
|
-
License-Expression: MIT
|
|
8
|
-
Project-URL: Homepage, https://github.com/lvalverdeb/boti-data
|
|
9
|
-
Project-URL: Repository, https://github.com/lvalverdeb/boti-data
|
|
10
|
-
Project-URL: Documentation, https://github.com/lvalverdeb/boti-data#readme
|
|
11
|
-
Project-URL: Issues, https://github.com/lvalverdeb/boti-data/issues
|
|
12
|
-
Classifier: Development Status :: 3 - Alpha
|
|
13
|
-
Classifier: Intended Audience :: Developers
|
|
14
|
-
Classifier: Programming Language :: Python :: 3
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
-
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
-
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
-
Requires-Python: >=3.13
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
Requires-Dist: boti<2.0.0,>=1.0.2
|
|
21
|
-
Requires-Dist: sqlalchemy[asyncio]<3.0.0,>=2.0.49
|
|
22
|
-
Provides-Extra: mysql
|
|
23
|
-
Requires-Dist: asyncmy<0.3.0,>=0.2.11; extra == "mysql"
|
|
24
|
-
Requires-Dist: pymysql<2.0.0,>=1.1.2; extra == "mysql"
|
|
25
|
-
Provides-Extra: postgres
|
|
26
|
-
Requires-Dist: asyncpg<0.32.0,>=0.30.0; extra == "postgres"
|
|
27
|
-
Requires-Dist: psycopg[binary]<4.0.0,>=3.2.0; extra == "postgres"
|
|
28
|
-
Provides-Extra: pgvector
|
|
29
|
-
Requires-Dist: pgvector<0.6.0,>=0.3.0; extra == "pgvector"
|
|
30
|
-
Provides-Extra: dataframes
|
|
31
|
-
Requires-Dist: boti-dask<2.0.0,>=1.0.0; extra == "dataframes"
|
|
32
|
-
Requires-Dist: dask[dataframe,distributed]<2027.0.0,>=2026.3.0; extra == "dataframes"
|
|
33
|
-
Requires-Dist: pandas<4.0.0,>=3.0.2; extra == "dataframes"
|
|
34
|
-
Requires-Dist: polars<2.0.0,>=1.29.0; extra == "dataframes"
|
|
35
|
-
|
|
36
1
|
# boti-data
|
|
37
2
|
|
|
38
3
|
`boti-data` is the **data access and data transformation layer** of the Boti ecosystem.
|
|
@@ -131,8 +96,25 @@ pip install "boti[data]"
|
|
|
131
96
|
```bash
|
|
132
97
|
pip install "boti-data[mysql]" # asyncmy + pymysql
|
|
133
98
|
pip install "boti-data[postgres]" # asyncpg + psycopg[binary]
|
|
99
|
+
pip install "boti-data[clickhouse]" # clickhouse-connect (sync only)
|
|
100
|
+
pip install "boti-data[duckdb]" # duckdb-engine (sync only)
|
|
134
101
|
```
|
|
135
102
|
|
|
103
|
+
ClickHouse support is sync-only: there is no maintained async SQLAlchemy driver for ClickHouse, so `AsyncSqlDatabaseResource` rejects a ClickHouse connection URL — use `SqlDatabaseResource` instead. `SqlUnitOfWork`/`AsyncSqlUnitOfWork` also reject ClickHouse outright, since ClickHouse's transaction support is experimental, single-node, and MergeTree-only, with no real cross-table ACID guarantees to back a multi-table unit of work — use `SqlRepository`/`AsyncSqlRepository` directly, one table at a time, instead.
|
|
104
|
+
|
|
105
|
+
Verified against a real ClickHouse instance:
|
|
106
|
+
|
|
107
|
+
- `SqlRepository`/`AsyncSqlRepository` are a get/insert/update/delete-**by-primary-key** API, but MergeTree's `ORDER BY` is a sort/index prefix, not a uniqueness constraint — many real ClickHouse tables have several rows per `ORDER BY` key. Because of that, `get()`/`update()`/`delete()` each **refuse to run against a ClickHouse table unless called with `assume_unique_key=True`** (e.g. `repo.get(pk, assume_unique_key=True)`). This flag is a per-call caller assertion, not something boti-data verifies or enforces: it does not check the data, and if the key genuinely isn't unique, `get(key, assume_unique_key=True)` will still return one arbitrary matching row and `delete(key, assume_unique_key=True)`/`update(key, ..., assume_unique_key=True)` will still affect *every* row sharing that key, not just one. Only pass it once you've confirmed the key is actually unique for that table. `insert()` has no such parameter — appending a row is never ambiguous.
|
|
108
|
+
- `.update()` also fails outright on a table without `enable_block_number_column` enabled (`ALTER TABLE ... MODIFY SETTING enable_block_number_column = 1`, off by default) — ClickHouse raises `Lightweight updates are not supported` otherwise.
|
|
109
|
+
- `query_only=True` is enforced server-side (ClickHouse's `readonly` setting, applied via `set_client_setting` on connect), not just by boti-data's own read-only session guard.
|
|
110
|
+
|
|
111
|
+
DuckDB support is also sync-only (no maintained async driver, same as ClickHouse — `AsyncSqlDatabaseResource` rejects a DuckDB connection URL), but is otherwise a better fit than ClickHouse for `SqlRepository`'s by-primary-key model:
|
|
112
|
+
|
|
113
|
+
- **`SqlUnitOfWork`/`AsyncSqlUnitOfWork` are fully supported** — DuckDB has real multi-table ACID transactions (unlike ClickHouse), verified with an actual mid-transaction constraint violation correctly rolling back writes across two tables.
|
|
114
|
+
- **No `assume_unique_key`-style flag is needed** — DuckDB genuinely enforces `PRIMARY KEY`/`UNIQUE` constraints at the database level, and boti-data recovers the real key columns from DuckDB's catalog even though `duckdb-engine`'s own reflection can't see them (a known upstream limitation — it warns "doesn't yet support reflection on indices"). Without that recovery step, every DuckDB table would look keyless and `SqlRepository` would silently bind to an arbitrary first column instead of the real key — this was caught and fixed during implementation, not just assumed away.
|
|
115
|
+
- **`query_only=True` is enforced via a connect-time flag**, not a runtime statement: DuckDB's read-only mode (`duckdb.connect(read_only=True)`) can only be set when a connection is opened, so boti-data injects `connect_args={"read_only": True}` itself before the engine is even constructed, rather than configuring it afterward the way Postgres/MySQL/ClickHouse do.
|
|
116
|
+
- **DuckDB allows only one process to hold a database file open for read-write at a time** — opening a file in its default mode claims an exclusive lock on connect, even for a connection that only ever reads. `SqlPartitionedLoader` (which spawns one engine per worker process) requires `query_only=True` for a file-backed DuckDB DSN for exactly this reason — otherwise the first worker to connect would block every other worker, regardless of whether any of them write. Concurrent read-only access from multiple processes works fine. In-memory (`duckdb:///:memory:`) DSNs are rejected for distributed loading the same way SQLite's are, since each worker process would get its own independent, empty database.
|
|
117
|
+
|
|
136
118
|
If a Postgres table has a [pgvector](https://github.com/pgvector/pgvector) `vector` column, install `boti-data[pgvector]` too — this registers pgvector's `Vector` type with SQLAlchemy's reflection so `SqlAlchemyModelBuilder`/`SqlRepository`/`DataGateway` map that column to a usable `list[float]` (with its dimension preserved) instead of an unreflectable `NullType`:
|
|
137
119
|
|
|
138
120
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boti-data"
|
|
3
|
-
version = "1.3.
|
|
3
|
+
version = "1.3.7"
|
|
4
4
|
description = "Data infrastructure for the Boti ecosystem"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.13"
|
|
@@ -46,6 +46,25 @@ postgres = [
|
|
|
46
46
|
pgvector = [
|
|
47
47
|
"pgvector>=0.3.0,<0.6.0",
|
|
48
48
|
]
|
|
49
|
+
# clickhouse-connect bundles both the DBAPI driver and its own SQLAlchemy
|
|
50
|
+
# dialect ("clickhousedb://") in one package, so unlike mysql/postgres there's
|
|
51
|
+
# no separate async driver to pair it with — ClickHouse support in boti-data is
|
|
52
|
+
# sync-only (SqlDatabaseResource only; AsyncSqlDatabaseResource and
|
|
53
|
+
# SqlUnitOfWork/AsyncSqlUnitOfWork reject a clickhouse backend outright, since
|
|
54
|
+
# ClickHouse has no maintained async SQLAlchemy driver and no real cross-table
|
|
55
|
+
# transactional guarantees to back a unit of work with).
|
|
56
|
+
clickhouse = [
|
|
57
|
+
"clickhouse-connect>=1.6.0,<2.0.0",
|
|
58
|
+
]
|
|
59
|
+
# duckdb-engine bundles the SQLAlchemy dialect ("duckdb://"); the duckdb driver
|
|
60
|
+
# itself comes along as its transitive dependency. Like clickhouse, there's no
|
|
61
|
+
# separate async driver to pair it with, so DuckDB support in boti-data is
|
|
62
|
+
# sync-only (AsyncSqlDatabaseResource rejects a duckdb backend outright). Unlike
|
|
63
|
+
# ClickHouse, DuckDB has real PRIMARY KEY/UNIQUE enforcement and real multi-table
|
|
64
|
+
# ACID transactions, so SqlUnitOfWork/AsyncSqlUnitOfWork are fully supported here.
|
|
65
|
+
duckdb = [
|
|
66
|
+
"duckdb-engine>=0.17.0,<0.18.0",
|
|
67
|
+
]
|
|
49
68
|
# The dataframe-shaped, bulk-oriented side of the package (DataGateway/
|
|
50
69
|
# DataHelper, ParquetPipeline, HybridDataset, the sink pipelines, etc.) — not
|
|
51
70
|
# needed by the lightweight SqlRepository/AsyncSqlRepository single-row API,
|
|
@@ -93,6 +112,18 @@ dev = [
|
|
|
93
112
|
# here so tests/data/test_sql_model_registry_pgvector.py can verify the
|
|
94
113
|
# registration side effect actually happens when the package is installed.
|
|
95
114
|
"pgvector>=0.3.0,<0.6.0",
|
|
115
|
+
# Not part of [project.optional-dependencies] clickhouse's runtime need on
|
|
116
|
+
# its own — it's here so the clickhouse cases in
|
|
117
|
+
# tests/data/test_sql_manager_registry.py and tests/data/test_sql_repository.py
|
|
118
|
+
# can construct real clickhousedb:// engines (DSN parsing / driver validation
|
|
119
|
+
# only, never dialed — same hermetic pattern as the mysql/postgres DSN tests).
|
|
120
|
+
"clickhouse-connect>=1.6.0,<2.0.0",
|
|
121
|
+
# Not part of [project.optional-dependencies] duckdb's runtime need on its
|
|
122
|
+
# own — it's here so the duckdb cases in tests/data/test_sql_manager_registry.py
|
|
123
|
+
# and tests/data/test_sql_repository.py can construct real duckdb:// engines,
|
|
124
|
+
# including genuine temp-file round-trips (DuckDB is embedded, so — unlike
|
|
125
|
+
# mysql/postgres/clickhouse — these tests need no external server at all).
|
|
126
|
+
"duckdb-engine>=0.17.0,<0.18.0",
|
|
96
127
|
"mypy>=1.14.0",
|
|
97
128
|
"pytest>=9.0.3",
|
|
98
129
|
"pytest-asyncio>=1.3.0",
|
|
@@ -58,14 +58,27 @@ class SqlPartitionedLoader(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
|
58
58
|
def _validate_distributed_config(self) -> None:
|
|
59
59
|
"""Reject connection URLs that cannot be reopened safely by worker-local engines."""
|
|
60
60
|
parsed = sqlalchemy_url.make_url(self.config.connection_url.get_secret_value())
|
|
61
|
-
|
|
61
|
+
backend = parsed.get_backend_name()
|
|
62
|
+
if backend not in {"sqlite", "duckdb"}:
|
|
62
63
|
return
|
|
63
64
|
|
|
64
65
|
database = parsed.database or ""
|
|
65
66
|
if database == "" or ":memory:" in database:
|
|
66
67
|
raise ValueError(
|
|
67
68
|
"SqlPartitionedLoader requires a database reachable by worker-local connections. "
|
|
68
|
-
"
|
|
69
|
+
f"{backend.capitalize()} in-memory DSNs are not supported; use a file-backed DSN "
|
|
70
|
+
"or request as_pandas=True."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if backend == "duckdb" and not self.config.query_only:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"SqlPartitionedLoader requires query_only=True for a file-backed DuckDB DSN. "
|
|
76
|
+
"Opening a DuckDB file in its default (read-write) mode claims an exclusive lock "
|
|
77
|
+
"on connect, even for a connection that only ever reads — so the first worker "
|
|
78
|
+
"process to connect would block every other worker with a file lock error, "
|
|
79
|
+
"regardless of whether any of them actually write. query_only=True opens each "
|
|
80
|
+
"worker's connection in DuckDB's native read-only mode instead, which multiple "
|
|
81
|
+
"processes can share concurrently without conflict."
|
|
69
82
|
)
|
|
70
83
|
|
|
71
84
|
def _cleanup(self) -> None:
|
|
@@ -70,6 +70,8 @@ def _build_driver_example(parsed_url: sqlalchemy_url.URL, *, async_mode: bool) -
|
|
|
70
70
|
"mysql": "mysql+pymysql://",
|
|
71
71
|
"postgresql": "postgresql+psycopg://",
|
|
72
72
|
"sqlite": "sqlite://",
|
|
73
|
+
"clickhousedb": "clickhousedb://",
|
|
74
|
+
"duckdb": "duckdb://",
|
|
73
75
|
}
|
|
74
76
|
return sync_examples.get(backend, f"{backend}://")
|
|
75
77
|
|
|
@@ -104,6 +106,16 @@ def _validate_async_driver_url(
|
|
|
104
106
|
consumer_name: str = "AsyncSqlDatabaseResource",
|
|
105
107
|
) -> sqlalchemy_url.URL:
|
|
106
108
|
parsed = _parse_connection_url(connection_url, consumer_name=consumer_name)
|
|
109
|
+
if parsed.get_backend_name() == "clickhousedb":
|
|
110
|
+
raise SQLAlchemyError(
|
|
111
|
+
f"{consumer_name} does not support ClickHouse: there is no maintained "
|
|
112
|
+
"asynchronous SQLAlchemy driver for it. Use SqlDatabaseResource instead."
|
|
113
|
+
)
|
|
114
|
+
if parsed.get_backend_name() == "duckdb":
|
|
115
|
+
raise SQLAlchemyError(
|
|
116
|
+
f"{consumer_name} does not support DuckDB: there is no maintained "
|
|
117
|
+
"asynchronous SQLAlchemy driver for it. Use SqlDatabaseResource instead."
|
|
118
|
+
)
|
|
107
119
|
if not parsed.get_dialect().is_async:
|
|
108
120
|
suggested_driver = _build_driver_example(parsed, async_mode=True)
|
|
109
121
|
raise SQLAlchemyError(
|
|
@@ -114,17 +126,42 @@ def _validate_async_driver_url(
|
|
|
114
126
|
return parsed
|
|
115
127
|
|
|
116
128
|
|
|
129
|
+
def _apply_duckdb_read_only_connect_arg(
|
|
130
|
+
connect_args: dict[str, Any], *, connection_url: str, query_only: bool
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Inject connect_args={"read_only": True} for DuckDB when query_only=True.
|
|
133
|
+
|
|
134
|
+
Unlike Postgres/MySQL/ClickHouse, DuckDB's read-only mode is a connect-time
|
|
135
|
+
flag (duckdb.connect(read_only=True)), not something settable after the
|
|
136
|
+
engine/connection already exists — so this has to happen before
|
|
137
|
+
create_engine() is called, not via _configure_query_only_engine(). Injection
|
|
138
|
+
wins on conflict, consistent with query_only=True always overriding
|
|
139
|
+
elsewhere in this codebase.
|
|
140
|
+
"""
|
|
141
|
+
if not query_only:
|
|
142
|
+
return connect_args
|
|
143
|
+
backend = sqlalchemy_url.make_url(_normalize_connection_url(connection_url)).get_backend_name()
|
|
144
|
+
if backend != "duckdb":
|
|
145
|
+
return connect_args
|
|
146
|
+
return {**connect_args, "read_only": True}
|
|
147
|
+
|
|
148
|
+
|
|
117
149
|
def _build_sync_engine_kwargs(
|
|
118
150
|
config: SqlDatabaseConfig,
|
|
119
151
|
*,
|
|
120
152
|
poolclass_override: type[Pool] | None = None,
|
|
121
153
|
) -> dict[str, Any]:
|
|
122
154
|
poolclass = poolclass_override or config.poolclass
|
|
155
|
+
connect_args = _apply_duckdb_read_only_connect_arg(
|
|
156
|
+
config.connect_args,
|
|
157
|
+
connection_url=config.connection_url.get_secret_value(),
|
|
158
|
+
query_only=config.query_only,
|
|
159
|
+
)
|
|
123
160
|
kwargs: dict[str, Any] = {
|
|
124
161
|
"pool_recycle": config.pool_recycle,
|
|
125
162
|
"pool_pre_ping": config.pool_pre_ping,
|
|
126
163
|
"poolclass": poolclass,
|
|
127
|
-
"connect_args":
|
|
164
|
+
"connect_args": connect_args,
|
|
128
165
|
"execution_options": config.execution_options,
|
|
129
166
|
}
|
|
130
167
|
if poolclass not in (NullPool, StaticPool):
|
|
@@ -249,11 +286,16 @@ def _resolve_worker_connection_url(config: WorkerSqlConfig) -> str:
|
|
|
249
286
|
|
|
250
287
|
|
|
251
288
|
def _build_worker_engine_kwargs(config: WorkerSqlConfig) -> dict[str, Any]:
|
|
289
|
+
connect_args = _apply_duckdb_read_only_connect_arg(
|
|
290
|
+
config.connect_args,
|
|
291
|
+
connection_url=_resolve_worker_connection_url(config),
|
|
292
|
+
query_only=config.query_only,
|
|
293
|
+
)
|
|
252
294
|
return {
|
|
253
295
|
"pool_recycle": config.pool_recycle,
|
|
254
296
|
"pool_pre_ping": config.pool_pre_ping,
|
|
255
297
|
"poolclass": NullPool,
|
|
256
|
-
"connect_args":
|
|
298
|
+
"connect_args": connect_args,
|
|
257
299
|
"execution_options": config.execution_options,
|
|
258
300
|
}
|
|
259
301
|
|
|
@@ -296,7 +338,7 @@ def _create_worker_sync_engine(config: SqlDatabaseConfig | WorkerSqlConfig) -> E
|
|
|
296
338
|
def _validate_query_only_support(parsed_url: sqlalchemy_url.URL) -> None:
|
|
297
339
|
backend = parsed_url.get_backend_name()
|
|
298
340
|
|
|
299
|
-
if backend in {"postgresql", "mysql"}:
|
|
341
|
+
if backend in {"postgresql", "mysql", "clickhousedb", "duckdb"}:
|
|
300
342
|
return
|
|
301
343
|
|
|
302
344
|
if backend == "sqlite":
|
|
@@ -349,5 +391,26 @@ def _configure_query_only_engine(engine: Engine, parsed_url: sqlalchemy_url.URL)
|
|
|
349
391
|
|
|
350
392
|
return
|
|
351
393
|
|
|
394
|
+
if backend == "clickhousedb":
|
|
395
|
+
|
|
396
|
+
@event.listens_for(engine, "connect")
|
|
397
|
+
def _set_clickhouse_read_only(dbapi_connection: Any, _connection_record: Any) -> None:
|
|
398
|
+
# ClickHouse's HTTP interface is stateless per request, so a plain
|
|
399
|
+
# "SET readonly = 1" statement wouldn't persist across later
|
|
400
|
+
# queries on this connection the way a Postgres/MySQL SET does.
|
|
401
|
+
# set_client_setting() instead updates the driver Client's
|
|
402
|
+
# persistent default settings, which clickhouse-connect merges
|
|
403
|
+
# into every subsequent query on this connection.
|
|
404
|
+
dbapi_connection.client.set_client_setting("readonly", 1)
|
|
405
|
+
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
if backend == "duckdb":
|
|
409
|
+
# DuckDB's read-only mode is a connect-time flag (duckdb.connect(read_only=True)),
|
|
410
|
+
# not a runtime SQL statement, so there's nothing left to configure post-connect —
|
|
411
|
+
# it's already applied via connect_args by _apply_duckdb_read_only_connect_arg()
|
|
412
|
+
# before the engine was ever constructed.
|
|
413
|
+
return
|
|
414
|
+
|
|
352
415
|
if backend == "sqlite":
|
|
353
416
|
return
|
|
@@ -18,7 +18,7 @@ from typing import Any, Union
|
|
|
18
18
|
|
|
19
19
|
from boti.core.logger import Logger
|
|
20
20
|
from boti.core.security import is_valid_dotted_identifier
|
|
21
|
-
from sqlalchemy import MetaData, Table
|
|
21
|
+
from sqlalchemy import MetaData, PrimaryKeyConstraint, Table, text
|
|
22
22
|
from sqlalchemy.engine import Engine
|
|
23
23
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
24
24
|
|
|
@@ -43,6 +43,42 @@ except ImportError as exc:
|
|
|
43
43
|
_logger.debug("pgvector.sqlalchemy not available, vector type registration skipped: %s", exc)
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def _recover_duckdb_primary_key(
|
|
47
|
+
engine: Engine, tbl: Table, *, tname: str, schema: str | None
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Recover a DuckDB table's real PRIMARY KEY from its catalog.
|
|
50
|
+
|
|
51
|
+
duckdb-engine's reflection doesn't report PRIMARY KEY/UNIQUE constraints at
|
|
52
|
+
all ("doesn't yet support reflection on indices"), even though DuckDB
|
|
53
|
+
enforces them at the database level — so without this, every DuckDB table
|
|
54
|
+
looks keyless to SQLAlchemy and _build_model_class's fallback binds
|
|
55
|
+
SqlRepository to an arbitrary first column instead of the real key,
|
|
56
|
+
silently corrupting get()/update()/delete() on any table whose real key
|
|
57
|
+
isn't a single first-position column. information_schema is queried
|
|
58
|
+
directly (not duckdb_constraints(), a DuckDB-specific function) since it's
|
|
59
|
+
the same standard introspection surface already assumed elsewhere in this
|
|
60
|
+
codebase for other backends.
|
|
61
|
+
"""
|
|
62
|
+
if engine.dialect.name != "duckdb" or tbl.primary_key.columns:
|
|
63
|
+
return
|
|
64
|
+
with engine.connect() as conn:
|
|
65
|
+
rows = conn.execute(
|
|
66
|
+
text(
|
|
67
|
+
"SELECT kcu.column_name "
|
|
68
|
+
"FROM information_schema.table_constraints tc "
|
|
69
|
+
"JOIN information_schema.key_column_usage kcu "
|
|
70
|
+
" ON tc.constraint_name = kcu.constraint_name "
|
|
71
|
+
"WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = :tname "
|
|
72
|
+
" AND (:schema IS NULL OR tc.table_schema = :schema) "
|
|
73
|
+
"ORDER BY kcu.ordinal_position"
|
|
74
|
+
),
|
|
75
|
+
{"tname": tname, "schema": schema},
|
|
76
|
+
).fetchall()
|
|
77
|
+
pk_columns = [tbl.c[row[0]] for row in rows if row[0] in tbl.c]
|
|
78
|
+
if pk_columns:
|
|
79
|
+
tbl.append_constraint(PrimaryKeyConstraint(*pk_columns))
|
|
80
|
+
|
|
81
|
+
|
|
46
82
|
class SqlModelRegistry:
|
|
47
83
|
"""
|
|
48
84
|
Thread-safe registry that reflects tables sequentially and
|
|
@@ -377,6 +413,8 @@ class SqlModelRegistry:
|
|
|
377
413
|
if tbl is None:
|
|
378
414
|
md.reflect(bind=engine, only=[tname], schema=schema)
|
|
379
415
|
tbl = md.tables.get(qname)
|
|
416
|
+
if tbl is not None:
|
|
417
|
+
_recover_duckdb_primary_key(engine, tbl, tname=tname, schema=schema)
|
|
380
418
|
return tbl
|
|
381
419
|
|
|
382
420
|
@staticmethod
|
|
@@ -21,6 +21,8 @@ from typing import Any
|
|
|
21
21
|
from boti.core.lifecycle import LifecycleCore
|
|
22
22
|
from boti.core.logger import Logger
|
|
23
23
|
from sqlalchemy import inspect as sa_inspect
|
|
24
|
+
from sqlalchemy.engine import url as sqlalchemy_url
|
|
25
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
24
26
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
25
27
|
from sqlalchemy.orm import Session
|
|
26
28
|
|
|
@@ -48,6 +50,34 @@ def _require_exactly_one_source(config: SqlDatabaseConfig | None, session: Any |
|
|
|
48
50
|
raise ValueError("Provide exactly one of 'config' or 'session'.")
|
|
49
51
|
|
|
50
52
|
|
|
53
|
+
def _require_unique_key_for_clickhouse(
|
|
54
|
+
engine: Any, *, assume_unique_key: bool, operation: str
|
|
55
|
+
) -> None:
|
|
56
|
+
if assume_unique_key or engine.url.get_backend_name() != "clickhousedb":
|
|
57
|
+
return
|
|
58
|
+
raise SQLAlchemyError(
|
|
59
|
+
f"{operation} refuses to run against ClickHouse without assume_unique_key=True. "
|
|
60
|
+
"ClickHouse's ORDER BY/primary key is a sort prefix, not a uniqueness constraint "
|
|
61
|
+
"(even ReplacingMergeTree only dedups eventually), so several rows can share the "
|
|
62
|
+
"same key: get() could silently return an arbitrary match, and update()/delete() "
|
|
63
|
+
"would affect every matching row instead of one. Pass assume_unique_key=True only "
|
|
64
|
+
"once you've verified the key is actually unique for this table — this is a caller "
|
|
65
|
+
"assertion boti-data cannot verify or enforce itself."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _reject_clickhouse_backend(config: SqlDatabaseConfig, *, consumer_name: str) -> None:
|
|
70
|
+
backend = sqlalchemy_url.make_url(config.connection_url.get_secret_value()).get_backend_name()
|
|
71
|
+
if backend == "clickhousedb":
|
|
72
|
+
raise SQLAlchemyError(
|
|
73
|
+
f"{consumer_name} does not support ClickHouse. ClickHouse has no real "
|
|
74
|
+
"cross-table transactional guarantees (transactions are experimental, "
|
|
75
|
+
"single-node, MergeTree-only), so committing or rolling back several "
|
|
76
|
+
"repositories together can't be honestly backed. Use SqlRepository/"
|
|
77
|
+
"AsyncSqlRepository directly, one table at a time, instead."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
51
81
|
class SqlRepository(LifecycleCore):
|
|
52
82
|
"""Single-row get/insert/update/delete against one table, by primary key.
|
|
53
83
|
|
|
@@ -78,6 +108,14 @@ class SqlRepository(LifecycleCore):
|
|
|
78
108
|
|
|
79
109
|
``pk`` accepts a scalar or a tuple for composite keys, mirroring
|
|
80
110
|
``Session.get()``'s own signature.
|
|
111
|
+
|
|
112
|
+
``get()``/``update()``/``delete()`` each accept ``assume_unique_key``,
|
|
113
|
+
which must be explicitly passed as ``True`` to run against a ClickHouse
|
|
114
|
+
table: ClickHouse's ORDER BY/primary key is a sort prefix, not a
|
|
115
|
+
uniqueness constraint, so without this assertion those calls could
|
|
116
|
+
silently touch the wrong row (``get()``) or every row sharing a key
|
|
117
|
+
(``update()``/``delete()``) instead of just one. ``insert()`` is
|
|
118
|
+
unaffected — appending a row is never ambiguous.
|
|
81
119
|
"""
|
|
82
120
|
|
|
83
121
|
def __init__(
|
|
@@ -126,7 +164,10 @@ class SqlRepository(LifecycleCore):
|
|
|
126
164
|
else:
|
|
127
165
|
session.flush()
|
|
128
166
|
|
|
129
|
-
def get(self, pk: Any) -> dict[str, Any] | None:
|
|
167
|
+
def get(self, pk: Any, *, assume_unique_key: bool = False) -> dict[str, Any] | None:
|
|
168
|
+
_require_unique_key_for_clickhouse(
|
|
169
|
+
self.engine, assume_unique_key=assume_unique_key, operation="SqlRepository.get()"
|
|
170
|
+
)
|
|
130
171
|
with self._session_scope() as session:
|
|
131
172
|
instance = session.get(self._model, pk)
|
|
132
173
|
return _row_to_dict(instance) if instance is not None else None
|
|
@@ -139,7 +180,14 @@ class SqlRepository(LifecycleCore):
|
|
|
139
180
|
session.refresh(instance)
|
|
140
181
|
return _row_to_dict(instance)
|
|
141
182
|
|
|
142
|
-
def update(
|
|
183
|
+
def update(
|
|
184
|
+
self, pk: Any, values: dict[str, Any], *, assume_unique_key: bool = False
|
|
185
|
+
) -> dict[str, Any] | None:
|
|
186
|
+
_require_unique_key_for_clickhouse(
|
|
187
|
+
self.engine,
|
|
188
|
+
assume_unique_key=assume_unique_key,
|
|
189
|
+
operation="SqlRepository.update()",
|
|
190
|
+
)
|
|
143
191
|
with self._session_scope() as session:
|
|
144
192
|
instance = session.get(self._model, pk)
|
|
145
193
|
if instance is None:
|
|
@@ -150,7 +198,12 @@ class SqlRepository(LifecycleCore):
|
|
|
150
198
|
session.refresh(instance)
|
|
151
199
|
return _row_to_dict(instance)
|
|
152
200
|
|
|
153
|
-
def delete(self, pk: Any) -> bool:
|
|
201
|
+
def delete(self, pk: Any, *, assume_unique_key: bool = False) -> bool:
|
|
202
|
+
_require_unique_key_for_clickhouse(
|
|
203
|
+
self.engine,
|
|
204
|
+
assume_unique_key=assume_unique_key,
|
|
205
|
+
operation="SqlRepository.delete()",
|
|
206
|
+
)
|
|
154
207
|
with self._session_scope() as session:
|
|
155
208
|
instance = session.get(self._model, pk)
|
|
156
209
|
if instance is None:
|
|
@@ -169,6 +222,10 @@ class AsyncSqlRepository(LifecycleCore):
|
|
|
169
222
|
``__aenter__``, not ``__init__``, since resolving the engine requires
|
|
170
223
|
awaiting ``EngineRegistry.get_or_create_async()`` (``config`` path) or
|
|
171
224
|
``SqlModelRegistry.get_model_async()`` (``session`` path either way).
|
|
225
|
+
|
|
226
|
+
``get()``/``update()``/``delete()`` each accept ``assume_unique_key`` —
|
|
227
|
+
see :class:`SqlRepository`'s docstring for why this is required against
|
|
228
|
+
ClickHouse.
|
|
172
229
|
"""
|
|
173
230
|
|
|
174
231
|
def __init__(
|
|
@@ -232,7 +289,12 @@ class AsyncSqlRepository(LifecycleCore):
|
|
|
232
289
|
else:
|
|
233
290
|
await session.flush()
|
|
234
291
|
|
|
235
|
-
async def get(self, pk: Any) -> dict[str, Any] | None:
|
|
292
|
+
async def get(self, pk: Any, *, assume_unique_key: bool = False) -> dict[str, Any] | None:
|
|
293
|
+
_require_unique_key_for_clickhouse(
|
|
294
|
+
self.engine,
|
|
295
|
+
assume_unique_key=assume_unique_key,
|
|
296
|
+
operation="AsyncSqlRepository.get()",
|
|
297
|
+
)
|
|
236
298
|
async with self._session_scope() as session:
|
|
237
299
|
instance = await session.get(self._model, pk)
|
|
238
300
|
return _row_to_dict(instance) if instance is not None else None
|
|
@@ -245,7 +307,14 @@ class AsyncSqlRepository(LifecycleCore):
|
|
|
245
307
|
await session.refresh(instance)
|
|
246
308
|
return _row_to_dict(instance)
|
|
247
309
|
|
|
248
|
-
async def update(
|
|
310
|
+
async def update(
|
|
311
|
+
self, pk: Any, values: dict[str, Any], *, assume_unique_key: bool = False
|
|
312
|
+
) -> dict[str, Any] | None:
|
|
313
|
+
_require_unique_key_for_clickhouse(
|
|
314
|
+
self.engine,
|
|
315
|
+
assume_unique_key=assume_unique_key,
|
|
316
|
+
operation="AsyncSqlRepository.update()",
|
|
317
|
+
)
|
|
249
318
|
async with self._session_scope() as session:
|
|
250
319
|
instance = await session.get(self._model, pk)
|
|
251
320
|
if instance is None:
|
|
@@ -256,7 +325,12 @@ class AsyncSqlRepository(LifecycleCore):
|
|
|
256
325
|
await session.refresh(instance)
|
|
257
326
|
return _row_to_dict(instance)
|
|
258
327
|
|
|
259
|
-
async def delete(self, pk: Any) -> bool:
|
|
328
|
+
async def delete(self, pk: Any, *, assume_unique_key: bool = False) -> bool:
|
|
329
|
+
_require_unique_key_for_clickhouse(
|
|
330
|
+
self.engine,
|
|
331
|
+
assume_unique_key=assume_unique_key,
|
|
332
|
+
operation="AsyncSqlRepository.delete()",
|
|
333
|
+
)
|
|
260
334
|
async with self._session_scope() as session:
|
|
261
335
|
instance = await session.get(self._model, pk)
|
|
262
336
|
if instance is None:
|
|
@@ -283,6 +357,7 @@ class SqlUnitOfWork:
|
|
|
283
357
|
"""
|
|
284
358
|
|
|
285
359
|
def __init__(self, config: SqlDatabaseConfig, *, query_only: bool = True) -> None:
|
|
360
|
+
_reject_clickhouse_backend(config, consumer_name="SqlUnitOfWork")
|
|
286
361
|
effective_config = config.model_copy(update={"query_only": query_only})
|
|
287
362
|
self._resource = SqlDatabaseResource(effective_config)
|
|
288
363
|
self._session: Session = self._resource.session()
|
|
@@ -326,6 +401,7 @@ class AsyncSqlUnitOfWork:
|
|
|
326
401
|
"""
|
|
327
402
|
|
|
328
403
|
def __init__(self, config: SqlDatabaseConfig, *, query_only: bool = True) -> None:
|
|
404
|
+
_reject_clickhouse_backend(config, consumer_name="AsyncSqlUnitOfWork")
|
|
329
405
|
self._config = config.model_copy(update={"query_only": query_only})
|
|
330
406
|
self._resource: AsyncSqlDatabaseResource | None = None
|
|
331
407
|
self._session: AsyncSession | None = None
|
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boti-data
|
|
3
|
+
Version: 1.3.7
|
|
4
|
+
Summary: Data infrastructure for the Boti ecosystem
|
|
5
|
+
Author: Luis Valverde
|
|
6
|
+
Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/lvalverdeb/boti-data
|
|
9
|
+
Project-URL: Repository, https://github.com/lvalverdeb/boti-data
|
|
10
|
+
Project-URL: Documentation, https://github.com/lvalverdeb/boti-data#readme
|
|
11
|
+
Project-URL: Issues, https://github.com/lvalverdeb/boti-data/issues
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: boti<2.0.0,>=1.0.2
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]<3.0.0,>=2.0.49
|
|
22
|
+
Provides-Extra: mysql
|
|
23
|
+
Requires-Dist: asyncmy<0.3.0,>=0.2.11; extra == "mysql"
|
|
24
|
+
Requires-Dist: pymysql<2.0.0,>=1.1.2; extra == "mysql"
|
|
25
|
+
Provides-Extra: postgres
|
|
26
|
+
Requires-Dist: asyncpg<0.32.0,>=0.30.0; extra == "postgres"
|
|
27
|
+
Requires-Dist: psycopg[binary]<4.0.0,>=3.2.0; extra == "postgres"
|
|
28
|
+
Provides-Extra: pgvector
|
|
29
|
+
Requires-Dist: pgvector<0.6.0,>=0.3.0; extra == "pgvector"
|
|
30
|
+
Provides-Extra: clickhouse
|
|
31
|
+
Requires-Dist: clickhouse-connect<2.0.0,>=1.6.0; extra == "clickhouse"
|
|
32
|
+
Provides-Extra: duckdb
|
|
33
|
+
Requires-Dist: duckdb-engine<0.18.0,>=0.17.0; extra == "duckdb"
|
|
34
|
+
Provides-Extra: dataframes
|
|
35
|
+
Requires-Dist: boti-dask<2.0.0,>=1.0.0; extra == "dataframes"
|
|
36
|
+
Requires-Dist: dask[dataframe,distributed]<2027.0.0,>=2026.3.0; extra == "dataframes"
|
|
37
|
+
Requires-Dist: pandas<4.0.0,>=3.0.2; extra == "dataframes"
|
|
38
|
+
Requires-Dist: polars<2.0.0,>=1.29.0; extra == "dataframes"
|
|
39
|
+
|
|
1
40
|
# boti-data
|
|
2
41
|
|
|
3
42
|
`boti-data` is the **data access and data transformation layer** of the Boti ecosystem.
|
|
@@ -96,8 +135,25 @@ pip install "boti[data]"
|
|
|
96
135
|
```bash
|
|
97
136
|
pip install "boti-data[mysql]" # asyncmy + pymysql
|
|
98
137
|
pip install "boti-data[postgres]" # asyncpg + psycopg[binary]
|
|
138
|
+
pip install "boti-data[clickhouse]" # clickhouse-connect (sync only)
|
|
139
|
+
pip install "boti-data[duckdb]" # duckdb-engine (sync only)
|
|
99
140
|
```
|
|
100
141
|
|
|
142
|
+
ClickHouse support is sync-only: there is no maintained async SQLAlchemy driver for ClickHouse, so `AsyncSqlDatabaseResource` rejects a ClickHouse connection URL — use `SqlDatabaseResource` instead. `SqlUnitOfWork`/`AsyncSqlUnitOfWork` also reject ClickHouse outright, since ClickHouse's transaction support is experimental, single-node, and MergeTree-only, with no real cross-table ACID guarantees to back a multi-table unit of work — use `SqlRepository`/`AsyncSqlRepository` directly, one table at a time, instead.
|
|
143
|
+
|
|
144
|
+
Verified against a real ClickHouse instance:
|
|
145
|
+
|
|
146
|
+
- `SqlRepository`/`AsyncSqlRepository` are a get/insert/update/delete-**by-primary-key** API, but MergeTree's `ORDER BY` is a sort/index prefix, not a uniqueness constraint — many real ClickHouse tables have several rows per `ORDER BY` key. Because of that, `get()`/`update()`/`delete()` each **refuse to run against a ClickHouse table unless called with `assume_unique_key=True`** (e.g. `repo.get(pk, assume_unique_key=True)`). This flag is a per-call caller assertion, not something boti-data verifies or enforces: it does not check the data, and if the key genuinely isn't unique, `get(key, assume_unique_key=True)` will still return one arbitrary matching row and `delete(key, assume_unique_key=True)`/`update(key, ..., assume_unique_key=True)` will still affect *every* row sharing that key, not just one. Only pass it once you've confirmed the key is actually unique for that table. `insert()` has no such parameter — appending a row is never ambiguous.
|
|
147
|
+
- `.update()` also fails outright on a table without `enable_block_number_column` enabled (`ALTER TABLE ... MODIFY SETTING enable_block_number_column = 1`, off by default) — ClickHouse raises `Lightweight updates are not supported` otherwise.
|
|
148
|
+
- `query_only=True` is enforced server-side (ClickHouse's `readonly` setting, applied via `set_client_setting` on connect), not just by boti-data's own read-only session guard.
|
|
149
|
+
|
|
150
|
+
DuckDB support is also sync-only (no maintained async driver, same as ClickHouse — `AsyncSqlDatabaseResource` rejects a DuckDB connection URL), but is otherwise a better fit than ClickHouse for `SqlRepository`'s by-primary-key model:
|
|
151
|
+
|
|
152
|
+
- **`SqlUnitOfWork`/`AsyncSqlUnitOfWork` are fully supported** — DuckDB has real multi-table ACID transactions (unlike ClickHouse), verified with an actual mid-transaction constraint violation correctly rolling back writes across two tables.
|
|
153
|
+
- **No `assume_unique_key`-style flag is needed** — DuckDB genuinely enforces `PRIMARY KEY`/`UNIQUE` constraints at the database level, and boti-data recovers the real key columns from DuckDB's catalog even though `duckdb-engine`'s own reflection can't see them (a known upstream limitation — it warns "doesn't yet support reflection on indices"). Without that recovery step, every DuckDB table would look keyless and `SqlRepository` would silently bind to an arbitrary first column instead of the real key — this was caught and fixed during implementation, not just assumed away.
|
|
154
|
+
- **`query_only=True` is enforced via a connect-time flag**, not a runtime statement: DuckDB's read-only mode (`duckdb.connect(read_only=True)`) can only be set when a connection is opened, so boti-data injects `connect_args={"read_only": True}` itself before the engine is even constructed, rather than configuring it afterward the way Postgres/MySQL/ClickHouse do.
|
|
155
|
+
- **DuckDB allows only one process to hold a database file open for read-write at a time** — opening a file in its default mode claims an exclusive lock on connect, even for a connection that only ever reads. `SqlPartitionedLoader` (which spawns one engine per worker process) requires `query_only=True` for a file-backed DuckDB DSN for exactly this reason — otherwise the first worker to connect would block every other worker, regardless of whether any of them write. Concurrent read-only access from multiple processes works fine. In-memory (`duckdb:///:memory:`) DSNs are rejected for distributed loading the same way SQLite's are, since each worker process would get its own independent, empty database.
|
|
156
|
+
|
|
101
157
|
If a Postgres table has a [pgvector](https://github.com/pgvector/pgvector) `vector` column, install `boti-data[pgvector]` too — this registers pgvector's `Vector` type with SQLAlchemy's reflection so `SqlAlchemyModelBuilder`/`SqlRepository`/`DataGateway` map that column to a usable `list[float]` (with its dimension preserved) instead of an unreflectable `NullType`:
|
|
102
158
|
|
|
103
159
|
```bash
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
boti<2.0.0,>=1.0.2
|
|
2
2
|
sqlalchemy[asyncio]<3.0.0,>=2.0.49
|
|
3
3
|
|
|
4
|
+
[clickhouse]
|
|
5
|
+
clickhouse-connect<2.0.0,>=1.6.0
|
|
6
|
+
|
|
4
7
|
[dataframes]
|
|
5
8
|
boti-dask<2.0.0,>=1.0.0
|
|
6
9
|
dask[dataframe,distributed]<2027.0.0,>=2026.3.0
|
|
7
10
|
pandas<4.0.0,>=3.0.2
|
|
8
11
|
polars<2.0.0,>=1.29.0
|
|
9
12
|
|
|
13
|
+
[duckdb]
|
|
14
|
+
duckdb-engine<0.18.0,>=0.17.0
|
|
15
|
+
|
|
10
16
|
[mysql]
|
|
11
17
|
asyncmy<0.3.0,>=0.2.11
|
|
12
18
|
pymysql<2.0.0,>=1.1.2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|