Skip to content

fix(db): give every result column a name and a value of its own - #1575

Merged
cevheri merged 47 commits into
mainfrom
fix/result-column-names
Oct 7, 2026
Merged

cevheri merged 47 commits into
mainfrom
fix/result-column-names

Conversation

@cevheri

@cevheri cevheri commented Oct 7, 2026

Copy link
Copy Markdown
Member

Two defects from the browser pass of #1570 had one cause: providers keyed result rows by the column names the driver declared.

  • SQL Server leaves an unaliased expression unnamed (SELECT COUNT(*) FROM t). The grid threw on the empty name, and the results panel stayed broken until a reload.
  • A repeated name, such as id from both sides of a join, lost a value silently on PostgreSQL, MySQL, SQLite, DuckDB, libSQL, Cassandra, ClickHouse and InfluxDB 3: both headers showed one table's value.

Providers now read results by position and name them with one rule, uniqueFieldNames: an unnamed column or an empty document key is (No column name), a repeat is name (2). QueryResult.fields states the invariant. Where the driver has already merged a repeat (Cassandra, InfluxDB 3), the result is refused with a clear error. The results panel resets when its result changes, masking covers a numbered repeat, and inline edit refuses a generated name.

Visible change: a ClickHouse statement with its own FORMAT JSON now shows raw text, like any other explicit format.

Verified: the full gate set with 100% line coverage, and in a browser on PostgreSQL 16, MySQL 8.4, SQL Server 2022 and SQLite, agent mode included.

cevheri added 30 commits October 7, 2026 12:25
…eps its value

Add uniqueFieldNames beside unionFields: a column a driver declares with no
name is named "(No column name)", and a repeated name is numbered "name (2)",
never taking a name the result itself declares. QueryResult.fields now states
the invariant the providers keep: non-empty, unique, and the keys rows use.
…lumns keep their values

query(), queryReadOnly() and queryInTransaction() now ask the driver for array row mode on the request that runs the user's statement, name the columns with uniqueFieldNames, and key rows, columnTypes and the zoneless value conversion by those names.
SELECT @@Version answered fields [""], which crashed the results grid, and SELECT 1 AS a, 2 AS a merged both values into one column.
FOR JSON and FOR XML stay one column holding the whole text, a row whose value count differs from the column count is refused, and the provider's own catalog and admission reads keep object rows.
The transaction path now names a zero-row result's columns, which closes the mssql bullet of BACKLOG X9.
…le stale test names

The batch test now gives its second set a datetime2 column and checks the
converted text under its unique name, so dropping that conversion fails.
Two tests are retitled to what they now exercise, a test comment no longer
cites the deleted X9 bullet, and the columnTypes section names all three
execution paths.
…column keeps its value

pg and mysql2 object rows keep only the last of two columns that share a name, so a join projecting id from both tables showed one table's id under both headers. query(), queryInTransaction() and the PostgreSQL read-only profile now ask for array rows (rowMode "array", rowsAsArray), name the columns with uniqueFieldNames and key each row by position; columnTypes follows the same names, and a row whose value count differs from the column count raises.

A multi-statement PostgreSQL text and a MySQL CALL answer a list of results: rows, fields and columnTypes are now the first result set's and resultSets lists every set. Before, the PostgreSQL text answered no rows and a CALL threw a TypeError after the procedure ran.

Measured on PostgreSQL 16 and MySQL 8.4; provider docs updated. object-route.ts citations of postgres.ts line numbers moved with the inserted helpers.
… docs

MySQL answers an empty column name for SELECT '' and SELECT 1 AS ''; the
mysql doc claimed it never does. State the measured before and after, and
pin SELECT '', '' in a mock and a wire test.

Word the PostgreSQL multi-result rule by what the code tests (a statement
that answers no column), and stop claiming mysql2 lets a __proto__ column
through.
…mn name keeps its value

The provider keyed each row by column name and took fields from the first
row's keys, so SELECT 1 AS a, 2 AS a answered one column holding 2 and
SELECT 1 AS "" answered a column named "". Both drivers now read user
results as arrays (bun:sqlite values(), node:sqlite setReturnArrays) and
key them by uniqueFieldNames, on query() and queryReadOnly(), with each
declared type paired to its column by position. An empty result now names
its columns. keyRowsByPosition is the shared row builder, and refuses a row
whose value count differs from the column count.
…its value

getRowObjectsJson() keys a repeated column a:1 while columnNames() lists
the name twice, so both headers showed the first value, and a column the
statement itself named a:1 was overwritten by the repeat. The client now
reads getRowsJson() and keys each row by position under uniqueFieldNames,
so query() and queryReadOnly() answer a, a (2) with both values and both
types. DuckDB refuses an empty alias at parse time, pinned as such.
…lumn keeps its value

