From 3971dffa0cd98dea30d10af3c82eaf5ce9c7f1ee Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Sun, 13 Sep 2026 09:51:58 +0000 Subject: [PATCH] fix: allow "length" as a column name in result rows Result rows reserve a non-enumerable "length" property so that a row can be indexed like an array. When a result column is named "length" (e.g. SELECT COUNT(*) AS "length"), the column value was swallowed by that property and rows appeared empty. Define the array-like "length" only when no column claims the name, so the column value takes precedence. For the hrana-based clients, rows built by @libsql/hrana-client have a non-configurable "length" property, so rebuild them in resultSetFromHrana when a "length" column is present. ResultSet.toJSON() also iterated rows via row.length, which is no longer reliable; iterate the real column count instead. Fixes tursodatabase/libsql-client-ts#234 --- packages/libsql-client-wasm/src/wasm.ts | 7 +++- .../src/__tests__/client.test.ts | 28 ++++++++++++++ packages/libsql-client/src/hrana.ts | 38 ++++++++++++++++++- packages/libsql-client/src/sqlite3.ts | 7 +++- packages/libsql-core/src/util.ts | 12 ++++-- 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/libsql-client-wasm/src/wasm.ts b/packages/libsql-client-wasm/src/wasm.ts index 240244a4..6f3f6cd9 100644 --- a/packages/libsql-client-wasm/src/wasm.ts +++ b/packages/libsql-client-wasm/src/wasm.ts @@ -395,8 +395,6 @@ function rowFromSql( intMode: IntMode, ): Row { const row = {}; - // make sure that the "length" property is not enumerable - Object.defineProperty(row, "length", { value: sqlRow.length }); for (let i = 0; i < sqlRow.length; ++i) { const value = valueFromSql(sqlRow[i], intMode); Object.defineProperty(row, i, { value }); @@ -411,6 +409,11 @@ function rowFromSql( }); } } + // make sure that the "length" property is not enumerable, unless a column + // is named "length", in which case the column value takes precedence + if (!Object.hasOwn(row, "length")) { + Object.defineProperty(row, "length", { value: sqlRow.length }); + } return row as Row; } diff --git a/packages/libsql-client/src/__tests__/client.test.ts b/packages/libsql-client/src/__tests__/client.test.ts index a0d7e2f0..812b9db7 100644 --- a/packages/libsql-client/src/__tests__/client.test.ts +++ b/packages/libsql-client/src/__tests__/client.test.ts @@ -197,6 +197,24 @@ describe("execute()", () => { }), ); + test( + 'column named "length"', + withClient(async (c) => { + const rs = await c.execute("SELECT 1 AS one, 'two' AS \"length\""); + expect(rs.columns).toStrictEqual(["one", "length"]); + expect(rs.rows.length).toStrictEqual(1); + + const r = rs.rows[0]; + expect(r[0]).toStrictEqual(1); + expect(r[1]).toStrictEqual("two"); + expect(r["length"]).toStrictEqual("two"); + expect(Object.entries(r)).toStrictEqual([ + ["one", 1], + ["length", "two"], + ]); + }), + ); + test( "statement that produces error", withClient(async (c) => { @@ -994,6 +1012,16 @@ describe("ResultSet.toJSON()", () => { }), ); + test( + 'column named "length"', + withClient(async (c) => { + const rs = await c.execute("SELECT 1 AS one, 'two' AS \"length\""); + const json = rs.toJSON(); + expect(json["columns"]).toStrictEqual(["one", "length"]); + expect(json["rows"]).toStrictEqual([[1, "two"]]); + }), + ); + (hasHrana2 ? test : test.skip)( "row values", withClient(async (c) => { diff --git a/packages/libsql-client/src/hrana.ts b/packages/libsql-client/src/hrana.ts index 588796b4..d76242c6 100644 --- a/packages/libsql-client/src/hrana.ts +++ b/packages/libsql-client/src/hrana.ts @@ -2,6 +2,7 @@ import * as hrana from "@libsql/hrana-client"; import type { InStatement, ResultSet, + Row, Transaction, TransactionMode, InArgs, @@ -384,7 +385,15 @@ export function stmtToHrana(stmt: InStatement | [string, InArgs?]): hrana.Stmt { export function resultSetFromHrana(hranaRows: hrana.RowsResult): ResultSet { const columns = hranaRows.columnNames.map((c) => c ?? ""); const columnTypes = hranaRows.columnDecltypes.map((c) => c ?? ""); - const rows = hranaRows.rows; + let rows: Array = hranaRows.rows; + if (columns.includes("length")) { + // Rows produced by @libsql/hrana-client have a non-configurable + // "length" property which hides a column named "length", so the rows + // are rebuilt to make the column value take precedence. + rows = hranaRows.rows.map((hranaRow) => + rowFromHrana(hranaRow, hranaRows.columnNames), + ); + } const rowsAffected = hranaRows.affectedRowCount; const lastInsertRowid = hranaRows.lastInsertRowid !== undefined @@ -399,6 +408,33 @@ export function resultSetFromHrana(hranaRows: hrana.RowsResult): ResultSet { ); } +function rowFromHrana( + hranaRow: hrana.Row, + colNames: Array, +): Row { + const row = {}; + for (let i = 0; i < colNames.length; ++i) { + const value = hranaRow[i]; + Object.defineProperty(row, i, { value }); + + const colName = colNames[i]; + if (colName !== undefined && !Object.hasOwn(row, colName)) { + Object.defineProperty(row, colName, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + } + // make sure that the "length" property is not enumerable, unless a column + // is named "length", in which case the column value takes precedence + if (!Object.hasOwn(row, "length")) { + Object.defineProperty(row, "length", { value: colNames.length }); + } + return row as Row; +} + export function mapHranaError(e: unknown): unknown { if (e instanceof hrana.ClientError) { const code = mapHranaErrorCode(e); diff --git a/packages/libsql-client/src/sqlite3.ts b/packages/libsql-client/src/sqlite3.ts index 3044da31..e9e7940d 100644 --- a/packages/libsql-client/src/sqlite3.ts +++ b/packages/libsql-client/src/sqlite3.ts @@ -724,8 +724,6 @@ function rowFromSql( intMode: IntMode, ): Row { const row = {}; - // make sure that the "length" property is not enumerable - Object.defineProperty(row, "length", { value: sqlRow.length }); for (let i = 0; i < sqlRow.length; ++i) { const value = valueFromSql(sqlRow[i], intMode); Object.defineProperty(row, i, { value }); @@ -740,6 +738,11 @@ function rowFromSql( }); } } + // make sure that the "length" property is not enumerable, unless a column + // is named "length", in which case the column value takes precedence + if (!Object.hasOwn(row, "length")) { + Object.defineProperty(row, "length", { value: sqlRow.length }); + } return row as Row; } diff --git a/packages/libsql-core/src/util.ts b/packages/libsql-core/src/util.ts index 6d5bdbb6..ce72716c 100644 --- a/packages/libsql-core/src/util.ts +++ b/packages/libsql-core/src/util.ts @@ -50,7 +50,7 @@ export class ResultSetImpl implements ResultSet { return { columns: this.columns, columnTypes: this.columnTypes, - rows: this.rows.map(rowToJson), + rows: this.rows.map((row) => rowToJson(row, this.columns.length)), rowsAffected: this.rowsAffected, lastInsertRowid: this.lastInsertRowid !== undefined @@ -60,8 +60,14 @@ export class ResultSetImpl implements ResultSet { } } -function rowToJson(row: Row): unknown { - return Array.prototype.map.call(row, valueToJson); +function rowToJson(row: Row, columnCount: number): unknown { + // a column may be named "length", in which case `row.length` is the + // column value rather than the number of columns + const values: Array = []; + for (let i = 0; i < columnCount; ++i) { + values.push(valueToJson(row[i])); + } + return values; } function valueToJson(value: Value): unknown {