diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index d31b7092fe..1231195558 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -2757,8 +2757,8 @@ Neither `StudioModals`, `StudioOverlays` nor the modals are memoized. ### X9. What `columnTypes` still cannot name, measured The four string-returning drivers fill `QueryResult.columnTypes` since 2026-08-23, and -SQLite joined them on 2026-09-18 by reading its own declarations through the driver bridge. Four bounds were -measured while doing it, and each is a small residue rather than a defect: +SQLite joined them on 2026-09-18 by reading its own declarations through the driver bridge. Three bounds +measured while doing it remain open, and each is a small residue rather than a defect: - **A user-defined type has no name.** Postgres's built-in OIDs are a generated static table (they are compiled into the server and never reused), so an enum, a composite or an extension type falls @@ -2774,11 +2774,6 @@ measured while doing it, and each is a small residue rather than a defect: hands a bit string back as the string `"1010"` while `mysql2` hands back a Buffer, so the same declared name needs the text family on one engine and the binary family on the other. One name, two answers, which is why it was left alone. -- **The mssql transaction path declares types for columns `fields` does not list.** `queryInTransaction` - takes `fields` from `Object.keys(recordset[0])`, so a zero-row result has no fields while its - `recordset.columns` (which does carry the declaration, even for zero rows - measured) fills - `columnTypes`. Harmless today because all three consumers iterate `fields`; taking `fields` from - `columns` too would be the right fix and is a behaviour change of its own. **Done when:** each bound is closed or judged settled, with the enum case the only one a user is likely to meet. @@ -4352,6 +4347,7 @@ Two halves, and the second is what stops it recurring: resolves each, and fails on a miss, so a coordinate cannot go stale silently again. DOC4 is the same class in the provider docs. +More instances, found stale at `8f2fc5c81` during the PR #1575 review (2026-10-07): `tests/integration/db/mssql-provider.test.ts` (`mssql.ts:1798`), `docs/providers/opensearch.md` (`http-transport.ts` `:806`, `:883-935`, `:913`, `:999`, `:1127`, `:1128`, `:1283`), `docs/providers/elasticsearch.md` (`http-transport.ts:1194`), `docs/BACKLOG.md` (`http-transport.ts:1556`), `tests/components/studio/BottomPanel.test.tsx` (`BottomPanel.tsx:574`), `docs/BACKLOG.md` (`BottomPanel.tsx:445`), and the `oracle.ts:1492`, `:2000` and `:2015` citations in `oracle.ts`, `tests/integration/db/oracle-provider.test.ts` and `tests/api/db-objects.test.ts`. This entry was first written as a second "D94 (proposed)" block, which reused the id of D94 and was not a heading the structure guard reads. **Done when:** a test fails on a stale `file.ts:NNNN` anywhere under `src/`, `docs/` and `tests/`, diff --git a/docs/providers/cassandra.md b/docs/providers/cassandra.md index 977cb68678..01ea32973d 100644 --- a/docs/providers/cassandra.md +++ b/docs/providers/cassandra.md @@ -580,6 +580,12 @@ The **declaration** drives the row shape, not the row's own keys: it is the only the statement projected. `fieldNames: null` (a write) and `fieldNames: []` are kept apart at the seam and both collapse to no columns for the grid. +A column declared with an empty name comes back as `(No column name)` (`uniqueFieldNames` in [`result-fields.ts`](../../src/lib/db/utils/result-fields.ts)), so the grid always has a key for it. +Two columns of one name (`SELECT id AS x, name AS x`) are refused with a query error naming the column: cassandra-driver 4.10.0 builds each row as `row[column.name] = value` (`lib/streams.js`, `parseRows`), so the earlier column's value is already gone when the row reaches this provider, and a second header would only repeat the last value. +Give each column its own alias. +The refusal covers a repeat of one column too (`SELECT a, a`), whose two values would be equal: it stays because it is explicit and never shows a wrong value, where telling the two cases apart needs a live cluster to measure against. +This is read from the driver source and pinned with the driver's row shape in `tests/unit/db/cassandra/wire.test.ts` and `tests/integration/db/cassandra-provider.test.ts`; it was not measured against a live cluster in this change, so whether a given server version accepts a repeated name at all is not stated here. + `columnTypes` carries the wire's declared type per column. Note that `text` reads back as `varchar` and a column declared `varchar` reads back as `text` — they are one type on the wire — so this is whatever the protocol declared rather than the DDL word. The schema tree reads diff --git a/docs/providers/clickhouse.md b/docs/providers/clickhouse.md index e7bb85af93..d2e7a110c7 100644 --- a/docs/providers/clickhouse.md +++ b/docs/providers/clickhouse.md @@ -57,10 +57,10 @@ from: | `DatabaseProvider` slot | ClickHouse realisation | Mechanism | |-------------------------|-------------------------|-----------| | "Table" (the relation kind) | A table, displayed as `name` or `database.name` | `system.tables`, filtered to non-system databases | -| "Row" | One result row | JSON `data` array element | +| "Row" | One result row | JSONCompact `data` array element, read by position | | Columns | The declared column list, types verbatim | `system.columns` / the query response `meta` | | Primary key | The MergeTree sparse primary index | `system.tables.primary_key`, `is_in_primary_key` | -| `query(sql)` | One SQL statement | `POST /?default_format=JSON` | +| `query(sql)` | One SQL statement | `POST /?default_format=JSONCompact` | | Indexes | Data-skipping indexes, and nothing else ([below](#indexes-are-only-the-skipping-indexes)) | `system.data_skipping_indices` | | Foreign keys | none (ClickHouse has none) | always `[]` | | `getOverview()` / storage | Server identity, connection counts, part sizes | `version()`, `uptime()`, `system.metrics`, `system.parts`, `system.disks` | @@ -262,18 +262,26 @@ The upside is real: **the schema tree and the overview panel survive a restricte monitoring panels and maintenance operations that need their own grant degrade ([§7](#7-monitoring--health)). -### 3.4 `default_format=JSON` as a URL parameter, never appended to the SQL +### 3.4 `default_format=JSONCompact` as a URL parameter, never appended to the SQL -`POST /?default_format=JSON` with the raw statement as the body returns the JSON envelope. User SQL +`POST /?default_format=JSONCompact` with the raw statement as the body returns the JSONCompact envelope. User SQL is never rewritten to add a format — the editor sends exactly what the user typed. +JSONCompact and not JSON, because only a positional row keeps every value. +Its `meta` is the same as JSON's, and each `data` row is an array of values in `meta` order instead of an object keyed by column name. +Measured on 26.7.1.1315: the engine qualifies a joined column itself (`b.x`), and that name can collide with a column the statement already calls `b.x`. +``SELECT a.x, b.x, 3 AS `b.x` FROM (SELECT 1 AS x) a, (SELECT 2 AS x) b`` declares `b.x` twice; under JSON the row is `{"x": 1,"b.x": 2,"b.x": 3}` and `JSON.parse` keeps only `3`, under JSONCompact it is `[1, 2, 3]`. +The transport numbers the repeated name with the shared `uniqueFieldNames` helper and keys each row by position ([§5.2](#52-result-shaping)). +A row that is not an array, or whose value count differs from the `meta` column count, is refused with an error rather than shown under guessed names. + **An explicit `FORMAT` in the user's own SQL wins over `default_format`, live-verified.** -`SELECT 1 FORMAT TSV` genuinely comes back as TSV even with `default_format=JSON` on the URL. The +`SELECT 1 FORMAT TSV` genuinely comes back as TSV even with `default_format=JSONCompact` on the URL. The response header `X-ClickHouse-Format` reports the format the server actually used, and the transport branches on it before parsing the body ([`toQueryResult`](../../src/lib/db/providers/sql/clickhouse/http-transport.ts)): when it is not -`JSON`, the raw text comes back as `rawText` rather than being parsed or thrown away — the user asked -for that format deliberately. The provider then surfaces it as one synthetic column, +`JSONCompact`, the raw text comes back as `rawText` rather than being parsed or thrown away: the user asked +for that format deliberately. +That includes an explicit `FORMAT JSON`: it comes back as the server's text under `__text`, like any other format the user chose. The provider then surfaces it as one synthetic column, `__text` (`RAW_TEXT_COLUMN`, [`index.ts`](../../src/lib/db/providers/sql/clickhouse/index.ts)), the same convention Couchbase uses for a scalar projection. @@ -757,13 +765,22 @@ that is how the rest of the application calls every provider uniformly. | Source | `QueryResult` field | Notes | |--------|----------------------|-------| -| `data` array | `rows` | Objects exactly as the server returned them; 64-bit integers and every `Decimal` arrive as strings ([§3.5](#35-64-bit-integers-and-decimals-are-quoted-on-purpose-to-stop-jsonparse-from-rounding-them)) | -| `meta` array | `fields` | Declared column order; `[]` when the source could not describe the rows (a non-JSON format, or a write) | +| `data` array | `rows` | One object per row, built by position from the row's array of values and keyed by the names in `fields`; 64-bit integers and every `Decimal` arrive as strings ([§3.5](#35-64-bit-integers-and-decimals-are-quoted-on-purpose-to-stop-jsonparse-from-rounding-them)) | +| `meta` array | `fields` | Declared column order, a repeated name numbered (see below); `[]` when the source could not describe the rows (a format the user chose, or a write) | | — | `rowCount` | `rows.length` when there are rows; otherwise `mutationCount` from `X-ClickHouse-Summary`, verbatim, zero included ([§3.6](#36-writes-return-an-empty-200-body-the-row-count-lives-in-a-header)) | | `X-ClickHouse-Summary.elapsed_ns` | `executionTime` | The server's own duration, preferred because it excludes network latency; falls back to the envelope's `statistics.elapsed` (seconds), then to the measured wall clock when neither source reported anything | -| a non-JSON `X-ClickHouse-Format` | `rows` / `fields` | One synthetic row `{ __text: "" }` under the single column `__text` ([§3.4](#34-default_formatjson-as-a-url-parameter-never-appended-to-the-sql)) | +| any `X-ClickHouse-Format` but `JSONCompact` | `rows` / `fields` | One synthetic row `{ __text: "" }` under the single column `__text` ([§3.4](#34-default_formatjsoncompact-as-a-url-parameter-never-appended-to-the-sql)) | | `meta` array | `columnTypes` | The declared type per column, keyed by its name in `fields` and spelled exactly as ClickHouse spells it — `Nullable(String)`, `LowCardinality(String)`, `Enum8('x' = 1)` — because the wrapper is what tells the user the column is nullable or low-cardinality. **Absent** when the envelope described no columns: a write, a format the user chose, or a statement with no result set. For a computed column such as `count()` this is the only source of a type at all, since no catalog entry exists for it (issue #273) | +Every name in `fields` is non-empty and appears once, and every column keeps its own value, measured on 26.7.1.1315. +An empty quoted alias is a syntax error, so no column comes back unnamed. +Two expressions under one alias (`SELECT 1 AS a, 2 AS a`) are refused with `MULTIPLE_EXPRESSIONS_FOR_ALIAS`, and a join qualifies a repeated column itself (`id`, `t2.id`). +But the server can still declare two `meta` columns of one name, in two ways. +`SELECT number, number FROM numbers(2)` and `SELECT rand(), rand()` repeat one expression, which ClickHouse evaluates once, so both columns carry the same value. +The qualified name can also collide with a name the statement already has: ``SELECT a.x, b.x, 3 AS `b.x` FROM (SELECT 1 AS x) a, (SELECT 2 AS x) b`` declares `x`, `b.x`, `b.x` with the values `1`, `2`, `3`. +The same holds for ``SELECT * FROM (SELECT 1 AS x) a JOIN (SELECT 2 AS x, 7 AS `b.x`) b ON 1=1`` (`1`, `2`, `7`), and `b.x` is also how a Nested sub-column is named. +Rows are read by position ([§3.4](#34-default_formatjsoncompact-as-a-url-parameter-never-appended-to-the-sql)), so both values survive, and the repeat is numbered as every provider numbers it: `fields` is `["x", "b.x", "b.x (2)"]`, the row is `{ x: 1, "b.x": 2, "b.x (2)": 3 }`, and `columnTypes` is keyed by the numbered names. + ### 5.3 EXPLAIN The EXPLAIN button is available (`supportsExplain: true`) and renders the plan tree described in diff --git a/docs/providers/couchbase.md b/docs/providers/couchbase.md index 45b0a7ac07..2b76d94240 100644 --- a/docs/providers/couchbase.md +++ b/docs/providers/couchbase.md @@ -562,11 +562,16 @@ operator — and a statement ending in a `#` run is returned unbounded rather th | Source field | `QueryResult` field | Notes | |--------------|---------------------|-------| | result rows | `rows` | JSON objects exactly as the cluster returned them, except that an integer past 2^53 arrives as its exact digits (see below) | -| signature | `fields` | `null` for a wildcard signature, in which case columns are the union of the keys the rows carry, first seen first | +| signature | `fields` | `null` for a wildcard signature, in which case columns are the union of the keys the rows carry, first seen first; an empty key, from either source, is named `(No column name)` and the rows carrying it are keyed under that name (see below) | | — | `rowCount` | `rows.length`, or the mutation count when a statement returned no rows | | metrics `executionTime` | `executionTime` | The cluster's own time (excludes network latency); falls back to the measured wall clock when the cluster reported none | | `warnings` | `warnings` | The notices the cluster attached to a statement it completed, each carrying its message and the cluster's own code **when it reported one** — an entry with no code arrives without one rather than with a substituted `0`, which is itself a legal code. **Absent** when the cluster reported no warnings at all — never an empty array, so the result UI decides from the field's presence alone (issue #273) | +**An empty key is a column named `(No column name)`.** +JSON carries an empty key, and the transport's `JSON.parse` keeps it, so a document `{"": 1}` or a signature `{"": "number"}` would otherwise answer a column `""`, which the grid cannot take. +[`uniquelyKeyedRows`](../../src/lib/db/utils/result-fields.ts) names it through `uniqueFieldNames`, numbered past any key the rows already use, and keys every row carrying it under that name. +Not measured on a live cluster; the rule is pinned by the provider's integration tests. + **An integer past 2^53 is handed over as its digits.** The query service sends a document's number as the **unquoted** literal it was stored as, and `JSON.parse` rounds one past 2^53 with no error: measured on 8.0.2 CE on 2026-10-04, a document `{"big":9007199254740993}` read through diff --git a/docs/providers/db2.md b/docs/providers/db2.md index 5145faa236..5632d15d69 100644 --- a/docs/providers/db2.md +++ b/docs/providers/db2.md @@ -166,7 +166,7 @@ Reading them is exact since 1.0.25: `SELECT *` over `APP.ALLTYPES` answers all 2 These were measured on 1.0.24, reported upstream one issue each, and fixed in [1.0.25](https://github.com/gurungabit/db2-node/releases/tag/v1.0.25); each now probes `GONE` on 12.1.0.0 and 11.5.9.0, and the provider workaround each one needed is gone with it unless the line says otherwise. - K4: a LOB or XML column read beside other columns came back wrong, lost rows or failed: a BLOB beside a CLOB answered the CLOB's bytes, a CLOB(1M) beside a GRAPHIC dropped rows when a later LOB was NULL, a CLOB beside a DOUBLE failed with "Protocol error: Invalid SQLDTAGRP indicator 0xEF", and `SELECT *` over `APP.ALLTYPES` answered 1 row of 3 and 23 of 25 columns ([gurungabit/db2-node#19](https://github.com/gurungabit/db2-node/issues/19), [#20](https://github.com/gurungabit/db2-node/issues/20), fixed by [#27](https://github.com/gurungabit/db2-node/pull/27)). Every such read, NULL LOB rows and a 50000-byte CLOB included, now answers what each column read alone answers. The integrity warning is gone; the preview still leaves CLOB, DBCLOB and BLOB out, for K24, and reads XML. -- K15: two columns of one name collapsed into one value in a row ([gurungabit/db2-node#21](https://github.com/gurungabit/db2-node/issues/21), fixed by [#28](https://github.com/gurungabit/db2-node/pull/28)). Object rows still keep only the last; the provider reads `rowMode: "array"`, which keeps both, and names the repeat `A (2)`, as the Druid and Trino providers do. +- K15: two columns of one name collapsed into one value in a row ([gurungabit/db2-node#21](https://github.com/gurungabit/db2-node/issues/21), fixed by [#28](https://github.com/gurungabit/db2-node/pull/28)). Object rows still keep only the last; the provider reads `rowMode: "array"`, which keeps both, and names the repeat `A (2)`, never a name the statement itself declares. - K16: a BOOLEAN parameter had to be a JS boolean, so the text `true` the grid's inline editor binds was refused ([gurungabit/db2-node#22](https://github.com/gurungabit/db2-node/issues/22), fixed by [#28](https://github.com/gurungabit/db2-node/pull/28)). The texts `true` and `false` are now stored as those booleans, so a BOOLEAN cell takes an inline edit. - K17: a failure the driver raised itself carried no SQLSTATE and nothing else to classify it by ([gurungabit/db2-node#23](https://github.com/gurungabit/db2-node/issues/23), fixed by [#28](https://github.com/gurungabit/db2-node/pull/28)). It still carries no SQLSTATE, which only a server gives, and now carries a `driverCode`, which the provider maps (section 7.1). - K23: a password holding `!`, `^`, `[`, `]` or `|` was sent wrongly and refused as "user id or password invalid" ([gurungabit/db2-node#25](https://github.com/gurungabit/db2-node/issues/25), fixed by [#29](https://github.com/gurungabit/db2-node/pull/29)). Measured with one user per character and one holding all five, over TLS under the default mechanism and under `userPassword` and without TLS under `userPassword`, through Bun and Node: every one signs in, the IBM CLP signs in with the same passwords over TCP, and a wrong password is still refused. The provider's refusal of those characters is gone. @@ -321,7 +321,10 @@ A statement may start with a comment, which 1.0.22 refused (K18). `fields` and `columnTypes` come from the driver's column metadata, so a zero-row result still has its header. A SELECT reports the number of rows returned; any other statement reports the driver's affected-row count, which is 0 for an UPDATE or DELETE that matched no row (on 1.0.22 it was -2147221503, K19). Each statement commits on its own. -The statement is read with `rowMode: "array"`, so two columns of one name keep both values: the repeat is named `A (2)`, then `A (3)`, skipping a name the statement already used, as the Druid and Trino providers name theirs (K15, fixed in 1.0.25). +The statement is read with `rowMode: "array"`, so two columns of one name keep both values: the repeat is named `A (2)`, then `A (3)`, never a name the statement itself declares, before or after the repeat (K15, fixed in 1.0.25). +So `SELECT 1 AS A, 2 AS A, 3 AS "A (2)"` comes back as `A`, `A (3)`, `A (2)`, and the user's own `A (2)` keeps its value. +Db2 names an unaliased expression itself (`1`, `2`, see below), so an empty name is not something it declares. +A row whose value count differs from the column count is refused with a query error rather than read by position, which would leave a value `undefined` or drop one. The driver's diagnostics are passed through as result warnings, after the LOB warning of K24. A failure the driver raises itself carries a `driverCode` and no SQLSTATE: `DB2_PARAMETER_COUNT` and `DB2_PARAMETER_TYPE`, a wrong number of parameters or a value that does not fit its target, are a query error, and `DB2_PROTOCOL` and `DB2_INVALID_OPTION` are the driver's own and stay a plain database error whatever their words say, each with the driver's message (K17, fixed in 1.0.25). Every other error goes through the shared `mapDatabaseError()`, which reads the SQLSTATE and SQLCODE when the driver's message carries them. diff --git a/docs/providers/druid.md b/docs/providers/druid.md index 8971412df4..1942a45de3 100644 --- a/docs/providers/druid.md +++ b/docs/providers/druid.md @@ -326,9 +326,13 @@ convincing possible lie: a successful query, over the right datasource, that hap nothing. Data loss has to surface as an error, which is the same reason the mid-response truncation case above raises instead of returning what it managed to read. -Rows are rebuilt from the declared names, and a repeat is **disambiguated rather than overwritten**: -`SELECT 1 AS c, 2 AS c` reaches the grid as columns `c` and `c (2)`, both with their values. The -suffix keeps climbing (`c (3)`, …) because `SELECT 1 AS c, 2 AS "c (2)", 3 AS c` is legal too. Without +Rows are rebuilt from the declared names, and a repeat is **numbered rather than overwritten**: +`SELECT 1 AS c, 2 AS c` reaches the grid as columns `c` and `c (2)`, both with their values. A +number never takes a name the statement itself declares, before or after the repeat: +`SELECT 1 AS a, 2 AS a, 3 AS "a (2)"` reaches the grid as `a`, `a (3)`, `a (2)`, so the user's own +`a (2)` keeps its value. A header naming a column with an empty string would be shown as +`(No column name)`. A data row whose value count differs from the header is refused as +unreadable rather than padded with nulls or cut, the same reasoning as the short header above. Without this the array format would have been chosen and then thrown away one step later, since a row is a `Record`. @@ -862,11 +866,11 @@ both the reason and the alternative, which is more useful than anything this pro | Source | `QueryResult` field | Notes | |---|---|---| -| the data rows | `rows` | Rebuilt from the positional arrays, keyed by the disambiguated column names | -| header row 0 | `fields` | Declared column order, made unique (`c`, `c (2)`); `[]` when the payload carried no header | +| the data rows | `rows` | Rebuilt from the positional arrays, keyed by the unique column names | +| header row 0 | `fields` | Declared column order, made unique (`c`, `c (2)`, never a name the payload itself declares); `[]` when the payload carried no header | | — | `rowCount` | `rows.length`. There is no second number: no Druid statement mutates, so a mutation count could only ever be zero | | the measured exchange | `executionTime` | Rounded milliseconds, **measured by the transport**. The endpoint reports no timing whatsoever, so there is no server-side number this could be preferred over ([§3.2](#32-the-transport-seam-one-interface-one-implementation)) | -| header row 2 (SQL types) | `columnTypes` | Keyed by the same disambiguated names; **absent** when the payload declared no types. The native types (header row 1) deliberately do not travel — they lie for an expression ([§3.5](#35-the-sql-type-labels-the-column-because-the-native-type-lies)) | +| header row 2 (SQL types) | `columnTypes` | Keyed by the same unique names; **absent** when the payload declared no types. The native types (header row 1) deliberately do not travel: they lie for an expression ([§3.5](#35-the-sql-type-labels-the-column-because-the-native-type-lies)) | | `X-Druid-Response-Context.missingSegments` | `warnings` | One warning naming how many segments were unavailable, and **absent** for a whole answer or an answer that said nothing about availability ([§13](#13-known-limitations--future-work)) | **`COUNT(DISTINCT ...)` answers an estimate.** Druid documents it as an alias for diff --git a/docs/providers/duckdb.md b/docs/providers/duckdb.md index 4de0d5ec71..c693aa41fb 100644 --- a/docs/providers/duckdb.md +++ b/docs/providers/duckdb.md @@ -49,8 +49,8 @@ is a native N-API addon carrying a 70 MB shared library rather than a runtime bu It is a separate type-id from `sqlite`, not a relative of it, for the same reason libSQL is: the execution layer has nothing in common. `bun:sqlite` is synchronous and returns JavaScript values; -`@duckdb/node-api` is asynchronous, returns a reader, and can only be serialised through -`getRowObjectsJson()` (§3.2). Every catalog statement differs. Every maintenance statement differs. +`@duckdb/node-api` is asynchronous, returns a reader, and can only be serialised through its +JSON readers (§3.2). Every catalog statement differs. Every maintenance statement differs. Sharing an id would mean one document describing two sets of measurements. ### 1.1 Deployment constraint @@ -103,7 +103,7 @@ src/lib/db/providers/sql/duckdb/ ``` The driver seam is narrow by construction: `DuckDBInstance` / `DuckDBConnection` and the reader API -(`runAndReadAll`, `getRowObjectsJson`, `columnNames`, `columnTypes`, `rowsChanged`, `interrupt`) are +(`runAndReadAll`, `getRowsJson`, `columnNames`, `columnTypes`, `rowsChanged`, `interrupt`) are vocabulary of `@duckdb/node-api` and stay inside **`client.ts`** — `duckdb-seam-guard.test.ts` fails the build when any of it appears in a sibling. The import of the driver is dynamic and lives inside the open function, never at module scope, so the ~70 MB `libduckdb.so` is not loaded into a process @@ -131,7 +131,7 @@ scoped with `database_name = current_database()` rather than by name, and object qualifies `schema.table`. A user who hits the binder error in the editor is being told about their own file name, and the doc says so here rather than leaving it to be rediscovered. -### 3.2 `getRowObjects()` is banned; rows come from `getRowObjectsJson()` +### 3.2 `getRowObjects()` is banned; rows come from `getRowsJson()` `getRowObjects()` throws the moment a result is serialised: @@ -141,8 +141,9 @@ JSON.stringify cannot serialize BigInt (Bun 1.3.14) ``` Wide integers arrive as JavaScript `BigInt` values in that shape, so a table with a `BIGINT` primary -key would break the whole API response rather than one cell. `getRowObjectsJson()` is the -only reader used on the API path, and it hands back values that are already JSON-safe. +key would break the whole API response rather than one cell. The JSON readers hand back values that +are already JSON-safe, and `getRowsJson()`, which answers each row as an array, is the only reader +used ([Result column names](#result-column-names) says why the array form and not `getRowObjectsJson()`). For @@ -173,7 +174,7 @@ Wide integers staying as strings is the engine protecting exactness, and the pro "repair" it: `Number("9223372036854775807")` is a different number, and a rounded key is a corruption nothing downstream can detect. The grid renders the string as measured. -**Columns come from `columnNames()`, never from the rows.** `getRowObjectsJson()` returns `[]` for an +**Columns come from `columnNames()`, never from the rows.** `getRowsJson()` returns `[]` for an empty result set and carries no column information, while `columnNames()` and `columnTypes()` answer regardless. `columnTypes().map(String)` gives DuckDB's own type text — `INTEGER`, `VARCHAR`, `INTEGER[]`, `STRUCT("x" INTEGER)`, `HUGEINT`, `INTERVAL`, `UUID` — which is what populates @@ -657,14 +658,30 @@ Explicitly **not** supported, so that a reader does not go looking: `query(sql, params)` runs one statement and returns `{ rows, fields, rowCount, executionTime, columnTypes? }`. -- **Rows** come from `getRowObjectsJson()`; `getRowObjects()` is never called (§3.2). -- **`fields`** come from `columnNames()`, so an empty result still names its columns. +- **Rows** come from `getRowsJson()`, keyed by position under `fields`; `getRowObjects()` is never called (§3.2). +- **`fields`** come from `columnNames()`, so an empty result still names its columns, and a repeated name is numbered ([Result column names](#result-column-names)). - **`columnTypes`** carries `columnTypes().map(String)` — DuckDB's own type text — and the key is **omitted entirely** when there is nothing to publish, never emitted as `{}`. - **`rowCount`** is the row count for a read and `result.rowsChanged` for a write; the synthetic `Count` column is not surfaced (§3.4). - **Cancellation** calls `interrupt()` on the connection (§3.9). +### Result column names + +Every result column comes back under a name that is non-empty and that no other column of the result has, with its own value. +`client.ts` reads the rows as arrays (`getRowsJson()`) and keys them by position under `uniqueFieldNames(columnNames())` ([`result-fields.ts`](../../src/lib/db/utils/result-fields.ts)), and `columnTypes` is keyed by the same names. +Measured 2026-10-07 in-process on @duckdb/node-api 1.5.5-r.4, pinned in `tests/integration/db/duckdb-provider.test.ts`: + +| Statement | `fields` | Row | +|---|---|---| +| `SELECT 1 AS a, 'x' AS a` | `a`, `a (2)` | `{ a: 1, "a (2)": "x" }`, typed `INTEGER` and `VARCHAR` | +| `SELECT 2 AS "a:1", 3 AS a, 4 AS a, 4 + 5` | `a:1`, `a`, `a (2)`, `(4 + 5)` | every value under its own name | +| `SELECT 1 AS ""` | none | the parser refuses it: *zero-length delimited identifier* | + +DuckDB names an unaliased expression by its text (`(4 + 5)`) and refuses an empty alias, so it never declares a column with no name. +A repeated name was the defect: the driver's `getRowObjectsJson()` keys a repeat `a:1` while `columnNames()` lists `a` twice, so both grid headers showed the first value, and a column the statement itself named `a:1` was overwritten by the repeat. +`queryReadOnly()` reads through the same client call. + ### A transaction a statement left open A `BEGIN` sent through `query()` opens a transaction on the ONE connection this provider holds @@ -1559,7 +1576,7 @@ The reach described in this section is an admin editor's on an inline connection | No `REINDEX`, no integrity check | Neither statement exists on 1.5.5 | The engine's (§8) | | No analyze/timing plan, ever | `EXPLAIN (ANALYZE, FORMAT JSON)` **executes the statement** — measured, an `INSERT` explained three times inserted three rows | **Ours, and permanent.** A later DuckDB emitting valid analyze JSON makes this worse, not better: the plan would look usable while still running what the user asked only to see (§3.6) | | Sizes arrive as formatted strings | `pragma_database_size()` publishes no raw bytes | The engine's (§3.5) | -| Wide integers render as strings | `getRowObjectsJson()` quotes them to stay exact | The engine's, and deliberate (§3.2) | +| Wide integers render as strings | `getRowsJson()` quotes them to stay exact | The engine's, and deliberate (§3.2) | | A multi-statement string runs only its first statement | The driver's `runAndReadAll` behaviour | The driver's (§3.11) | | No `SERIAL`, no `IDENTITY`, no `AUTOINCREMENT` | None of the three exists; `GENERATED … AS IDENTITY` parses and is refused at execution | The engine's — the create-table form defaults from a sequence instead (§3.13) | | ~140 MB of bindings on a Linux tree | glibc and musl packages both install | Ours to prune in the AppImage build (§11) | diff --git a/docs/providers/elasticsearch.md b/docs/providers/elasticsearch.md index 6443365f05..4184fbcc8b 100644 --- a/docs/providers/elasticsearch.md +++ b/docs/providers/elasticsearch.md @@ -252,14 +252,18 @@ Measured, `SELECT customer, total FROM probe_orders`: Three properties the code depends on: - **Rows are positional**, so each row is rebuilt against the declared column list - ([http-transport.ts:747](../../src/lib/db/providers/sql/search/http-transport.ts)) rather than read - as an object. The declared **order** is therefore authoritative in a way object keys never are. + (`toRow()`, [http-transport.ts:759](../../src/lib/db/providers/sql/search/http-transport.ts)) rather than read + as an object. The declared **order** is therefore authoritative in a way object keys never are, and a + row whose value count differs from the declaration is refused rather than padded with nulls or cut. - **Duplicate output names are legal here.** Measured, `SELECT 1 AS c, 2 AS c, 3 AS c` answers HTTP 200 with `[{"name":"c",…},{"name":"c",…},{"name":"c",…}]` and the row `[1,2,3]`. A `SearchRow` is a - record, so without `disambiguate()` - ([http-transport.ts:695](../../src/lib/db/providers/sql/search/http-transport.ts)) the second and - third values would vanish **before** the seam. They reach the grid as `c`, `c (2)`, `c (3)`, and the - suffix keeps climbing because `SELECT 1 AS c, 2 AS "c (2)", 3 AS c` is legal too. **The same + record, so without `uniqueFieldNames()` + ([result-fields.ts](../../src/lib/db/utils/result-fields.ts)) the second and + third values would vanish **before** the seam. They reach the grid as `c`, `c (2)`, `c (3)`. A + number never takes a name the statement itself declares, before or after the repeat: + `SELECT 1 AS a, 2 AS a, 3 AS "a (2)"` reaches the grid as `a`, `a (3)`, `a (2)`, so the user's own + `a (2)` keeps its value. A column declared with no name would be shown as `(No column name)`, and one whose + declared name is not text is refused as an engine error rather than shown as its stringification. **The same statement is refused outright by OpenSearch**, so this invariant is load-bearing on exactly one of the two products — which is a fact about that engine, not dead code ([opensearch.md §3.4](./opensearch.md#34-the-success-envelope-schemadatarows-a-separate-alias-and-a-count)). @@ -302,7 +306,7 @@ traps shape the loop, both measured on that same run: - **Page two carries its rows and NO column declaration.** There is nothing on it to derive names from, so page one's declaration is carried forward and later pages are rebuilt against it - (`rebuildRows()`, [http-transport.ts:774](../../src/lib/db/providers/sql/search/http-transport.ts)). + (`rebuildRows()`, [http-transport.ts:790](../../src/lib/db/providers/sql/search/http-transport.ts)). That is also the only way the seam's "these names are exactly the key set of every row" invariant can hold across pages. - **The loop is bounded** by `MAX_PAGES = 1000` @@ -595,8 +599,8 @@ everything it *would* have accepted, which is more useful than anything substitu | Source | `QueryResult` field | Notes | |---|---|---| -| `rows` | `rows` | Rebuilt from the positional arrays, keyed by the disambiguated column names; an integer past 2^53 arrives as its exact digits ([§5.3](#53-row-values-arrive-as-the-mappings-json)) | -| `columns[].name` | `fields` | Declared order, made unique (`c`, `c (2)`); `[]` when the answer described no columns | +| `rows` | `rows` | Rebuilt from the positional arrays, keyed by the unique column names; an integer past 2^53 arrives as its exact digits ([§5.3](#53-row-values-arrive-as-the-mappings-json)) | +| `columns[].name` | `fields` | Declared order, made unique (`c`, `c (2)`, never a name the answer itself declares); `[]` when the answer described no columns | | — | `rowCount` | `rows.length`. There is no second number: no statement here mutates, so a mutation count could only ever be zero | | the measured exchange | `executionTime` | Rounded milliseconds, **measured by this process**. Neither the body nor the headers carry any timing, so there is no server number to prefer | | `columns[].type` | `columnTypes` | The engine's **mapping** types (`keyword`, `double`, `datetime`); **absent** when the answer declared none ([§3.9](#39-columns-are-labelled-with-mapping-types-not-sql-types)) | diff --git a/docs/providers/influxdb3.md b/docs/providers/influxdb3.md index 09d975d365..49e22bbaae 100644 --- a/docs/providers/influxdb3.md +++ b/docs/providers/influxdb3.md @@ -269,6 +269,16 @@ A line that is not a JSON object, or one nested deeper than 64 levels, is refuse > InfluxDB 3 answered with a line Studio cannot read as a row. +An unaliased expression is named by the engine, but an empty alias is accepted: measured on 3.12 Core, `SELECT 1 AS ""` answers the line `{"":1}`, and Studio names that column `(No column name)` through `uniqueFieldNames`. +The engine refuses a projection of two columns of one name, measured on 3.12 Core: `SELECT 1 AS a, 2 AS a`, `SELECT usage, usage FROM cpu` and `SELECT 1, 1` answer 400 "Projections require unique expression names". +A join still answers one key twice: `SELECT c1.usage, c2.usage FROM cpu c1 CROSS JOIN cpu c2` answers the line `{"usage":1.5,"usage":1.5}`, and `SELECT *` over that join repeats every key. +JSON keeps one value per key and a line leaves out the key of a null cell, so which value belongs to which column cannot be recovered, and Studio refuses the result rather than show it a column short: + +> InfluxDB 3 answered with more than one column named usage, and its answer cannot say which value belongs to which column, so nothing was shown; give one of them an alias with AS. + +A repeated name is caught only on a line that carries it twice, so the all-null limit above applies to it too: when one of the columns is null on every line, no line names it twice and the result comes back as one column. +Measured on 3.12 Core, `SELECT c2.usage, c1.usage FROM cpu c1 LEFT JOIN cpu c2 ON c2.host = 'zzz'` answers the line `{"usage":1.5}`, so the grid shows one `usage` column holding `c1.usage`. + The answer is read line by line up to the row cut, so a body of many short lines never holds more than the cut's worth of rows. ### 5.6 Bounds @@ -439,6 +449,7 @@ None: no Admin > Operations card and no tree control is offered, because InfluxD | 500 with any other text | InfluxDB 3 refused the statement: [server text] | | The server ended the answer after accepting it | InfluxDB failed while running this query after accepting it, so its reason did not reach Studio; it is in the server log. Common causes: a division by zero, a failed cast. | | A line that is not a row | InfluxDB 3 answered with a line Studio cannot read as a row. | +| A line that names a column twice | InfluxDB 3 answered with more than one column named [column], and its answer cannot say which value belongs to which column, so nothing was shown; give one of them an alias with AS. | | Over the response cap | The result is larger than 16 MiB, the most Studio reads for one answer; add a LIMIT or a narrower time range. | | The deadline | InfluxDB did not answer within [milliseconds] ms, so Studio stopped waiting and closed the connection, which stops the query on the server. | | Stop | The query was cancelled. | @@ -501,6 +512,8 @@ The token goes in `password`, there is no `user`, and `allowInsecureAuth: true` - SSL mode `require` sends the token to a server whose certificate is not checked. - Studio reads `query_sql` as jsonl, with no Arrow Flight: column types are what JSON carries, and a stopped query gets no server-side cancel ([D197](../BACKLOG.md)). - An all-null column and the columns of an empty result cannot be known, and no column type is reported. +- A result that names a column twice, such as a self join's two `usage` columns, is refused: alias one of them with `AS`. + When one of the two is null on every line, no line names it twice, so the result comes back as one column instead ([§5.5](#55-result-shape)). - One connection reads one database; a server with several needs one connection each ([U81](../BACKLOG.md)). - A page boundary inside a tie of `time` can repeat or skip a row; add the tag columns to `ORDER BY` for stable pages. - Count and Profile read the whole table and meet Core's file limit on a large one. diff --git a/docs/providers/libsql.md b/docs/providers/libsql.md index 9f04d037db..8609873349 100644 --- a/docs/providers/libsql.md +++ b/docs/providers/libsql.md @@ -425,6 +425,12 @@ columnTypes? }`. - **Parameters are positional** (`?`), encoded per type: an integer as a decimal string, a non-integral number as a float, a boolean as SQLite's own 1/0, `Uint8Array` as base64, a `Date` as an ISO string. Passing none sends no `args` member at all. +- **`fields`** are the declared column names, each non-empty and unique, and the rows are keyed by position under them in [`hrana-transport.ts`](../../src/lib/db/providers/sql/libsql/hrana-transport.ts). + A repeated name comes back numbered: two columns declared `id` are `id` and `id (2)`, each with its own value and declared type. + A column declared with no name, or with an empty one (`1 AS ""`), comes back as `(No column name)`. + Before this, a repeated name kept only the last value while `fields` listed it twice, and an unnamed column was `column_N`, which a column the statement itself named `column_1` overwrote. + Pinned against the Hrana answer shape in `tests/unit/db/libsql/hrana-transport.test.ts` and `tests/integration/db/libsql-provider.test.ts`; not re-measured against a live sqld in this change. + A row whose value count is not the column count is refused with an error rather than padded. - **`rowCount`** is the row count for a read and the engine's `affected_row_count` for a write. - **`columnTypes`** carries SQLite's declared types verbatim (`INTEGER`, `TEXT`) and is OMITTED when the engine declared none — which it does for every computed column and every PRAGMA, so an absent diff --git a/docs/providers/mongodb.md b/docs/providers/mongodb.md index 1e45ebfd0d..603c4fe180 100644 --- a/docs/providers/mongodb.md +++ b/docs/providers/mongodb.md @@ -402,6 +402,11 @@ carried from the grid and from those exports (the JSON export kept them). A unif answers exactly the first document's keys in their order. The union covers top-level keys only: a subdocument stays one column. +**An empty top-level key is a column named `(No column name)`, and the rows carrying it are keyed under that name.** +BSON carries an empty key: `BSON.deserialize(BSON.serialize({ "": 1 }))` answers `{ "": 1 }` (measured in-process with the bundled driver on 2026-10-07; a live server was not measured). +Keyed by `""`, the grid could not take the column at all, so [`uniquelyKeyedRows`](../../src/lib/db/utils/result-fields.ts) names it through `uniqueFieldNames`, numbered past any key the documents already use (`(No column name) (2)` when one is literally called `(No column name)`). +Every other key, and every document without an empty key, is answered as read. + **`options` handling differs per operation** (a real source of surprise — see [Known limitations](#13-known-limitations--future-work)): diff --git a/docs/providers/mssql.md b/docs/providers/mssql.md index bbbbda1a8f..fff8b8204d 100644 --- a/docs/providers/mssql.md +++ b/docs/providers/mssql.md @@ -203,7 +203,7 @@ schema; it is deleted. Row counts still come from `SUM(sys.partitions.rows)`. ### 3.4 `rowsAffected` is surfaced Unlike the MySQL/Oracle providers (which report `rows.length`), `query()` sets -`rowCount = result.rowsAffected?.[0] ?? recordset.length` ([`mssql.ts`](../../src/lib/db/providers/sql/mssql.ts)), +`rowCount = result.rowsAffected?.[0] ?? rows.length` ([`mssql.ts`](../../src/lib/db/providers/sql/mssql.ts)), and `queryInTransaction()` repeats the same expression, so a non-`SELECT` statement returns its real affected-row count. @@ -346,9 +346,30 @@ to the newer name would change the wording on the form without changing a single `@p1`, `@p2`, … via `request.input()`, runs the query, and returns: ```ts -{ rows: recordset, fields, rowCount: rowsAffected[0] ?? recordset.length, executionTime, columnTypes? } +{ rows, fields, rowCount: rowsAffected[0] ?? rows.length, executionTime, columnTypes?, resultSets? } ``` +**Rows are read by position, so every column keeps its own name and value.** +`query()`, `queryReadOnly()` and `queryInTransaction()` set `request.arrayRowMode = true` on the request that runs the user's statement, and only there: the provider's own catalog, monitoring and admission reads keep the driver's object rows, because they read columns the provider itself named. +In array row mode the driver answers each row as its values in column order and the recordset's `columns` as a list, so `fields` is `uniqueFieldNames` ([`result-fields.ts`](../../src/lib/db/utils/result-fields.ts)) over the declared names and each row is keyed by those names, position by position. +SQL Server leaves every unaliased expression unnamed, and such a column is named `(No column name)`, as SQL Server's own tools word it; a repeated name is numbered `name (2)`, `name (3)`. +A row whose value count differs from the column count, or a result set without its column list, is refused with a `QueryError` rather than shifted or named by guess. +Measured 2026-10-07 on SQL Server 2022 RTM-CU27 (16.0.4295.3) through `mssql` 12.7.2, through `query()`: + +| statement | in object row mode, before | `fields` now | row now | +|---|---|---|---| +| `SELECT @@VERSION` | `fields: [""]`, row `{ "": "Microsoft SQL Server 2022 …" }` | `["(No column name)"]` | `{ "(No column name)": "Microsoft SQL Server 2022 …" }` | +| `SELECT 1, 2` | one column `""`, row `{ "": [1, 2] }` | `["(No column name)", "(No column name) (2)"]` | `{ "(No column name)": 1, "(No column name) (2)": 2 }` | +| `SELECT 1 AS a, 2 AS a` | one column `a`, row `{ a: [1, 2] }` | `["a", "a (2)"]` | `{ a: 1, "a (2)": 2 }` | +| `SELECT TOP 0 1 AS x, 2 AS x` | `fields: ["x"]` | `["x", "x (2)"]` | none | +| `… FOR JSON PATH` | one column, the whole text | `["JSON_F52E2B61-18A1-11d1-B105-00805F49916B"]` | that name, holding the whole JSON text | +| `… FOR XML PATH` | one column, the whole text | `["XML_F52E2B61-18A1-11d1-B105-00805F49916B"]` | that name, holding the whole XML text | + +The empty name crashed the results grid, which cannot build a column with an empty id, and its error boundary stayed failed until a page reload. +A repeated name lost every value but one: object row mode merges the repeats into one array under one key. +`FOR JSON` and `FOR XML` are the one shape the driver builds as an object even in array row mode: it joins the chunks of the text into a single row keyed by the column's position (`{ "0": text }`, `lib/tedious/request.js`), which the provider reads as the one value it is. +Each set of a multi-result batch is named on its own: `SELECT 1 AS a, 2 AS a; SELECT 3, 4 AS b` answers `fields` `["a", "a (2)"]` and a second set with `["(No column name)", "b"]`. + A text that returns several result sets still answers with its **first**, as `EXEC sp_help` always did, and carries every set in `resultSets` (#1312). `POST /api/db/query` and `POST /api/db/transaction` do not send that field; the multi-statement route reads it to show a batch's last result with rows. @@ -400,7 +421,7 @@ throw — it does **not** confirm the cancellation actually took effect. Exposed ISO instant, `CAST('10:30:00.1234567' AS time(7))` arrived as `1970-01-01T10:30:00.123Z`: a moment it does not hold, and four digits short. `query()`, `queryReadOnly()` and `queryInTransaction()` now rewrite the three declarations into the engine's text, keyed on - `recordset.columns` - the same map [§5.4](#54-declared-column-types) reads - and reconstruct + the result's column metadata by the names in `fields` - the same map [§5.4](#54-declared-column-types) reads - and reconstruct the fraction from the remainder the driver keeps on the value (`nanosecondsDelta`), so `time(7)` keeps all seven digits. `datetimeoffset` is deliberately untouched: it IS an instant. Measured 2026-09-28 on SQL Server 2022 CU27 (16.0.4295.3) through `mssql` 12.7.2 / @@ -430,11 +451,14 @@ throw — it does **not** confirm the cancellation actually took effect. Exposed ### 5.4 Declared column types -`mssql` attaches a `columns` map to the recordset, and each entry's `type` carries a `declaration` - +`mssql` attaches its column metadata to the recordset, and each entry's `type` carries a `declaration` - T-SQL's own lowercase spelling. That is passed through into `QueryResult.columnTypes` -([column-types.ts](../../src/lib/db/providers/sql/column-types.ts)) by both `query()` and -`queryInTransaction()`, keyed by the column name. `type` is a factory FUNCTION for some of the +([column-types.ts](../../src/lib/db/providers/sql/column-types.ts)) by `query()`, `queryReadOnly()` and +`queryInTransaction()`, keyed by the column's name in `fields`. `type` is a factory FUNCTION for some of the driver's types and a plain object for others; `declaration` is on both. +In the array row mode the user paths read with ([§5.1](#51-execution)) the metadata is a list in column order, so it is keyed by the unique names: `SELECT 1 AS a, N'x' AS a` declares `{ a: "int", "a (2)": "nvarchar" }`, and an unnamed column's type sits under `(No column name)`. +Object row mode kept one map entry per distinct name, so a repeated column's type was lost with its value. +A zero-row result names its columns and declares their types alike, on every path. Measured on SQL Server 2022 CU26 over the probe table: @@ -460,7 +484,7 @@ This is the only source of a type for a computed column or an ad-hoc projection. because `BIGINT` reaches the browser as a string and `DECIMAL` as a number (§5.3): measured before this existed, the probe table's `BIGINT` and `UNIQUEIDENTIFIER` columns both exported as `NVARCHAR(MAX)` and its `DECIMAL(10,2)` as `FLOAT` - which is the same evidence read the right way -round, a string exporting as text and a number as a float. Both execution paths fill it from the same column map - including +round, a string exporting as text and a number as a float. All three execution paths fill it from the same column map - including `queryInTransaction()`, which had the map available all along and simply never read it. ### 5.5 What the SQL INSERT export writes for a BIT @@ -1673,6 +1697,7 @@ Integration tests live in The `mssql` module is replaced with an in-process mock via `mock.module('mssql', …)` **before** the provider is imported — there is no live SQL Server in the suite. The mock's pool/request returns canned `{ recordset, rowsAffected }` results, exercising the same code paths as the real driver. +A request the provider sets to array row mode gets the fixture converted to the driver's array shape (rows of values, a list of column metadata); a fixture that must say what only that shape can, an unnamed or repeated column, is written in it directly. > ⚠️ **Mock isolation:** `bun`'s `mock.module()` is process-wide; files mocking different drivers > would cross-contaminate if they shared one. They never do: `bun run test` gives every test file its diff --git a/docs/providers/mysql.md b/docs/providers/mysql.md index d0ff1df937..8bd3b1300b 100644 --- a/docs/providers/mysql.md +++ b/docs/providers/mysql.md @@ -270,8 +270,8 @@ against three live servers: One behaviour does differ, and only for a connection that opted into `multipleStatements=true` in its connection string: a `;`-separated statement is rejected by the prepared protocol and accepted by the text one, which then answers an array of result sets. That is the shape `CALL ()` already -answers today on both protocols, so nothing new reaches the envelope; the app splits multi-statement -input itself (`POST /api/db/multi-query`) and issues one statement per call. +answers on both protocols, and the envelope reads it the same way ([§5.1](#51-execution)); the app +splits multi-statement input itself (`POST /api/db/multi-query`) and issues one statement per call. `rowCount` is `rows.length` **only when the driver returns a row array** (i.e. `SELECT`); for a non-`SELECT` statement mysql2 returns a `ResultSetHeader` rather than an array, and the provider @@ -656,9 +656,35 @@ standard envelope with the driver's own values ([§3.3](#33-blob--binary-values-reach-every-surface-as-bytes)): ```ts -{ rows, fields: string[], rowCount: rows.length, executionTime, columnTypes? } +{ rows, fields: string[], rowCount: rows.length, executionTime, columnTypes?, resultSets? } ``` +**Every column keeps its own value.** +`query()` and `queryInTransaction()` ask `mysql2` for array rows (`rowsAsArray: true`, on the text protocol, the prepared one, the client-side binding and the utf8mb3 relabelling path alike), name the columns with `uniqueFieldNames` ([result-fields.ts](../../src/lib/db/utils/result-fields.ts)) and key each row by those names by position. +`mysql2`'s object rows key a value by its column's name and keep the last of two columns that share one, so before this a repeated name lost a value with no error. +`rowsAsArray` changes the row's shape and nothing else: `dateStrings`, `supportBigNumbers` and the relabelled decoding read every value as before. +`columnTypes` is keyed by the same names. +A row whose value count is not the column count raises a `QueryError` rather than being read. +The provider's own reads (the object tree, monitoring, maintenance) keep object rows, because they read columns by names they wrote themselves. + +Measured on MySQL 8.4 through `mysql2` 3.24.5 on 2026-10-07, before and after: + +| Statement | Before | After | +|---|---|---| +| `SELECT 1 AS a, 2 AS a` | fields `["a","a"]`, row `{"a":2}` | fields `["a","a (2)"]`, row `{"a":1,"a (2)":2}` | +| `SELECT 1, 1` (MySQL names an unaliased expression by its text) | fields `["1","1"]`, row `{"1":1}` | fields `["1","1 (2)"]`, row `{"1":1,"1 (2)":1}` | +| `SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id` | fields `["id","customer_id","item","id","name"]`, the customer's id under both `id` headers, the order's id gone | fields `["id","customer_id","item","id (2)","name"]`, the order's id under `id` and the customer's under `id (2)` | +| `SELECT ''` (MySQL names a column holding an empty string literal by it, and `SELECT 1 AS ''` names it empty too) | fields `[""]`, which the results grid cannot take as a column id | fields `["(No column name)"]`, row `{"(No column name)":""}` | +| `SELECT '', ''` | fields `["",""]`, one value under `""` | fields `["(No column name)","(No column name) (2)"]`, both values | +| `CALL sys.ps_setup_show_enabled(FALSE, FALSE)` | `TypeError: undefined is not an object (evaluating 'f.name')`, after the procedure had run | the first of its four result sets, and all four in `resultSets` | + +An empty column name is named `(No column name)`, as `uniqueFieldNames` names it for every engine. + +**A `CALL` answers a list of result sets.** +`mysql2` answers one result set per SELECT the procedure ran and the call's own OK packet last, with the field packets one list per set and `undefined` for each header. +`rows`, `fields` and `columnTypes` are the first set's, `rowCount` its row count, and `resultSets` lists every set when there are several. +A list with no result set in it, which only a connection that opted into `multipleStatements` can receive, answers the first header's `affectedRows`. + Native `mysql2` errors are normalised via `mapDatabaseError()` into the shared [`errors.ts`](../../src/lib/db/errors.ts) classes. diff --git a/docs/providers/neo4j.md b/docs/providers/neo4j.md index b8aa8bf96e..7ef1fe1c87 100644 --- a/docs/providers/neo4j.md +++ b/docs/providers/neo4j.md @@ -266,6 +266,8 @@ Every value is converted on the server before it leaves the transport, so the re The deprecated numeric `identity` is never emitted, and `elementId` is stable only within one transaction: do not keep one to find the same node later. `columnTypes` names `Node`, `Relationship` or `Path` for a column whose every non-null value has that form, `Mixed` when graph forms mix or meet other values, and nothing for a scalar column; the grid shows it in the header. The `"~graph"` tag is how the Graph tab finds graph values in any result without a provider change (section 5.6). +Neo4j refuses two output columns of one name, so a repeated column never reaches the grid: measured on 5.26, `RETURN 1 AS a, 2 AS a` and `MATCH (n) RETURN n.x, n.x` both fail with `Neo.ClientError.Statement.SyntaxError` "Multiple result columns with the same name are not supported". +An empty output name is accepted (``` RETURN 1 AS `` ``` answers the key `""`), and the transport names that column `(No column name)` through `uniqueFieldNames`, reading each value by its position in the record. ### 5.3 Bounds diff --git a/docs/providers/opensearch.md b/docs/providers/opensearch.md index cd7659c371..83a3a36313 100644 --- a/docs/providers/opensearch.md +++ b/docs/providers/opensearch.md @@ -254,16 +254,18 @@ Four properties the code depends on: - **The alias is a separate member.** Upstream declares `{"name":"who"}` for the same statement — the alias *is* the name there — so reading `name` alone would label this column `customer`, which is a **wrong** label rather than a missing one. `describeColumns()` - ([http-transport.ts:715](../../src/lib/db/providers/sql/search/http-transport.ts)) prefers the alias + ([http-transport.ts:719](../../src/lib/db/providers/sql/search/http-transport.ts)) prefers the alias when the dialect declares an `aliasKey`, and the alias is what the user typed, so it is what the grid must show. + An alias that is present but not text (an object, a number) is refused with an engine error, as a name that is not text is, rather than passed over for the name the user aliased away; a `null` alias is no alias, and the name stands. - **Rows are positional**, so each row is rebuilt against the declared column list rather than read as - an object; the declared **order** is authoritative in a way object keys never are. -- **A duplicate output name is REFUSED, not disambiguated.** Measured, `SELECT 1 AS c, 2 AS c` answers + an object; the declared **order** is authoritative in a way object keys never are, and a row whose + value count differs from the declaration is refused rather than padded with nulls or cut. +- **A duplicate output name is REFUSED, not numbered.** Measured, `SELECT 1 AS c, 2 AS c` answers HTTP 400, `IllegalArgumentException`, "Multiple entries with same key: c=2 and c=1" — where upstream answers 200 with two columns named `c`. So the seam's uniqueness invariant is load-bearing on exactly - one of the two products, and `disambiguate()` - ([http-transport.ts:695](../../src/lib/db/providers/sql/search/http-transport.ts)) can never fire + one of the two products, and the numbering in `uniqueFieldNames()` + ([result-fields.ts](../../src/lib/db/utils/result-fields.ts)) can never fire here. That is a fact about this engine, not dead code. - **`total` and `size` accompany every answer**, so `SearchQueryResult.totalHits` is a real number here and `null` upstream. It is deliberately **not used** by the provider diff --git a/docs/providers/oracle.md b/docs/providers/oracle.md index ea9b3da3c7..a42f7ab56a 100644 --- a/docs/providers/oracle.md +++ b/docs/providers/oracle.md @@ -509,6 +509,23 @@ and returns: { rows, fields: metaData.map(m => m.name), rowCount: rows.length, executionTime, columnTypes? } ``` +**Column names.** Every column reaches the grid named, and under a name no other column of the result has, by the driver's own doing. +An unaliased expression is named by its text (`SELECT 1+1 FROM dual` answers `1+1`), and an empty alias is refused by the server (`SELECT 1 AS "" FROM dual` fails with `ORA-01741: illegal zero-length identifier`). +A repeated name is numbered by node-oracledb itself, in `metaData` and before any row is keyed (`_setup` in `oracledb/lib/impl/resultset.js`, 6.10.0): the first column keeps the name, a repeat takes `NAME_1`, `NAME_2`, and a number skips a name the statement declares. +Measured on 2026-10-07 against Oracle XE with that driver, the same in `OUT_FORMAT_OBJECT` and `OUT_FORMAT_ARRAY`: + +| statement | names | row | +|---|---|---| +| `SELECT 1 AS a, 2 AS a FROM dual` | `A`, `A_1` | `{ A: 1, A_1: 2 }` | +| `SELECT 1 AS a, 2 AS a, 3 AS a_1 FROM dual` | `A`, `A_2`, `A_1` | `{ A: 1, A_2: 2, A_1: 3 }` | +| a join projecting `user_id` from both sides of `all_users` | `USER_ID`, `USER_ID_1` | both values | +| `SELECT 1+1, 1+1 FROM dual` | `1+1`, `1+1_1` | both values | + +So no value is lost, and the provider keys rows by the names the driver gives rather than through `uniqueFieldNames`. +The declared names are not gone: the driver hands each column to the call's `fetchTypeHandler` under its declared name before it renames it, though only for a statement it does not serve from its statement cache. +Rebuilding the shared `name (2)` form from them was tried and left out: it would take a statement-cache miss on every editor statement, and a `CURSOR(...)` column read as arrays loses the names of its nested columns. +So a repeat reads `NAME_1`, which data masking and the inline-edit refusal do not read as a repeat of `NAME`. + A `SELECT` answers with a `rows` array and `rowCount` is `rows.length`. A non-`SELECT` (INSERT/UPDATE/DELETE/DDL/PL/SQL) carries **no `rows` array at all**, and that absence is what selects the other branch of `buildQueryResult()`: the grid is empty (`rows: []`, `fields: []`, no diff --git a/docs/providers/postgres.md b/docs/providers/postgres.md index d9d176e17b..b7dcbcc55a 100644 --- a/docs/providers/postgres.md +++ b/docs/providers/postgres.md @@ -1320,9 +1320,32 @@ acquires a pooled client, optionally records its backend PID for cancellation, r (optionally parameterized — `$1`, `$2`, …) statement, and returns the standard envelope: ```ts -{ rows, fields: string[], rowCount, executionTime, columnTypes?, warnings? } +{ rows, fields: string[], rowCount, executionTime, columnTypes?, warnings?, resultSets? } ``` +#### Result columns: every column keeps its own value + +`query()`, `queryInTransaction()` and `queryReadOnly()` ask `pg` for array rows (`rowMode: "array"`), name the columns with `uniqueFieldNames` ([result-fields.ts](../../src/lib/db/utils/result-fields.ts)) and key each row by those names by position. +`pg`'s object rows key a value by its column's name and keep the last of two columns that share one, so before this a repeated name lost a value with no error. +`rowMode` changes the row's shape and nothing else: the pool's type parsers (§5.5) read every value as before. +`columnTypes` is keyed by the same names. +A row whose value count is not the column count raises a `QueryError` rather than being read. + +Measured on PostgreSQL 16 through `pg` 8.23.1 on 2026-10-07, before and after: + +| Statement | Before | After | +|---|---|---| +| `SELECT 1 AS a, 2 AS a` | fields `["a","a"]`, row `{"a":2}` | fields `["a","a (2)"]`, row `{"a":1,"a (2)":2}` | +| `SELECT 1, 2` (unnamed columns, which PostgreSQL calls `?column?`) | fields `["?column?","?column?"]`, row `{"?column?":2}` | fields `["?column?","?column? (2)"]`, row `{"?column?":1,"?column? (2)":2}` | +| `SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id` | fields `["id","customer_id","item","id","name"]`, the customer's id under both `id` headers, the order's id gone | fields `["id","customer_id","item","id (2)","name"]`, the order's id under `id` and the customer's under `id (2)` | + +PostgreSQL never answers an empty column name: an unaliased expression is named by the server (`?column?`, `count`, `now`), so an unnamed column is only ever a repeated one here. + +A text of several statements sent as one simple query (`SELECT 1 AS a; SELECT 2 AS b, 3 AS b`) answers one result per statement. +`rows`, `fields` and `columnTypes` are the first result set's, `rowCount` is the count of the statement that produced it (or of the first statement when none produced one), and `resultSets` lists every set when there are several; a statement that answers no column (an INSERT, a COMMIT, or a `SELECT` of no columns) is not a set. +Measured the same day, that text answers `rows` `[{"a":1}]` and two `resultSets`, the second with fields `["b","b (2)"]`. +Before, such a text answered no `rows` at all, because the list of answers was read as one answer. + #### Server notices (#1401) The NOTICE and WARNING messages the server sends while a statement that SUCCEEDS runs come back in diff --git a/docs/providers/sqlite.md b/docs/providers/sqlite.md index 97aa816c3b..0a7c70b50c 100644 --- a/docs/providers/sqlite.md +++ b/docs/providers/sqlite.md @@ -204,7 +204,7 @@ used to leave the handle held, so the user could not delete or move the file the ### 3.3 Read vs write dispatch `query()` ([`sqlite.ts`](../../src/lib/db/providers/sql/sqlite.ts)) prepares the statement and -branches on `stmt.returnsRows()`: a statement with result columns uses `stmt.all()` and returns rows; +branches on `stmt.returnsRows()`: a statement with result columns uses `stmt.values()` and returns rows ([Result column names](#result-column-names)); one without uses `stmt.run()` and returns `{ changes }`. `rowCount = rows.length || changes`. Both drivers are **synchronous**: the provider wraps them in the async signature but there is no real concurrency or cancellation. @@ -217,8 +217,8 @@ method. So the branch does not depend on how the statement is spelled: | Statement | Branch | Result | |---|---|---| -| `SELECT ...`, `WITH ... SELECT`, `WITH RECURSIVE ...`, `VALUES (1), (2)`, `EXPLAIN ...`, `PRAGMA journal_mode` | `all()` | the rows | -| `INSERT` / `UPDATE` / `DELETE ... RETURNING ...` | `all()` | the returned rows, and the write is applied; `rowCount` is the number of rows returned | +| `SELECT ...`, `WITH ... SELECT`, `WITH RECURSIVE ...`, `VALUES (1), (2)`, `EXPLAIN ...`, `PRAGMA journal_mode` | `values()` | the rows | +| `INSERT` / `UPDATE` / `DELETE ... RETURNING ...` | `values()` | the returned rows, and the write is applied; `rowCount` is the number of rows returned | | plain `INSERT` / `UPDATE` / `DELETE` (CTE-led ones included), DDL, `BEGIN`, `PRAGMA user_version = 3` | `run()` | no rows, `rowCount` from `changes` | The router used to be the inherited `isReadOnlyQuery(sql)`, a leading-keyword set of `SELECT`, @@ -305,7 +305,7 @@ spells the request its own way — bun `safeIntegers`, node `readBigInts` — bu row reaches the browser, refuses a `BigInt` outright. So both adapters set their own spelling and [`sqlite-driver.ts`](../../src/lib/db/providers/sql/sqlite-driver.ts) converts back at the one seam every row crosses — `prepare()`, the provider's only row-returning entry point (`exec()` returns -nothing), wrapped by `withoutBigInts()` so `all()`, `get()` and `run()` are covered alike. Measured +nothing), wrapped by `withoutBigInts()` so `all()`, `values()`, `get()` and `run()` are covered alike. Measured through both adapters in the same pass, with identical answers: | Value read | Answered as | @@ -485,7 +485,7 @@ Homebrew). Each channel is browser-verified by ## 5. Query interface -`query(sql, params?)` — positional params via the driver's `all()`/`run()`. There is no +`query(sql, params?)`: positional params via the driver's `values()`/`run()`. There is no `prepareQuery()` override, so the inherited base injects a `LIMIT` into bare `SELECT`s (`DEFAULT_QUERY_LIMIT = 500`). No transactions, no cancellation ([§3.4](#34-no-transactions-api-no-cancellation-no-pool)). @@ -570,6 +570,24 @@ A 64-bit id is where the two features meet: it leaves as the decimal string declared `INTEGER`, so the export writes an `INTEGER` column rather than the `TEXT` a value-shaped guess would produce. +### Result column names + +Every result column comes back under a name that is non-empty and that no other column of the result has, with its own value. +The rows are read as arrays (bun:sqlite `values()`, node:sqlite `all()` after `setReturnArrays(true)`, bridged as one `values()` in [`sqlite-driver.ts`](../../src/lib/db/providers/sql/sqlite-driver.ts)) and keyed by position under `uniqueFieldNames` of the declared names ([`result-fields.ts`](../../src/lib/db/utils/result-fields.ts)). +Measured 2026-10-07 on both drivers in-process (Bun 1.4.2), pinned in `tests/integration/db/sqlite-provider.test.ts`: + +| Statement | `fields` | Row | +|---|---|---| +| `SELECT 1 AS a, 2 AS a` | `a`, `a (2)` | `{ a: 1, "a (2)": 2 }` | +| `SELECT * FROM orders o JOIN customers c ON c.id = o.customer_id` | `id`, `customer_id`, `item`, `id (2)`, `name` | the order's id under `id`, the customer's under `id (2)` | +| `SELECT 1 AS ""` | `(No column name)` | `{ "(No column name)": 1 }` | +| `SELECT 4 + 5` | `4 + 5` | SQLite names an unaliased expression by its text, so only an empty alias is ever unnamed | + +Before this, `fields` was the keys of the first row object, so a repeated name was one column holding the last value and an empty alias was a column named `""`. +A declared type is paired with its column by position, so `id (2)` above carries the customer table's `INTEGER`. +An empty result names its columns too, because the names come from the statement and not from a first row. +The agent's `queryReadOnly()` reads the same way. + ### BLOB values Both drivers read a `BLOB` as a plain `Uint8Array`, and the rows reach the browser through diff --git a/docs/providers/trino.md b/docs/providers/trino.md index a9b619f963..15f7969dfb 100644 --- a/docs/providers/trino.md +++ b/docs/providers/trino.md @@ -417,6 +417,11 @@ counter above 2^53 would arrive as a string and `numberField` would read it as a Column labels are taken from the page's declaration and **de-duplicated** — a second column called `x` becomes `x (2)` — because the seam promises `fieldNames` is exactly the key set of every row. +A number never takes a name the statement itself declares, before or after the repeat: +`SELECT 1 AS a, 2 AS a, 3 AS "a (2)"` reaches the grid as `a`, `a (3)`, `a (2)`, so the user's own +`a (2)` keeps its value. Trino names an unaliased expression itself (`_col0`), so a declaration with +no name is not something the engine sends; if one ever arrives it is shown as `(No column name)`. +A row whose value count differs from the declaration is refused rather than padded with nulls or cut. ### 3.12 Statelessness is a warning, not a silent surprise diff --git a/src/components/LazyView.tsx b/src/components/LazyView.tsx index aa971ce8e6..da839e1945 100644 --- a/src/components/LazyView.tsx +++ b/src/components/LazyView.tsx @@ -2,6 +2,7 @@ import React from "react"; import { LoaderCircle } from "lucide-react"; +import { ChunkLoadError } from "@/lib/lazy"; import { logger } from "@/lib/logger"; import { cn } from "@/lib/utils"; @@ -42,53 +43,114 @@ interface ChunkBoundaryProps { className?: string; /** Takes the failed view away, for a caller that can: the notice then offers Close. */ onDismiss?: () => void; + /** + * What the boundary is showing, for a caller whose one boundary holds many things (the + * results panel: every result, every mode, every tab). When any entry changes, by + * `Object.is`, a failure is cleared and the new children are rendered; a view that + * still fails throws again and is shown failing again. + */ + resetKeys?: readonly unknown[]; } +interface ChunkBoundaryState { + /** What the view threw, or null while it renders. */ + error: { thrown: unknown } | null; + /** The keys the current state belongs to. */ + resetKeys: readonly unknown[] | undefined; +} + +function sameKeys(a: readonly unknown[] | undefined, b: readonly unknown[] | undefined): boolean { + if (a === b) return true; + if (a === undefined || a.length !== b?.length) return false; + return a.every((key, index) => Object.is(key, b[index])); +} + +function messageOf(thrown: unknown): string { + return thrown instanceof Error ? thrown.message : String(thrown); +} + +/** + * What a render-error notice says under its first line, whatever was thrown. The thrown message goes to + * the log and never to the screen: it may quote a value (a `JSON.parse` SyntaxError quotes its input), + * and a value shown there would bypass masking. + */ +export const RENDER_ERROR_HINT = + "Something in what it was given could not be drawn. The error is in the browser console."; + /** - * The boundary a failed chunk lands in. + * The boundary a failed view lands in. * * A class, because catching a render-time error is still the one thing hooks cannot - * do. Reloading is the honest remedy rather than a local retry: the common cause is a - * page whose build no longer exists on the server, and no number of retries against - * the old chunk names will produce it — only re-fetching the document will. + * do. It tells two failures apart, by class and never by message (a production render + * error can have none): + * + * - a `ChunkLoadError`, a view whose code never arrived. Reloading is the honest remedy + * rather than a local retry: the common cause is a page whose build no longer exists + * on the server, and no number of retries against the old chunk names will produce + * it; only re-fetching the document will. + * - anything else, a view that threw while drawing what it was given. That is said as + * such, in a fixed sentence (`RENDER_ERROR_HINT`; the message is logged, not shown), and + * offers Try again; Reload would be a false diagnosis, and the next thing the boundary is + * given (`resetKeys`) clears it. */ -export class ChunkBoundary extends React.Component { - state = { failed: false }; +export class ChunkBoundary extends React.Component { + state: ChunkBoundaryState = { error: null, resetKeys: this.props.resetKeys }; - static getDerivedStateFromError() { - return { failed: true }; + static getDerivedStateFromError(thrown: unknown): Partial { + return { error: { thrown } }; } - componentDidCatch(error: Error) { - logger.warn("A split view could not be loaded", { + static getDerivedStateFromProps( + props: ChunkBoundaryProps, + state: ChunkBoundaryState, + ): Partial | null { + if (sameKeys(props.resetKeys, state.resetKeys)) return null; + return { error: null, resetKeys: props.resetKeys }; + } + + componentDidCatch(thrown: unknown) { + logger.warn(thrown instanceof ChunkLoadError ? "A split view could not be loaded" : "A view failed to render", { route: "ChunkBoundary", view: this.props.label, - error: error.message, + error: messageOf(thrown), }); } + private readonly retry = () => this.setState({ error: null }); + render() { - if (!this.state.failed) return this.props.children; + const { error } = this.state; + if (error === null) return this.props.children; + const isChunk = error.thrown instanceof ChunkLoadError; return (
-

{this.props.label} could not be loaded.

-

- This view is fetched when it is first opened, and the request did not complete. If the server was upgraded - while this page was open, reloading picks up the new one. -

+ {isChunk ? ( + <> +

{this.props.label} could not be loaded.

+

+ This view is fetched when it is first opened, and the request did not complete. If the server was upgraded + while this page was open, reloading picks up the new one. +

+ + ) : ( + <> +

{this.props.label} could not be displayed.

+

{RENDER_ERROR_HINT}

+ + )}
{this.props.onDismiss && (