The Hrana transport keyed each row by the declared name, so a repeated name
kept only the last value while fields listed it twice, and an unnamed column
became column_N, which a column the statement named column_1 overwrote.
Columns are now named with uniqueFieldNames and rows keyed by position, with
declared types under the same names. A row whose value count is not the
column count, or that is not a list, is refused instead of padded.
… one name

cassandra-driver builds each row as row[column.name] = value, so two
columns of one name reach the provider holding only the last value, and
both grid headers showed it. That result is now refused with a query error
naming the column and asking for an alias, since the earlier value cannot
be recovered. A column declared with an empty name is keyed
"(No column name)", with its declared type under the same name.
…rch, Db2 and Trino

The four private copies of the repeat-numbering rule never reserved a name the
result declares later, so `SELECT 1 AS a, 2 AS a, 3 AS "a (2)"` showed the
user's own `a (2)` column under a generated name. Each transport now calls
uniqueFieldNames, which numbers that repeat `a (3)` and names an empty Trino
declaration `(No column name)`. Docs state the new numbering.
…ruid, search, Db2 and Trino

Each transport read a row by position and padded a short one with null or dropped the tail of a long one, so a malformed answer reached the grid with invented or missing values. A count mismatch, or a row that is not an array, now raises the provider's own error.
…olumn

A declaration with no name member was keyed "undefined"; it now reaches the shared helper as an empty name and shows as (No column name), as Trino already does.
… as one

The panel's one ChunkBoundary caught every render error, called each a chunk that did not load, and never reset, so one result the grid could not draw (a SQL Server column with no name) left the Reload notice over every later result, mode and tab.

lazyRetry and GraphView now throw a ChunkLoadError for a load that failed, and only that gets the Reload copy; any other error says the view could not be displayed, with its message when it has one, and offers Try again. The boundary resets when its resetKeys change, and BottomPanel keys it by mode, result, agent artifact and explain plan.
Inline edit writes the field name into UPDATE ... SET, and a generated name such as `name (2)` or `(No column name)` is no column of the table: PostgreSQL 16 refuses `SET "name (2)"` on customers(id, name) as a missing column, and a table that had a column of that name would take the write. Such a cell now opens no editor and says why.
…umn it repeats

A result names a repeated column 'email (2)', so the second email no longer shares the first's key and its own value reached the grid, exports, profiler and graph unmasked. Detection now also matches the base of a numbered name.
loadCytoscape now tags a rejected import as ChunkLoadError, so a failure after both packages arrived shows as a render error instead of offering Reload.
…lumn

MongoDB and Couchbase took their columns from the keys the rows carry, and a
key "" became an empty field, which breaks the QueryResult.fields invariant.
uniquelyKeyedRows names it through uniqueFieldNames and keys the rows carrying
it under that name; rows without one are answered as read.
…roducer

The grid's inline-edit refusal and masking each parsed "name (N)" with their
own regex. numberedRepeatBase in result-fields.ts is now the one reader, and
both callers are pinned against uniqueFieldNames output, so a format change
fails their tests too. Each caller keeps what it matched before.
result-fields.ts no longer claims to be server only or to copy the transports
that now call it; column-types.ts says the names arrive unique and counts its
callers by provider; types.ts names the three ways providers keep names apart.
cassandra.md says why a repeat of one column is refused too.
The three providers each re-wrote the length check and positional keying the
helper already does, in three wordings and without the statement text. They
now call keyRowsByPosition, so a row of the wrong width raises one QueryError
that carries the statement. mssql keeps its own reader for the FOR JSON case.
The panel's boundary printed the thrown message under the notice. A message can
quote a value (a JSON.parse SyntaxError quotes its input), which would then show
past masking. The notice now says a fixed sentence and the message goes only to
the log.
…uildRows

Both line numbers drifted when the search transport moved to uniqueFieldNames.
The OpenSearch paragraphs this branch rewrote cite no transport line.
…hared helper

ClickHouse answers SELECT number, number FROM numbers(2) with two meta
columns of one name and one value (measured on 26.7.1). Key fields,
rows and columnTypes by uniqueFieldNames so the grid never gets two
columns of one id.
InfluxDB 3.12 Core answers SELECT c1.usage, c2.usage FROM cpu c1 CROSS
JOIN cpu c2 with the line {"usage":1.5,"usage":1.5}. JSON.parse keeps
one value and a line omits the key of a null cell, so the result lost a
column silently. Refuse it with a sentence that names the column and asks
for an alias.
…eps its value

ClickHouse can declare two columns of one name with different values: a
join qualifies a column as b.x, which can collide with a column the
statement already calls b.x. Under default_format=JSON the row object
named the key twice and JSON.parse kept the last value, so the previous
fix showed 3 under both headers and lost 2 (measured on 26.7.1.1315).

Ask for JSONCompact, number repeated names with uniqueFieldNames in the
transport and build each row by position. A row that is not an array or
whose value count differs from meta is refused with an explicit error.
An explicit FORMAT JSON in the user's SQL now comes back as raw text,
like any other format the user chose.
…cument the all-null case

