bucketdb 0.1.0__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 (43) hide show
  1. bucketdb-0.1.0/.github/workflows/publish.yml +28 -0
  2. bucketdb-0.1.0/.gitignore +44 -0
  3. bucketdb-0.1.0/BUILD.md +42 -0
  4. bucketdb-0.1.0/CLAUDE.md +54 -0
  5. bucketdb-0.1.0/LICENSE +21 -0
  6. bucketdb-0.1.0/PKG-INFO +422 -0
  7. bucketdb-0.1.0/README.md +392 -0
  8. bucketdb-0.1.0/bucketdb/__init__.py +73 -0
  9. bucketdb-0.1.0/bucketdb/cli.py +310 -0
  10. bucketdb-0.1.0/bucketdb/config.py +38 -0
  11. bucketdb-0.1.0/bucketdb/connection.py +359 -0
  12. bucketdb-0.1.0/bucketdb/cursor.py +237 -0
  13. bucketdb-0.1.0/bucketdb/exceptions.py +41 -0
  14. bucketdb-0.1.0/bucketdb/meta.py +126 -0
  15. bucketdb-0.1.0/bucketdb/registry.py +124 -0
  16. bucketdb-0.1.0/bucketdb/transaction.py +433 -0
  17. bucketdb-0.1.0/bucketdb/writer.py +361 -0
  18. bucketdb-0.1.0/bucketdb.egg-info/PKG-INFO +422 -0
  19. bucketdb-0.1.0/bucketdb.egg-info/SOURCES.txt +41 -0
  20. bucketdb-0.1.0/bucketdb.egg-info/dependency_links.txt +1 -0
  21. bucketdb-0.1.0/bucketdb.egg-info/entry_points.txt +2 -0
  22. bucketdb-0.1.0/bucketdb.egg-info/requires.txt +8 -0
  23. bucketdb-0.1.0/bucketdb.egg-info/scm_file_list.json +37 -0
  24. bucketdb-0.1.0/bucketdb.egg-info/scm_version.json +8 -0
  25. bucketdb-0.1.0/bucketdb.egg-info/top_level.txt +1 -0
  26. bucketdb-0.1.0/examples/.query_cli_history +8 -0
  27. bucketdb-0.1.0/examples/benchmark_bulk_insert.py +134 -0
  28. bucketdb-0.1.0/examples/demo_prodotti_vendite_negozi.py +205 -0
  29. bucketdb-0.1.0/examples/join_dati.py +124 -0
  30. bucketdb-0.1.0/examples/mostra_dati.py +81 -0
  31. bucketdb-0.1.0/pyproject.toml +50 -0
  32. bucketdb-0.1.0/setup.cfg +4 -0
  33. bucketdb-0.1.0/tests/__init__.py +0 -0
  34. bucketdb-0.1.0/tests/conftest.py +71 -0
  35. bucketdb-0.1.0/tests/test_connection.py +54 -0
  36. bucketdb-0.1.0/tests/test_cursor.py +76 -0
  37. bucketdb-0.1.0/tests/test_ddl.py +60 -0
  38. bucketdb-0.1.0/tests/test_discovery.py +95 -0
  39. bucketdb-0.1.0/tests/test_dml.py +207 -0
  40. bucketdb-0.1.0/tests/test_index.py +330 -0
  41. bucketdb-0.1.0/tests/test_pep249.py +455 -0
  42. bucketdb-0.1.0/tests/test_preload.py +171 -0
  43. bucketdb-0.1.0/tests/test_transaction.py +447 -0
