Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<path>).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<Vec<Row>>` | `await db.query(sql) -> Record[]` (runs on libuv threadpool) |
| Query carrying the projection order | — (drift, #1173) | `db.query_ordered(sql) -> Result<QueryResult>` (`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<QueryResult>` (`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())` |
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "just test-e2e",
"ran_at": 1790283783,
"exit_code": 0,
"commit": "2fb22b61a9db9022d3c3adbdf7b7373530de091c",
"branch": "claude/1173-py-projection-order"
}
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 17 additions & 3 deletions packages/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,13 @@ mod python {

fn query(&self, py: Python<'_>, sql: String) -> PyResult<Py<PyList>> {
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())
}
Expand Down Expand Up @@ -302,6 +304,18 @@ mod python {
Ok(dict.unbind())
}

fn ordered_row_to_py_dict(
py: Python<'_>,
columns: &[String],
row: &Row,
) -> PyResult<Py<PyDict>> {
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<String, Py<PyAny>>,
Expand Down
52 changes: 52 additions & 0 deletions packages/python/tests/integration/binding/column_order_test.py
Original file line number Diff line number Diff line change
@@ -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
Loading