From 5af5c5288e533aac45a291a10427d008b45dcc1f Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:40:15 -0400 Subject: [PATCH 1/5] test(python): assert query rows are keyed in projection order --- .../integration/binding/column_order_test.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/python/tests/integration/binding/column_order_test.py diff --git a/packages/python/tests/integration/binding/column_order_test.py b/packages/python/tests/integration/binding/column_order_test.py new file mode 100644 index 000000000..a71e2ccb8 --- /dev/null +++ b/packages/python/tests/integration/binding/column_order_test.py @@ -0,0 +1,52 @@ +"""Binding-tier tests: a query's rows are keyed in the SELECT list's order.""" + +import os + +import pytest + +from dirsql import DirSQL, Table + +COLUMNS = ["a", "b", "c", "d", "e", "f", "g", "h"] + + +def _db(tmp_dir): + with open(os.path.join(tmp_dir, "row.json"), "w") as f: + f.write("{}") + return DirSQL( + tmp_dir, + tables=[ + Table( + name="t", + ddl=f"CREATE TABLE t ({', '.join(COLUMNS)})", + glob="*.json", + on_file=lambda path: [{c: i for i, c in enumerate(COLUMNS)}], + ) + ], + ) + + +def describe_query_column_order(): + @pytest.mark.asyncio + async def it_keys_rows_in_the_select_list_order(tmp_dir): + db = _db(tmp_dir) + projection = ["f", "c", "h", "a", "e", "b", "g", "d"] + + rows = await db.query(f"SELECT {', '.join(projection)} FROM t") + + assert list(rows[0]) == projection + + @pytest.mark.asyncio + async def it_follows_the_select_list_when_it_is_reversed(tmp_dir): + db = _db(tmp_dir) + projection = list(reversed(COLUMNS)) + + rows = await db.query(f"SELECT {', '.join(projection)} FROM t") + + assert list(rows[0]) == projection + + @pytest.mark.asyncio + async def it_keys_select_star_in_declaration_order(tmp_dir): + db = _db(tmp_dir) + rows = await db.query("SELECT * FROM t") + + assert list(rows[0]) == COLUMNS From 12e70d71aae5011fbe39542b063fef916a2e1cef Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:44:51 -0400 Subject: [PATCH 2/5] fix(python): key query rows in projection order Build each row dict from the core's query_ordered columns instead of iterating the row's HashMap, so the dict's insertion order is the order the SELECT list asked for. --- PARITY.md | 2 +- docs/reference/sdk.md | 3 +++ ...26-09-24-query-rows-in-projection-order.md | 1 + packages/python/src/lib.rs | 20 ++++++++++++++++--- 4 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 packages/python/changelog.d/2026-09-24-query-rows-in-projection-order.md diff --git a/PARITY.md b/PARITY.md index 752892b4c..432bc2442 100644 --- a/PARITY.md +++ b/PARITY.md @@ -88,7 +88,7 @@ core, so all three SDKs change at once. **No drift.** |----------------------------|------------------------------------------------|------------------------------------------------------|---------------------------------------------------------| | Constructor | `DirSQL(root=None, *, tables=None, ignore=None, no_ignore=False, config=None, persist=False, persist_path=None, extensions=None)` | `DirSQL::builder().root(..).tables(..).ignore(..).config(..).persist(Option).extensions(..).build()` (also `DirSQL::new`/`with_ignore` shortcuts) | `new DirSQL(configPath)` or `new DirSQL({ root?, tables?, ignore?, config?, persist?, persistPath?, extensions?, noIgnore? })` + `await db.ready` | | Query (read-only; rejects non-SELECT) | `db.query(sql) -> list[dict]` | `db.query(sql) -> Result>` | `await db.query(sql) -> Record[]` (runs on libuv threadpool) | -| Query carrying the projection order | — (drift, #1173) | `db.query_ordered(sql) -> Result` (`columns` + `rows`) | `await db.queryOrdered(sql) -> QueryResult` (`columns` + `rows`) | +| Query carrying the projection order | `db.query(sql)`: each row dict is keyed in projection order | `db.query_ordered(sql) -> Result` (`columns` + `rows`) | `await db.queryOrdered(sql) -> QueryResult` (`columns` + `rows`) | | Start watcher | `db._start_watcher()` | `db.start_watching()` | `await db.startWatcher()` (runs on libuv threadpool) | | Poll events | `db._poll_events(ms)` | `db.poll_events(duration)` | `await db.pollEvents(ms)` (runs on libuv threadpool) | | Watch (channel/stream) | `async for event in db.watch()` (via `_async.py`) | `db.watch() -> WatchStream` (channel) | `for await (const ev of db.watch())` | diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index 234445fef..1095e01a0 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -223,6 +223,9 @@ Executes a SQL query and returns rows keyed by column name. [Read-only by design](../explanation#read-only-by-design). - Internal tracking columns (`_dirsql_file_path`, `_dirsql_row_index`) are excluded from `SELECT *` results; name them explicitly to see them. +- **Column order.** Python row dicts are keyed in the order the SELECT list + names the columns. Rust and TypeScript rows do not carry that order; use + [`queryOrdered`](#queryordered). - SQLite values map back to language types: | SQLite | Python | TypeScript | Rust | diff --git a/packages/python/changelog.d/2026-09-24-query-rows-in-projection-order.md b/packages/python/changelog.d/2026-09-24-query-rows-in-projection-order.md new file mode 100644 index 000000000..601f508da --- /dev/null +++ b/packages/python/changelog.d/2026-09-24-query-rows-in-projection-order.md @@ -0,0 +1 @@ +**Fixed** `db.query()` rows are now dicts keyed in the order the SELECT list names the columns, so `list(row)` gives the projection order (`SELECT *` follows the table's declared column order). Keys previously came out in the core's hash-map order, which varied from run to run, so a caller rendering columns had no order to follow. diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs index 918f42a25..602dfe1a1 100644 --- a/packages/python/src/lib.rs +++ b/packages/python/src/lib.rs @@ -193,11 +193,13 @@ mod python { fn query(&self, py: Python<'_>, sql: String) -> PyResult> { let db = self.inner.clone(); - let rows = py.detach(move || db.query(&sql)).map_err(to_py_err)?; + let result = py + .detach(move || db.query_ordered(&sql)) + .map_err(to_py_err)?; let list = PyList::empty(py); - for row in rows { - list.append(value_row_to_py_dict(py, &row)?)?; + for row in &result.rows { + list.append(ordered_row_to_py_dict(py, &result.columns, row)?)?; } Ok(list.unbind()) } @@ -302,6 +304,18 @@ mod python { Ok(dict.unbind()) } + fn ordered_row_to_py_dict( + py: Python<'_>, + columns: &[String], + row: &Row, + ) -> PyResult> { + let dict = PyDict::new(py); + for column in columns { + dict.set_item(column, value_to_py(py, &row[column]))?; + } + Ok(dict.unbind()) + } + fn convert_py_row( py: Python<'_>, py_row: &HashMap>, From d46c92c7ef51587fa2ca7466f5951edff38c8e9d Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:45:25 -0400 Subject: [PATCH 3/5] e2e attestation for claude/1173-py-projection-order --- .../e2e-attestations/claude-1173-py-projection-order.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1173-py-projection-order.json diff --git a/packages/python/e2e-attestations/claude-1173-py-projection-order.json b/packages/python/e2e-attestations/claude-1173-py-projection-order.json new file mode 100644 index 000000000..caad768d2 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1173-py-projection-order.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1790250325, + "exit_code": 0, + "commit": "7ff1db176c39cdad2304c44b5b7df44ea2d6b8fc", + "branch": "claude/1173-py-projection-order" +} From 2fb22b61a9db9022d3c3adbdf7b7373530de091c Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:57:07 -0400 Subject: [PATCH 4/5] docs(python): migration fragment for projection-ordered query rows --- ...26-09-24-query-rows-in-projection-order.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/python/migrations.d/2026-09-24-query-rows-in-projection-order.md diff --git a/packages/python/migrations.d/2026-09-24-query-rows-in-projection-order.md b/packages/python/migrations.d/2026-09-24-query-rows-in-projection-order.md new file mode 100644 index 000000000..85f3598ee --- /dev/null +++ b/packages/python/migrations.d/2026-09-24-query-rows-in-projection-order.md @@ -0,0 +1,36 @@ +### `db.query()` rows are keyed in projection order + +**Summary** + +Each dict `db.query()` returns is now keyed in the order the SELECT list names the columns; `SELECT *` follows the table's declared column order. The keys previously came out in the core's hash-map order, which varied from process to process. No signature changed. + +**Required changes** + +_None._ + +**Deprecations removed** + +_None._ + +**Behavior changes without code changes** + +| Call | Before | After | +| --- | --- | --- | +| `await db.query("SELECT path, size FROM './'")` | keys in arbitrary order, e.g. `{'size': 6, 'path': 'a.md'}` | `{'path': 'a.md', 'size': 6}` | + +The old order was never stable, so no caller could have depended on it. Code that sorted the keys to get a stable order can drop the sort if it wants the order the query asked for. + +**Verification** + +```python +import asyncio +from dirsql import DirSQL + +async def main(): + db = DirSQL(".") + print(await db.query("SELECT size, path FROM './' LIMIT 1")) + +asyncio.run(main()) +``` + +Expected: `[{'size': ..., 'path': ...}]`, `size` first as written. From 9087a1ab303e1c97246c0659776af6302777f68b Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:03:03 -0400 Subject: [PATCH 5/5] e2e attestation for claude/1173-py-projection-order --- .../e2e-attestations/claude-1173-py-projection-order.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python/e2e-attestations/claude-1173-py-projection-order.json b/packages/python/e2e-attestations/claude-1173-py-projection-order.json index caad768d2..691f8f62d 100644 --- a/packages/python/e2e-attestations/claude-1173-py-projection-order.json +++ b/packages/python/e2e-attestations/claude-1173-py-projection-order.json @@ -1,7 +1,7 @@ { "command": "just test-e2e", - "ran_at": 1790250325, + "ran_at": 1790283783, "exit_code": 0, - "commit": "7ff1db176c39cdad2304c44b5b7df44ea2d6b8fc", + "commit": "2fb22b61a9db9022d3c3adbdf7b7373530de091c", "branch": "claude/1173-py-projection-order" }