@@ -0,0 +1,28 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write # required for OIDC Trusted Publishing
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0 # setuptools-scm needs full history to derive version from tags
19
+
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - run: pip install build
25
+
26
+ - run: python -m build
27
+
28
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,44 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.egg
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ wheels/
11
+ *.whl
12
+ .eggs/
13
+ MANIFEST
14
+
15
+ # Virtual environments
16
+ .venv/
17
+ venv/
18
+ env/
19
+ .env
20
+
21
+ # Testing
22
+ .pytest_cache/
23
+ .coverage
24
+ .coverage.*
25
+ coverage.xml
26
+ htmlcov/
27
+
28
+ # Type checking
29
+ .mypy_cache/
30
+ .pyright/
31
+ .ruff_cache/
32
+
33
+ # IDE
34
+ .idea/
35
+ .vscode/
36
+ *.iml
37
+
38
+ # DuckDB
39
+ *.duckdb
40
+ *.duckdb.wal
41
+
42
+ # OS
43
+ .DS_Store
44
+ Thumbs.db
@@ -0,0 +1,42 @@
1
+ # Build e pubblicazione su PyPI
2
+
3
+ ## Prerequisiti
4
+
5
+ ```bash
6
+ pip install build twine
7
+ ```
8
+
9
+ ## Build
10
+
11
+ ```bash
12
+ python -m build
13
+ ```
14
+
15
+ Produce in `dist/`:
16
+ - `bucketdb-0.1.0.tar.gz` — source distribution
17
+ - `bucketdb-0.1.0-py3-none-any.whl` — wheel
18
+
19
+ ## Pubblicazione su PyPI
20
+
21
+ Serve un API token da pypi.org → Account settings → API tokens.
22
+
23
+ ```bash
24
+ twine upload dist/*
25
+ ```
26
+
27
+ Credenziali:
28
+ - username: `__token__`
29
+ - password: `pypi-...` (il token)
30
+
31
+ ## Aggiornare la versione
32
+
33
+ 1. Modificare `version` in `pyproject.toml`
34
+ 2. Rifare build: `rm -rf dist/ && python -m build`
35
+ 3. Caricare: `twine upload dist/*`
36
+
37
+ ## Verifica locale prima di pubblicare
38
+
39
+ ```bash
40
+ pip install dist/bucketdb-0.1.0-py3-none-any.whl
41
+ python -c "import bucketdb; print(bucketdb.apilevel)"
42
+ ```
@@ -0,0 +1,54 @@
1
+ # bucketdb
2
+
3
+ PEP 249 (DB-API 2.0) SQL driver backed by DuckDB + S3. Each table is a Parquet
4
+ file on S3; DuckDB is the query engine; boto3 talks to S3 for anything DuckDB's
5
+ `httpfs` extension doesn't cover (listing, deleting, ETag checks).
6
+
7
+ ## Architecture
8
+
9
+ - `bucketdb/__init__.py` — `connect()` entry point, PEP 249 module attributes/types
10
+ - `bucketdb/config.py` — `S3Config` dataclass (bucket, credentials, prefix, endpoint)
11
+ - `bucketdb/connection.py` — `S3QLConnection`; sets up DuckDB's `httpfs` extension for S3 access
12
+ - `bucketdb/registry.py` — discovers `.parquet` files on S3 at connect time, maps them to DuckDB views
13
+ - `bucketdb/cursor.py` — `S3QLCursor`; dispatches SELECT vs DML (INSERT/UPDATE/DELETE, buffered) vs DDL (CREATE/DROP/ALTER, immediate)
14
+ - `bucketdb/transaction.py` — buffers DML in an in-memory DuckDB temp table per dirty table; on `commit()`, verifies the ETag of **every** dirty table before writing **any** of them (no partial commit on conflict). Also exposes `preload(*tables)` / `unload(*tables)` for explicit in-memory caching. Tracks `_loaded` (all tables in memory) and `_modified` (tables with pending DML) separately — `flush()` writes only `_modified`. A table in `_loaded` but not `_modified` was necessarily preloaded explicitly (the only other path into `_loaded` is `apply()`, which always adds to `_modified`).
15
+ - `bucketdb/writer.py` — copy-on-write to S3 for DDL (`_create_table`/`_drop_table`); also has dead `_insert`/`_update`/`_delete`/`_copy_on_write` functions unreachable from `cursor.py` (DML always goes through `transaction.py` instead)
16
+ - `bucketdb/cli.py` — the `bucketdb` console command (interactive SQL shell + one-shot queries), registered via `[project.scripts]` in `pyproject.toml`
17
+
18
+ ## DuckDB API gotchas (bit us once — pyarrow/duckdb version drift)
19
+
20
+ The pinned dependency floors (`duckdb>=0.10`) are loose; whatever gets installed today may not behave like 0.10 did:
21
+
22
+ - `.arrow()` on a DuckDB relation can return a `pyarrow.RecordBatchReader` instead of a `pyarrow.Table` (breaks `pq.write_table`). Use `.to_arrow_table()`.
23
+ - `SELECT changes()` no longer exists as a scalar function. Row count comes from `fetchone()` on the DML statement's own result — DuckDB returns `[(n_affected,)]` for INSERT/UPDATE/DELETE.
24
+
25
+ If tests suddenly fail after a `pip install --upgrade duckdb`, suspect the installed `duckdb`/`pyarrow` API surface first.
26
+
27
+ ## Testing
28
+
29
+ `moto`'s `@mock_aws` decorator only patches boto3/botocore — it does **not** intercept DuckDB's `httpfs` extension, which speaks raw HTTP directly to S3. Reading through a DuckDB view (`read_parquet('s3://...')`) with decorator-based mocking will silently hit **real AWS** and hang or 403.
30
+
31
+ `tests/conftest.py` therefore runs a real local `moto.server.ThreadedMotoServer` and points both boto3 and `bucketdb.connect(endpoint_url=...)` at it. Any new test that opens its own connection or boto3 client (rather than using the `conn`/`s3` fixtures) must pass `endpoint_url=moto_server` too, or it will hit real AWS the same way.
32
+
33
+ ```bash
34
+ pip install -e ".[dev]" # moto[s3,server] needs flask — pulled in automatically
35
+ pytest tests/
36
+ ```
37
+
38
+ ## The `bucketdb` CLI
39
+
40
+ `bucketdb/cli.py` reads connection parameters with priority CLI flags → environment variables → `.env` file in the cwd (same keys: `URL`, `ID`, `SECRET`, `BUCKET`, `REGION`, `PREFIX`). After `pip install -e .`, the `bucketdb` command is on PATH inside the venv.
41
+
42
+ ```bash
43
+ bucketdb # interactive shell (arrow keys + history in ~/.bucketdb_history)
44
+ bucketdb "SELECT * FROM orders" # one-shot query
45
+ bucketdb .tables # list discovered tables
46
+ ```
47
+
48
+ ## Examples (manual, hit real S3 — not pytest)
49
+
50
+ `examples/*.py` connect to a real bucket using `.env` (see README's "Examples" section). They are intentionally outside `tests/` so `pytest` never touches real infrastructure. `demo_prodotti_vendite_negozi.py` is **not idempotent** — rerunning it re-inserts the same rows (INSERT, not upsert), duplicating data.
51
+
52
+ ## Local `.env`
53
+
54
+ Gitignored. Keys: `URL` (S3-compatible endpoint, e.g. S3-compatible service), `ID`, `SECRET`, `BUCKET`, `REGION`, `PREFIX`. Real credentials live only in the untracked local file.
bucketdb-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 buzzobuono
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,422 @@
1
+ Metadata-Version: 2.4
2
+ Name: bucketdb
3
+ Version: 0.1.0
4
+ Summary: PEP 249 SQL driver backed by DuckDB + S3 (Parquet)
5
+ Author: buzzobuono
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/buzzobuono/bucketdb
8
+ Project-URL: Repository, https://github.com/buzzobuono/bucketdb
9
+ Project-URL: Issues, https://github.com/buzzobuono/bucketdb/issues
10
+ Keywords: s3,duckdb,parquet,database,sql,pep249,db-api
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Database :: Front-Ends
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: duckdb>=0.10
23
+ Requires-Dist: boto3>=1.34
24
+ Requires-Dist: pyarrow>=15
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: pytest-mock; extra == "dev"
28
+ Requires-Dist: moto[s3,server]>=5; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # bucketdb
32
+
33
+ A PEP 249-compliant Python SQL driver backed by **DuckDB** and **S3**.
34
+
35
+ Each table is stored as one or more Parquet files on S3. DuckDB is the query engine. The driver exposes a standard DB-API 2.0 interface so it works as a drop-in wherever a Python database driver is expected.
36
+
37
+ ## How it works
38
+
39
+ ```
40
+ Your code → PEP 249 driver → DuckDB (in-memory) → S3 (Parquet files)
41
+ ```
42
+
43
+ - One **bucket** = one database
44
+ - One **table** = a directory on S3 with a `_meta.json` file and one or more Parquet data files
45
+ - Tables are discovered automatically at connect time
46
+ - DML is **buffered in memory** and written to S3 on `commit()`
47
+ - Concurrent writes are detected via **ETag check** on `_meta.json` at commit time
48
+
49
+ ## Requirements
50
+
51
+ - Python ≥ 3.10
52
+ - `duckdb` ≥ 0.10
53
+ - `boto3` ≥ 1.34
54
+ - `pyarrow` ≥ 15
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install bucketdb
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```python
65
+ import bucketdb
66
+
67
+ conn = bucketdb.connect(
68
+ bucket="my-bucket",
69
+ aws_access_key_id="AKIAIOSFODNN7EXAMPLE",
70
+ aws_secret_access_key="wJalrXUtnFEMI/K7MDENG",
71
+ aws_region="eu-west-1",
72
+ prefix="warehouse/", # optional
73
+ endpoint_url="http://...", # optional, for MinIO or S3-compatible backends
74
+ )
75
+
76
+ cur = conn.cursor()
77
+
78
+ cur.execute("CREATE TABLE orders (id INTEGER PRIMARY KEY, item VARCHAR, amount DOUBLE)")
79
+ cur.execute("INSERT INTO orders VALUES (?, ?, ?)", [1, "apple", 9.99])
80
+ conn.commit()
81
+
82
+ cur.execute("SELECT * FROM orders WHERE amount > ?", [5.0])
83
+ for row in cur.fetchall():
84
+ print(row)
85
+
86
+ conn.close()
87
+ ```
88
+
89
+ Context manager is supported:
90
+
91
+ ```python
92
+ with bucketdb.connect(bucket="my-bucket", ...) as conn:
93
+ with conn.cursor() as cur:
94
+ cur.execute("SELECT COUNT(*) FROM orders")
95
+ print(cur.fetchone())
96
+ ```
97
+
98
+ ## How SELECT, INSERT, UPDATE and DELETE work
99
+
100
+ Understanding what happens under the hood helps choose the right index strategy.
101
+
102
+ ### S3 layout
103
+
104
+ Every table lives in a directory on S3:
105
+
106
+ ```
107
+ s3://bucket/prefix/orders/
108
+ _meta.json ← schema, index info, ordered list of data files
109
+ data/
110
+ part-a1b2c3.parquet ← immutable data file
111
+ part-d4e5f6.parquet ← immutable data file
112
+ ...
113
+ ```
114
+
115
+ `_meta.json` is the single source of truth. It lists every data file that belongs to the table, the schema, and any index configuration. Its ETag is the optimistic lock used to detect concurrent writes. Data files are immutable — writes always produce new files; old files become orphans cleaned up by `vacuum()`.
116
+
117
+ ---
118
+
119
+ ### SELECT
120
+
121
+ DuckDB reads directly from S3 via HTTP range requests. Each Parquet file has a **footer** containing min/max statistics for every column in every row group (~128 k rows each). DuckDB uses these statistics to skip entire row groups without downloading them — this is called **predicate pushdown**.
122
+
123
+ | Configuration | S3 reads | Notes |
124
+ |---|---|---|
125
+ | **No index** | all files, all row groups (full scan) | data in insertion order, overlapping min/max |
126
+ | **Sort key** | all files, few row groups | tight non-overlapping min/max on leading column(s); a point query on 10 M rows reads 1–2 row groups instead of 80 |
127
+ | **Partitioned** | only files for matching partition values | file-level pruning via `_meta.json`; files for other partitions are never opened |
128
+ | **Partitioned + sort key** | only matching partition files, few row groups within them | two-level pruning: file-level first, then row-group within each file |
129
+
130
+ **Example** — `WHERE region='IT' AND date>'2024-01-01'` on a table partitioned by `region` with sort key `date`:
131
+ 1. `_meta.json` is read; only the IT partition file is passed to DuckDB
132
+ 2. DuckDB reads the IT file footer and skips row groups where `max(date) ≤ '2024-01-01'`
133
+ 3. Only the qualifying row groups are fetched via HTTP range requests
134
+
135
+ ---
136
+
137
+ ### INSERT
138
+
139
+ New rows are never merged with existing data at write time.
140
+
141
+ | Configuration | S3 reads at commit | S3 writes at commit | Notes |
142
+ |---|---|---|---|
143
+ | **No index** | none | 1 new file | rows appended as-is |
144
+ | **Sort key** | none | 1 new file | new rows sorted before writing |
145
+ | **Partitioned** | none | 1 new file per distinct partition value in the new rows | existing files untouched |
146
+ | **Partitioned + sort key** | none | 1 new file per partition, sorted by sort key within each | existing files untouched |
147
+
148
+ Each commit appends one or more small files. Over time files accumulate — use `vacuum()` to consolidate. During the transaction the view is `existing S3 files UNION ALL in-memory buffer`, so SELECT sees the full picture.
149
+
150
+ ---
151
+
152
+ ### UPDATE and DELETE
153
+
154
+ UPDATE and DELETE require identifying and modifying specific rows. The amount of data read from S3 depends on how precisely the WHERE clause maps to the physical file layout.
155
+
156
+ | Configuration | S3 reads | S3 writes | Notes |
157
+ |---|---|---|---|
158
+ | **No index** | all files (full scan) | 1 new consolidated file | full rewrite; side-effect: compacts all INSERT deltas |
159
+ | **Sort key** | all files (full scan) | 1 new consolidated file | sort key helps SELECT but not UPDATE/DELETE reads |
160
+ | **Partitioned** — exact filter on all partition columns | only matching partition files | 1 new file per affected partition | unaffected partition files kept as-is on S3 |
161
+ | **Partitioned** — no exact partition filter | all files (full scan) | 1 new file per partition | falls back to full load |
162
+ | **Partitioned + sort key** — exact filter | only matching partition files | 1 new file per affected partition, sorted | unaffected files kept as-is |
163
+ | **Partitioned + sort key** — no exact filter | all files (full scan) | 1 new file per partition, sorted | falls back to full load |
164
+
165
+ **Partition filter extraction** is done by parsing the WHERE clause for equality conditions (`col = 'value'` or `col = 42`). Range conditions, `IN` lists, or expressions involving partition columns do not trigger partial load — the driver falls back to full load.
166
+
167
+ If two UPDATE/DELETE statements in the same transaction target different partitions, the driver upgrades to a full load on the second statement to guarantee correctness.
168
+
169
+ ---
170
+
171
+ ### Vacuum
172
+
173
+ As INSERT commits accumulate, the table grows from one file to many. `vacuum()` consolidates them:
174
+
175
+ ```python
176
+ conn.vacuum("orders")
177
+ ```
178
+
179
+ 1. All data files are read and merged in memory
180
+ 2. If a sort key is set, the merged data is sorted
181
+ 3. The result is written as a single new file (or one file per partition for partitioned tables)
182
+ 4. `_meta.json` is updated; old files are deleted from S3
183
+
184
+ | Configuration | After vacuum |
185
+ |---|---|
186
+ | **No index** | 1 file, insertion order |
187
+ | **Sort key** | 1 file, sorted by key columns |
188
+ | **Partitioned** | 1 file per distinct partition value |
189
+ | **Partitioned + sort key** | 1 file per partition, sorted within each |
190
+
191
+ Run vacuum during low-traffic windows. There is no auto-vacuum — call it explicitly when the file count in `_meta.json` grows large.
192
+
193
+ ---
194
+
195
+ ## Transactions
196
+
197
+ Writes are buffered in memory until `commit()` is called. At commit time the driver checks the ETag of `_meta.json` for each modified table. If the file was changed by a concurrent writer the commit raises `OperationalError` and the transaction remains active so the caller can roll back.
198
+
199
+ ```python
200
+ try:
201
+ cur.execute("UPDATE orders SET amount = 0.0 WHERE id = 1")
202
+ conn.commit()
203
+ except bucketdb.OperationalError:
204
+ conn.rollback() # discard in-memory buffer, no S3 write
205
+ ```
206
+
207
+ **Known limitations:**
208
+
209
+ - DDL (`CREATE TABLE`, `DROP TABLE`) is immediate and not transactional
210
+ - ETags for every dirty table are verified before any table is written, so a conflict on one table blocks the whole commit — no table is left partially written. There is still no cross-object atomicity on S3 itself
211
+ - `rollback()` has no effect on DDL statements
212
+ - Two concurrent INSERT transactions where neither finds an existing `_meta.json` (first INSERT ever on a new table) have a small race window — the second writer may overwrite the first silently. This edge case requires S3 conditional writes to be fully eliminated; document your tables as single-writer at creation time if needed
213
+
214
+ ---
215
+
216
+ ## Indexes
217
+
218
+ Indexes control the physical layout of data files and the sort order within them. Each table supports one index. An index is persisted in `_meta.json` — no separate index files exist.
219
+
220
+ ### Sort key
221
+
222
+ ```sql
223
+ CREATE INDEX idx_date ON orders (date)
224
+ CREATE INDEX idx ON orders (region, date) -- compound: sort by region first, then date
225
+ ```
226
+
227
+ **What it optimises:** SELECT predicate pushdown. Data is written sorted by the index columns at commit time (for UPDATE/DELETE) or at vacuum time (for INSERT). DuckDB row-group statistics become tight and non-overlapping, so filters on the leading column(s) skip most row groups without reading them.
228
+
229
+ **Analogy:** a clustered index in a traditional database. The physical order of rows on disk (here: in the Parquet file) matches the index order.
230
+
231
+ The column order matters: `WHERE region='IT' AND date>'2024-01-01'` prunes well on a `(region, date)` sort key. `WHERE date>'2024-01-01'` alone does not prune on the `region` level.
232
+
233
+ ### Partitioned index
234
+
235
+ ```sql
236
+ CREATE INDEX idx_region ON orders (region) PARTITIONED
237
+ CREATE INDEX idx ON orders (region, year) PARTITIONED -- two-level partition
238
+ ```
239
+
240
+ **What it optimises:** file-level pruning on SELECT, and write locality on INSERT (rows for the same partition go to the same file). Each distinct combination of partition column values is stored in a separate Parquet file. A query with `WHERE region='IT'` reads only the files whose metadata records `region=IT` — files for other regions are never opened.
241
+
242
+ **Use for:** low-cardinality columns (region, status, year, category). High-cardinality columns (id, timestamp, email) produce one file per value — catastrophic for both S3 costs and query performance.
243
+
244
+ **UPDATE/DELETE optimisation:** when the `WHERE` clause contains exact equality filters on all partition columns, the driver loads and rewrites only the matching partition files. Files for other partitions are left untouched on S3.
245
+
246
+ ### Primary key → automatic sort key
247
+
248
+ ```sql
249
+ CREATE TABLE orders (id INTEGER PRIMARY KEY, item VARCHAR, amount DOUBLE)
250
+ ```
251
+
252
+ A `PRIMARY KEY` declaration automatically sets the sort key to the PK columns. No separate `CREATE INDEX` is needed. Lookup queries `WHERE id = 42` benefit immediately from row-group predicate pushdown after the first vacuum.
253
+
254
+ Note: uniqueness is enforced by DuckDB within a single transaction but **not across commits**. Two separate commits can insert the same `id` value. If uniqueness across commits is required, enforce it at the application layer.
255
+
256
+ ### DDL reference
257
+
258
+ ```sql
259
+ -- Sort key
260
+ CREATE INDEX idx_name ON table (col1, col2)
261
+ CREATE INDEX IF NOT EXISTS idx_name ON table (col)
262
+
263
+ -- Partitioned
264
+ CREATE INDEX idx_name ON table (col1) PARTITIONED
265
+ CREATE INDEX idx_name ON table (col1, col2) PARTITIONED
266
+
267
+ -- Drop (merges all files back into one flat file for partitioned tables)
268
+ DROP INDEX idx_name
269
+ DROP INDEX IF EXISTS idx_name
270
+ ```
271
+
272
+ `DROP TABLE` on an indexed table removes all data files and `_meta.json`. `DROP INDEX` on a partitioned table consolidates all partition files into a single flat file.
273
+
274
+ ### Limitations
275
+
276
+ - One index per table
277
+ - `CREATE INDEX` on a non-empty table rewrites the data immediately (outside the transaction buffer) — run during low-traffic windows
278
+ - Partition values containing `/` or `=` are not supported
279
+ - Partition pruning is metadata-driven (file list filtered before passing to DuckDB), not Hive-style glob — file-level pruning works correctly but DuckDB cannot do further intra-file pruning based on the partition column alone
280
+ - Range partitioning (e.g. partition by month from a daily timestamp) is not yet supported — add a derived column and partition on that
281
+ - **Partition filter extraction is limited to simple equality conditions** (`col = 'value'` or `col = 42`) — `IN` lists, `BETWEEN`, `OR`, and compound expressions fall back to a full load of all partition files. This affects both SELECT (file-level pruning) and UPDATE/DELETE (partial load)
282
+
283
+ ---
284
+
285
+ ## In-memory cache: preload and unload
286
+
287
+ For read-heavy workloads, tables can be explicitly loaded into memory to avoid repeated S3 round trips. Once preloaded, every SELECT on that table reads from memory — zero HTTP requests to S3.
288
+
289
+ ```python
290
+ conn.preload("orders", "customers") # load into memory once
291
+ cur.execute("SELECT ...") # → memory
292
+ cur.execute("SELECT ...") # → memory
293
+ conn.close() # memory freed, nothing written to S3
294
+ ```
295
+
296
+ Tables not preloaded retain the default DuckDB behaviour: predicate pushdown and column projection directly against S3, with HTTP range requests fetching only the needed row groups and columns.
297
+
298
+ Both patterns can coexist in the same session:
299
+
300
+ ```python
301
+ conn.preload("customers") # lookup table — many reads, load once
302
+ cur.execute("INSERT INTO orders ...") # orders: only new rows buffered, no full load
303
+ conn.commit()
304
+ ```
305
+
306
+ To release a preloaded table and restore S3 pushdown:
307
+
308
+ ```python
309
+ conn.unload("orders") # drops temp table, view points back to S3
310
+ ```
311
+
312
+ `unload` raises `ProgrammingError` if the table has uncommitted changes.
313
+
314
+ **No local writes** — preloaded data lives exclusively in DuckDB's in-memory buffer. `SET temp_directory=''` is set at connect time so DuckDB raises `OutOfMemoryError` rather than spilling to disk.
315
+
316
+ ---
317
+
318
+ ## Supported SQL
319
+
320
+ Anything DuckDB understands — window functions, CTEs, aggregates, joins across tables in the same bucket.
321
+
322
+ ```sql
323
+ SELECT o.item, SUM(o.amount) AS total
324
+ FROM orders o
325
+ JOIN customers c ON o.customer_id = c.id
326
+ GROUP BY o.item
327
+ HAVING total > 100
328
+ ```
329
+
330
+ ---
331
+
332
+ ## Command line
333
+
334
+ `pip install -e .` registers an `bucketdb` command: an interactive SQL shell (arrow-key line editing, persistent history in `~/.bucketdb_history`) or a one-shot query runner.
335
+
336
+ ### Connection parameters
337
+
338
+ Resolved per-parameter in this order — each parameter is independent, so mixed configurations are valid:
339
+
340
+ 1. **CLI flags** — `--bucket`, `--id`, `--secret`, `--region`, `--prefix`, `--endpoint-url`
341
+ 2. **Environment variables** — `BUCKET`, `ID`, `SECRET`, `REGION`, `PREFIX`, `URL`
342
+ 3. **`.env` file** — same keys, looked up in the **current working directory** by default; override with `--env-file`
343
+
344
+ ```bash
345
+ bucketdb # interactive shell, params from .env
346
+ bucketdb "SELECT * FROM orders" # one-shot query
347
+ bucketdb ".status" # dot command one-shot
348
+ bucketdb --bucket other-bucket "SELECT * FROM orders" # override only bucket, rest from .env
349
+ BUCKET=test bucketdb "SELECT * FROM orders" # override via env var
350
+ ```
351
+
352
+ ### Dot commands
353
+
354
+ | Command | Description |
355
+ |---|---|
356
+ | `.help` | list all dot commands |
357
+ | `.tables` | list tables discovered in the bucket |
358
+ | `.schema <table>` | show column names and types |
359
+ | `.preload <table> [...]` | load tables into memory |
360
+ | `.unload <table> [...]` | release tables from memory |
361
+ | `.vacuum <table>` | compact data files, apply sort key |
362
+ | `.status` | show bucket, prefix, endpoint, transaction state |
363
+ | `.exit` / `.quit` | close the shell |
364
+
365
+ ### Output format
366
+
367
+ | Statement | Output |
368
+ |---|---|
369
+ | `SELECT` | aligned table with header and separator |
370
+ | `INSERT` / `UPDATE` / `DELETE` | `OK (N righe modificate)` — autocommit applied |
371
+ | `CREATE` / `DROP` | `OK` |
372
+ | Error | error message printed, shell continues |
373
+
374
+ ---
375
+
376
+ ## PEP 249 compliance
377
+
378
+ | Feature | Status |
379
+ |---|---|
380
+ | Module attributes (`apilevel`, `threadsafety`, `paramstyle`) | ✓ |
381
+ | Exception hierarchy | ✓ |
382
+ | `Connection`: `close`, `commit`, `rollback`, `cursor` | ✓ |
383
+ | `Cursor`: all mandatory methods | ✓ |
384
+ | `description` — 7-item tuples | ✓ |
385
+ | `rowcount`, `arraysize` | ✓ |
386
+ | `setinputsizes`, `setoutputsize` | ✓ (no-op) |
387
+ | Type objects and constructors | ✓ |
388
+ | `callproc` | — (no stored procedures in DuckDB) |
389
+ | `nextset` | — (single result set per execute) |
390
+
391
+ ---
392
+
393
+ ## Examples
394
+
395
+ `examples/` has standalone scripts (not pytest tests — they hit a **real** S3-compatible bucket) that read connection parameters from a `.env` file in the project root (`URL`, `ID`, `SECRET`, `BUCKET`, `REGION`, `PREFIX`):
396
+
397
+ - `demo_prodotti_vendite_negozi.py` — creates and seeds a small 3-table schema (`negozi`, `prodotti`, `vendite`)
398
+ - `mostra_dati.py` — lists every discovered table and prints its contents
399
+ - `join_dati.py` — joins the three tables (detail rows, aggregate revenue, grand total)
400
+
401
+ ```bash
402
+ python examples/demo_prodotti_vendite_negozi.py
403
+ python examples/mostra_dati.py
404
+ python examples/join_dati.py
405
+ ```
406
+
407
+ ---
408
+
409
+ ## Development
410
+
411
+ ```bash
412
+ pip install -e ".[dev]"
413
+ pytest tests/
414
+ ```
415
+
416
+ Tests use [moto](https://github.com/getmoto/moto) to mock S3 — no real AWS account needed. Because DuckDB's `httpfs` extension speaks raw HTTP directly to S3 (bypassing boto3/botocore), the fixtures run a real local `moto.server.ThreadedMotoServer` rather than the `@mock_aws` decorator, so both boto3 and DuckDB hit the same mock.
417
+
418
+ ---
419
+
420
+ ## Future improvements
421
+
422
+ - **Richer partition filter extraction** — the current WHERE clause parser recognises only simple equality conditions (`col = value`). Extending it to handle `col IN (...)`, conjunctions (`col1 = v1 AND col2 = v2`), and basic range expressions would allow the driver to prune partition files in a much wider set of real-world queries, both for SELECT (file-level pruning against `_meta.json`) and for UPDATE/DELETE (partial load, avoiding a full table read when only a subset of partitions is affected)