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.
Files changed (120) hide show
  1. {boti_data-1.3.6/src/boti_data.egg-info → boti_data-1.3.7}/PKG-INFO +22 -1
  2. boti_data-1.3.6/PKG-INFO → boti_data-1.3.7/README.md +17 -35
  3. {boti_data-1.3.6 → boti_data-1.3.7}/pyproject.toml +32 -1
  4. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_loader.py +15 -2
  5. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_engine.py +66 -3
  6. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_registry.py +39 -1
  7. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_repository.py +82 -6
  8. boti_data-1.3.6/README.md → boti_data-1.3.7/src/boti_data.egg-info/PKG-INFO +56 -0
  9. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data.egg-info/requires.txt +6 -0
  10. {boti_data-1.3.6 → boti_data-1.3.7}/setup.cfg +0 -0
  11. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/__init__.py +0 -0
  12. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/arrow_schema.py +0 -0
  13. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/connection_catalog.py +0 -0
  14. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/__init__.py +0 -0
  15. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/artifact.py +0 -0
  16. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/base.py +0 -0
  17. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/contract.py +0 -0
  18. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/datacube/resource.py +0 -0
  19. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/dataset/__init__.py +0 -0
  20. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/dataset/hybrid.py +0 -0
  21. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/dataset/hybrid_support.py +0 -0
  22. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/__init__.py +0 -0
  23. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/arrow_schema_mapper.py +0 -0
  24. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/engine_registry.py +0 -0
  25. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_execution.py +0 -0
  26. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_planner.py +0 -0
  27. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_planner_async.py +0 -0
  28. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_planning_math.py +0 -0
  29. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_row_materialization.py +0 -0
  30. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_statements.py +0 -0
  31. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/partitioned_types.py +0 -0
  32. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_config.py +0 -0
  33. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_manager.py +0 -0
  34. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_builder.py +0 -0
  35. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_module_injection.py +0 -0
  36. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_model_registry_types.py +0 -0
  37. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_readonly.py +0 -0
  38. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sql_resource.py +0 -0
  39. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/sqlalchemy_async.py +0 -0
  40. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/db/vector_search.py +0 -0
  41. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/__init__.py +0 -0
  42. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/async_enricher.py +0 -0
  43. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/composite.py +0 -0
  44. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/protocol.py +0 -0
  45. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/specs.py +0 -0
  46. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/enrichment/transformers.py +0 -0
  47. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/field_map.py +0 -0
  48. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/__init__.py +0 -0
  49. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels.py +0 -0
  50. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
  51. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
  52. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/arrow_kernels_string.py +0 -0
  53. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/backend_ops.py +0 -0
  54. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/expressions.py +0 -0
  55. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/handler.py +0 -0
  56. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/parquet_compiler.py +0 -0
  57. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/utils.py +0 -0
  58. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/filters/value_parsing.py +0 -0
  59. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/__init__.py +0 -0
  60. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_backend_strategies.py +0 -0
  61. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_factory.py +0 -0
  62. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_gateway_init.py +0 -0
  63. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_payloads.py +0 -0
  64. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/_series_filters.py +0 -0
  65. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/arrow_adapters.py +0 -0
  66. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/build_context.py +0 -0
  67. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/chunking.py +0 -0
  68. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/configured_load.py +0 -0
  69. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/core.py +0 -0
  70. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/core_describe.py +0 -0
  71. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/core_load.py +0 -0
  72. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/datacube_strategy.py +0 -0
  73. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/frame_convert.py +0 -0
  74. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/frame_strategies.py +0 -0
  75. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/incremental.py +0 -0
  76. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/load_execution.py +0 -0
  77. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/load_request.py +0 -0
  78. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/loaders.py +0 -0
  79. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/normalization.py +0 -0
  80. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/parquet_strategy.py +0 -0
  81. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/planning.py +0 -0
  82. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/policies.py +0 -0
  83. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/post_process.py +0 -0
  84. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/requests.py +0 -0
  85. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/return_type.py +0 -0
  86. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/select_cache.py +0 -0
  87. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/semi_join.py +0 -0
  88. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_describe.py +0 -0
  89. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_guard.py +0 -0
  90. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
  91. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_size_estimation.py +0 -0
  92. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_strategy.py +0 -0
  93. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
  94. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/helper.py +0 -0
  95. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/joins.py +0 -0
  96. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/__init__.py +0 -0
  97. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/config.py +0 -0
  98. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/discovery.py +0 -0
  99. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/file_info.py +0 -0
  100. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/filesystem.py +0 -0
  101. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/reader.py +0 -0
  102. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/reader_config.py +0 -0
  103. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/resource.py +0 -0
  104. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/parquet/schema_filters.py +0 -0
  105. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/__init__.py +0 -0
  106. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/base.py +0 -0
  107. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
  108. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/registry.py +0 -0
  109. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks.py +0 -0
  110. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks_common.py +0 -0
  111. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks_parquet.py +0 -0
  112. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/pipelines/sinks_text.py +0 -0
  113. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/py.typed +0 -0
  114. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/schema.py +0 -0
  115. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/watermark/__init__.py +0 -0
  116. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/watermark/incremental.py +0 -0
  117. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data/watermark/store.py +0 -0
  118. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data.egg-info/SOURCES.txt +0 -0
  119. {boti_data-1.3.6 → boti_data-1.3.7}/src/boti_data.egg-info/dependency_links.txt +0 -0
  120. {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.6
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.6"
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
- if parsed.get_backend_name() != "sqlite":
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
- "SQLite in-memory DSNs are not supported; use a file-backed DSN or request as_pandas=True."
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": config.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": config.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(self, pk: Any, values: dict[str, Any]) -> dict[str, Any] | None:
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(self, pk: Any, values: dict[str, Any]) -> dict[str, Any] | None:
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