A three-way self join names a key three times, so the refusal now says
"more than one column named host" instead of "two columns". The doc adds
the measured limit: a repeated name whose other column is null on every
line never appears twice on a line, so it comes back as one column.
Comment thread src/lib/db/providers/sql/mysql.ts Dismissed
Comment thread src/lib/db/providers/sql/mysql.ts Dismissed
Comment thread src/lib/db/providers/sql/mysql.ts Dismissed
Comment thread src/lib/db/providers/sql/postgres.ts Dismissed
@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

cevheri added 15 commits October 7, 2026 13:21
…test can delete it

Under Bun the node driver is Bun's own node:sqlite, whose close() leaves
its statements holding the database, WAL and shared-memory file until
they are collected (three descriptors after disconnect, none after a
collection, measured through /proc/self/fd). On windows-latest the
fixture's removal failed with EBUSY. Node's own close() releases them.
Neo4j 5.26 refuses two output columns of one name but answers an empty one for RETURN 1 AS ``, which reached the grid as an empty column id. The Bolt client now names the record keys through uniqueFieldNames; values were already read by position.
InfluxDB 3.12 Core answers {"":1} for SELECT 1 AS "", and the jsonl reader passed the empty key to the grid as a field. The ordered key union now goes through uniqueFieldNames and each row is keyed by position; the repeated-key refusal is unchanged.
The loop counted up from 2 for every repeat of a name, so n repeats checked about n squared over two candidates, and its condition tested a name while its update moved a counter (SonarCloud S1994). A next number is now kept per base; every result is unchanged (the existing tests pass, and 300,000 random inputs matched the old loop).
…on changes

The docs and schema diff views draw the schema and the connection, which were not among the boundary's reset keys, so a schema one of them could not draw kept the notice up through a schema refresh or a connection switch.
positionalResultSet and rowValues raised QueryError without the statement, unlike keyRowsByPosition and the other providers, so the error payload of a refused result set named no query. Every caller now passes its statement through.
describeColumns passed a non-string column name through String(), so an object would have reached the grid as "[object Object]" (SonarCloud S6551). An absent name is still an unnamed column; a name of any other type is now an explicit engine transport error.
… catch names

The numberedRepeatBase import sits with the file's header, sameKeys compares lengths through an optional chain, and the two catch parameters take the error naming the repository's own catch clauses use (error, and retryError beside an outer error).
…ike to its own

The negative case asserted only names no default pattern matches, so it passed without the numbered-repeat reading. It now checks that email (2) takes the email rule while email (work) keeps its own pattern and email (2)x is not read back to email; the old matcher fails it.
…s measured

node-oracledb 6.10 numbers a repeated column in metaData before any row is keyed (A, A_1; A, A_2, A_1 beside a declared A_1), in both out formats, and the server refuses an empty alias with ORA-01741. Measured against Oracle XE on 2026-10-07; the integration test pins the provider passing the driver's names and values through.
… NAME (2)

node-oracledb renames a repeated column in metaData (A, A_1, skipping a declared A_1), but it hands each column to the call's fetchTypeHandler under its declared name first. query() and queryInTransaction() now record those names in the handler, read array rows, and name the columns through uniqueFieldNames and keyRowsByPosition like the other SQL providers, so a repeat reads A (2) and masking and the inline-edit refusal treat it as one. A statement served from the statement cache arrives already renamed, so both paths run with keepInStmtCache false. Measured on Oracle XE on 2026-10-07 through the provider, both paths, run twice on one connection.
An OpenSearch alias that is present but not a string was passed over for the column name, which is the name the user aliased away. It now raises the same engine error a non-text name does; a null alias is still no alias.
…d names are not rebuilt

The declared names do reach a fetchTypeHandler before node-oracledb renames
a repeat, but only for a statement outside the statement cache; rebuilding
the name (2) form would cost a cache miss on every editor statement, and a
CURSOR column read as arrays loses its nested names. The rewrite that did
it is reverted, and section 5.1 states what masking and the inline-edit
refusal do with NAME_1.
cevheri added a commit that referenced this pull request Oct 7, 2026
… and the edit refusal (U95)

Found by the external review of #1575: node-oracledb numbers a repeat
NAME_1 and the provider keeps it, while masking and the inline-edit
refusal read only the name (N) form.
cevheri added a commit that referenced this pull request Oct 7, 2026
…nly its first (U96)

Found by the external review of #1575: an EXEC of a procedure that selects
twice, a batch inside a transaction, and a MySQL CALL once its sets are
read show the first set with no sign of the others.
@sonarqubecloud

sonarqubecloud Bot commented Oct 7, 2026

Copy link
Copy Markdown

@cevheri
cevheri merged commit 1674b0b into main Oct 7, 2026
64 of 72 checks passed
@cevheri
cevheri deleted the fix/result-column-names branch October 7, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants